Skip to content
happyprimePublic

About

No description, website, or topics provided.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Latest commit

 

History

38 Commits

Folders and files

Repository files navigation

happy-env

Shared local development for WordPress projects. A project opts in with one small file and gets a trusted-HTTPS site — no per-project proxy, certificate, database, port, or /etc/hosts work.

*.example.dev  ──►  127.0.0.2  ──►  Traefik  ──►  your site container
                                                  │
                                    one MariaDB ──┤  (one database per site)
                                    one Mailpit ──┘  (one inbox for all of them)

Every project shares one proxy, one database server, one inbox, and one certificate. Adding a project costs a config file and a schema — not another MySQL container, another core download, and another port to keep out of everything else's way.

This suits sites. Plugin and theme repos are usually better served by wp-env, which does multi-version testing and PHPUnit scaffolding that this deliberately doesn't.

Requirements

  • Docker. Docker Desktop, OrbStack, or Colima.
  • mkcert, with its root CA trusted (mkcert -install). That touches your system trust store and needs a password, so it can't be done for you.
  • Node 20+.
  • macOS or Linux. Windows/WSL2 isn't supported: Docker Desktop publishes ports on the Windows side, where binding a loopback alias isn't dependable.

Pick how hosts resolve

This is the one decision to make up front, and the only part that isn't automatic. Your site's hostname has to resolve to the proxy. There are three ways, and they trade off setup against convenience.

A domain you control (what we do)

Point a wildcard A record at the loopback alias:

*.example.dev.  A  127.0.0.2

Every project on that domain then works with no further DNS work, ever. The certificate is a wildcard, so adding a project needs no certificate work either.

The tradeoffs: you need a domain, and resolving a public record needs a network connection — nothing resolves on a plane (see Working offline). Use a domain you don't also serve from, or a subdomain of one — *.dev.example.com works fine and leaves example.com alone.

.localhost (nothing to set up)

*.localhost resolves to 127.0.0.1 on macOS and on systemd Linux with no DNS record, no network, and no /etc/hosts line. Set a host like mysite.dev.localhost and it just works.

This needs the proxy on 127.0.0.1 rather than the default alias:

{ "bindAddress": "127.0.0.1" }

in ~/.happy-env/config.json. The cost is that the proxy then contends for port 80 with everything else on your machine, which the alias exists to avoid.

Use three labels (mysite.dev.localhost, not mysite.localhost) if you want a wildcard certificate covering siblings — see Certificates.

/etc/hosts

Universal, offline, needs no domain, and is manual for every host:

127.0.0.2 mysite.example.dev

Quick start

npm install --save-dev @happyprime/env
{
	"scripts": {
		"env:start": "happy-env start",
		"env:stop": "happy-env stop",
		"env:cli": "happy-env cli"
	}
}

On macOS, unless you chose 127.0.0.1 above, persist the loopback alias the proxy binds — once per machine:

sudo cp node_modules/@happyprime/env/com.happyprime.loopback-alias.plist /Library/LaunchDaemons/
sudo launchctl load -w /Library/LaunchDaemons/com.happyprime.loopback-alias.plist

Linux needs nothing here: the whole 127.0.0.0/8 routes to loopback already.

Then, in any project:

npm run env:start

The first run generates the certificate and starts the shared services, so there's no separate setup step. start brings up the shared services if they aren't already running, creates the site's database, boots it, and installs WordPress on first run. It finishes by printing the site's URL and the inbox every project's mail lands in.

Configuring a project

A repo is a wp-content directory, so almost everything is read off disk. Most projects need one line:

{
	"host": "mysite.example.dev"
}

Themes, plugins, and mu-plugins are discovered from ./themes, ./plugins, and ./mu-plugins. Symlinks are resolved to their real paths, so shared code linked out to a sibling repo is mounted where it actually lives — a symlink to an absolute host path means nothing inside a container.

Several sites in one repo

Use sites instead of host. Each site gets its own container and database; the key names both. root points at a site's wp-content when it isn't the repo root:

{
	"php": "8.3",
	"wordpress": "7.1.3",
	"config": { "WP_MEMORY_LIMIT": "512M" },
	"sites": {
		"blog": {
			"host": "blog.example.dev",
			"root": "./blog/wp-content"
		},
		"shop": {
			"host": "shop.example.dev",
			"root": "./shop/wp-content"
		}
	}
}

One site, several hostnames

A WordPress network can serve more than one domain from a single database. That is one site as far as this is concerned — one container, one database — but the proxy has to answer for every domain in it, so name the rest as aliases:

{
	"host": "bedsbirdclub.example.dev",
	"aliases": [ "bnhs.example.dev" ],
	"config": {
		"MULTISITE": true,
		"SUBDOMAIN_INSTALL": true,
		"DOMAIN_CURRENT_SITE": "bedsbirdclub.example.dev",
		"PATH_CURRENT_SITE": "/",
		"SITE_ID_CURRENT_SITE": 1
	}
}

host stays canonical: it is the URL WordPress is configured with, the one printed on start, and the one the inbox domain is derived from. An alias only has to route — which site answers on it is the network's business, decided from wp_blogs, not the proxy's. Aliases join the certificate the same way hosts do, each contributing its own parent, so a domain a level deeper brings its own wildcard.

This is for one network across several domains. Separate sites that happen to share a repo want sites instead, so each gets its own database.

Note that WP_HOME and WP_SITEURL are set from host (unless the site sets core), and core removes their filters under multisite — "WP_HOME and WP_SITEURL should not have any effect in MS", in ms-default-filters.php — so a network reads its URLs from the database and the constants sit there harmlessly.

For a network that sets core, the multisite constants above usually belong to the checkout's own wp-config.php, not to config; see below.

Every key

Key Default Notes
host — Single-site shorthand. Mutually exclusive with sites.
sites — Several sites, keyed by slug. The slug names the database.
aliases [] More hostnames routed to the same site. See above.
php 8.4 8.5 works. 8.6 isn't released.
wordpress 7.1 7.1 resolves to its latest patch at creation; 7.1.3 pins.
themes every dir in ./themes Default themes are skipped — core supplies them.
plugins every dir in ./plugins See below.
muPlugins ./mu-plugins Mounted entry by entry, not as one directory.
mounts {} Extra paths, as { source: target }. Targets are relative to wp-content; one starting with / is absolute in the container.
config {} Extra wp-config.php constants.
uploads {} { "fallback": "https://prod" } serves missing media from production.
root repo root Per-site only.
core — A full WordPress checkout to run instead of the image's core. See below.
altis — true for an Altis Local Server repo. Mutually exclusive with host and sites. See below.

Declared vs. discovered plugins. Listing plugins explicitly is an intent to use them, so they're installed and activated. Anything merely found on disk is mounted and left alone — whether it's active is the database's business, and force-activating would silently override a site's real state. Declared entries accept a wordpress.org slug (woocommerce), a pinned slug (woocommerce@10.7.0), a zip URL, or a local path (./plugins/thing, mounted so it stays editable).

A pin is applied when the plugin is first installed. Changing @10.7.0 to @10.8.0 won't move a site that already has it — the plugin is present, so there's nothing to install. happy-env cli <site> plugin update moves it, and reset (or destroy) starts over.

Extra mounts. themes, plugins, mu-plugins, and uploads cover most repos. mounts is for what's left — in practice a composer vendor directory sitting at the wp-content root, which plugins in the same repo autoload from rather than from their own directory:

{
	"host": "mysite.example.dev",
	"mounts": { "./vendor": "vendor" }
}

Sources are relative to the site root, targets to wp-content. A source that doesn't exist is an error at start rather than a puzzle later — a missing vendor says to run composer install.

A target that starts with / is a path anywhere in the container. That's the way in for an Apache or PHP config file, since neither has a home under wp-content. Both kinds can sit side by side:

{
	"host": "mysite.example.dev",
	"mounts": {
		"./vendor": "vendor",
		"./local/site.conf": "/etc/apache2/conf-enabled/site.conf",
		"./local/php.ini": "/usr/local/etc/php/conf.d/local.ini"
	}
}

Apache reads every *.conf in conf-enabled and PHP every *.ini in conf.d, so a new file name adds to the image's config. Reuse a name the image already has only to replace that file outright.

Bring your own core

Some repos track all of WordPress, not just wp-content, and the point of running one locally is to run that core. core names the checkout:

{
	"host": "mysite.example.dev",
	"core": "../mysite",
	"mounts": {
		"./local/env.php": "../env.php"
	}
}

The checkout is bind-mounted whole as the docroot, so it supplies core, wp-content, and its own wp-config.php. Nothing is discovered or installed: themes, plugins, muPlugins, and root are errors beside core, because the checkout already answers all of them. The wordpress key still picks the image tag, and so must be one that exists — but only its PHP and Apache are used; the WordPress inside is ignored.

Nothing is written into the checkout. Docker creates a missing mountpoint as an empty directory on the host, which with a bind-mounted docroot means inside the repo. So a mounts entry into the checkout may only replace a file or directory that is already there, and start refuses one that isn't. This is what makes the shape above work: the repo's wp-config.php includes a gitignored env.php, the checkout has one (its contents don't matter), and the mount swaps in the one that names hp-mysql. If the file isn't there yet, create it — it's gitignored — so there's something to mount over.

Because the image's wp-config.php never runs and no mu-plugin can be added, three things arrive through PHP's auto_prepend_file instead, from services/php/prepend.php: $_SERVER['HTTPS'] is set from X-Forwarded-Proto, config constants are defined, and mail is pointed at Mailpit by seeding $wp_filter before plugin.php loads. config runs before wp-config.php, so its constants win; a wp-config.php that then defines the same constant unconditionally logs a PHP warning. Prefer a swapped env file for anything the checkout's own config also sets.

A checkout whose .htaccess sets php_value auto_prepend_file, as Wordfence's firewall does, replaces this prepend for web requests. That path is usually a production one, so every request fails with a PHP error naming it. If the path does exist in the container, the prepend silently doesn't run, and mail is not caught. Either way, mount a local .htaccess without that line over the checkout's.

WP_HOME and WP_SITEURL are not defined for these sites. The checkout's config and database own its URLs, so point them at host and any aliases — for a multisite that's wp_site, wp_blogs, and wp_sitemeta.

A first start still runs wp core install if the database is empty. A repo like this usually comes with a database rather than a fresh install — and for a multisite config core install can't succeed anyway — so import the dump into the site's database (127.0.0.1:3307, root / password, named after the slug) before the first start. For the same reason reset refuses a core site, since it would drop that import. --force resets it anyway.

Media from production

A repo carries no uploads, so a site troubleshot against real content would need every image synced down first. uploads.fallback skips that: any file missing from the local uploads directory is served from a production origin, and files you do have on disk still win.

{
	"host": "mysite.example.dev",
	"uploads": { "fallback": "https://mysite.com" }
}

A request for /wp-content/uploads/2024/06/hero.jpg is served from disk if it's there and reverse-proxied from https://mysite.com/wp-content/uploads/2024/06/hero.jpg if it isn't — so the media appears to come from the dev site, and nothing in the page markup points at production. Set it per site under sites, or once at the repo root for every site to inherit.

This is the container-side equivalent of a Valet driver's missing-file fallback. Valet routes every request through PHP, so a driver can catch a missing upload; here Apache serves uploads straight off disk and a missing file never reaches PHP, so the fallback is Apache config instead — generated, mounted read-only, and never touching the official image. It's built from mod_proxy + mod_ssl, which that image doesn't enable by default; the generated config loads them, so there's nothing to install.

fallback is for a publicly reachable origin — production, a staging host, a CDN. It resolves over normal DNS from inside the container and its certificate is validated against the image's CA bundle, both of which a real host satisfies. Pointing it at another local *.dev site won't work: that name resolves to the host loopback the container can't reach, and its certificate is the local CA the container doesn't trust.

An Altis project

Altis Local Server runs its own Traefik on :80/:443 of every interface, so under OrbStack it can't run beside the shared proxy (see Sharing ports). Its containers already carry Traefik labels, so the shared proxy routes them instead:

{
	"altis": true
}

start runs the containers Local Server generated in vendor/docker-compose.yml, connects the shared proxy to Local Server's proxy network, and adds the project's hosts to the certificate. stop and status work as usual. The hosts come from name, tld, and domains under extra.altis.modules.local-server in composer.json, with Local Server's defaults: the repo directory's name on altis.dev. Nothing about the project goes in .happy-env.json.

Local Server keeps everything else. Use composer server cli, composer server db, and the rest; happy-env cli, reset, and destroy refuse. Plain composer server start still works once the shared proxy is stopped, so teammates who don't run happy-env are unaffected.

First run. Local Server writes its compose file and creates its network in composer server start, so run that once. With the shared proxy up, it then stops at "Could not start traefik proxy". That's expected. On a fresh install, run happy-env services stop first so Local Server can finish installing, then composer server stop --clean to give back the ports.

DNS. *.altis.dev is public DNS pointing at 127.0.0.1, not the loopback alias. start checks each host and prints the /etc/hosts line a browser needs:

127.0.0.2 example.altis.dev s3-example.altis.dev

With bindAddress set to 127.0.0.1, no line is needed.

How it routes. Two settings in traefik.yml match Local Server's own proxy: TLS on every websecure router, because its S3, uploads, and Tachyon routers don't ask for it; and insecureSkipVerify, because its nginx serves HTTPS with a self-signed certificate. The proxy joins the proxy network under the alias proxy, which is the name Local Server's PHP container links the project's hosts to. That's how S3 Uploads and Tachyon requests from PHP get routed.

What bites. Recreating the shared proxy disconnects it from that network. If the site starts returning 404 after happy-env services stop or a services update, run start in the Altis repo again. Subdomain multisite hosts don't route: the shared proxy's Traefik rejects Local Server's *.example.altis.dev rules and serves only the hosts named outright.

Machine settings

~/.happy-env/config.json holds the few things that are about your computer rather than any project. It's optional, and most people never write one.

Key Default Notes
bindAddress 127.0.0.2 Where the proxy listens. 127.0.0.1 for .localhost.
dashboardHost proxy.localhost Traefik's dashboard. Only reachable if it resolves to bindAddress.
mailHost derived The inbox's host. Defaults to mail. plus your project's parent domain.

Commands

Command Does
happy-env start [site…] Start the shared services and this repo's sites
happy-env stop [site…] Stop containers, keeping them (restart is seconds)
happy-env reset [site…] Drop the database and reinstall WordPress fresh (asks first; --yes skips; a core site needs --force)
happy-env destroy [site…] Remove containers, volumes, and the database
happy-env prune [--yes] Remove what old sites and versions left behind (asks first; see below)
happy-env status What's running
happy-env cli <site> … Run wp-cli, e.g. happy-env cli blog plugin list
happy-env cert [--only] Reissue the certificate (start does this for you)
happy-env services stop Stop the shared proxy, MariaDB, and Mailpit

Admin credentials are admin / password.

How it works

Core comes from the image. Sites run the official wordpress:<wp>-php<php>-apache image, which already contains WordPress — so choosing a version is a tag lookup and a cold boot is seconds. Nothing is downloaded or unpacked per project.

That's also the whole version story: anything is fair game if the tag exists. "wordpress": "beta" follows the next release's latest beta or RC, and "beta-7.1.1-RC1" pins one exactly. A PHP version works the day its image is published and not before.

WordPress nightly/trunk has no image at all. To run trunk, use core with a clone of github.com/WordPress/WordPress, which mirrors the code nightly builds are made from:

mkdir trunk && cd trunk
git clone --depth 1 https://github.com/WordPress/WordPress.git
cp WordPress/wp-config-sample.php WordPress/wp-config.php
{
	"host": "trunk.example.dev",
	"core": "./WordPress"
}

In wp-config.php, set DB_NAME to trunk (the slug), DB_USER to root, DB_PASSWORD to password, and DB_HOST to hp-mysql. The first start installs WordPress, and git pull in the clone moves it forward. Its wp-content is the clone's: put a plugin under test in WordPress/wp-content/plugins, or create an empty folder there and mount the plugin over it.

Security updates arrive by re-pull. Images are rebuilt under the same tag for each security release, but Docker never re-pulls a tag it already has. So start pulls a site's images, and the shared services', when the last successful pull is a day old, and recreates whatever changed. Offline, it says so and starts with what's there. MariaDB follows lts, and upgrades its data in place when that moves to a new release, after backing up its system tables into the data volume.

Only tags for current WordPress releases are rebuilt: once 7.2 ships, 7.1 stops getting PHP fixes, and so does a pinned 7.1.3 once 7.1.4 does. Pinning WordPress pins PHP too. Check a running site with docker exec hp-site-<slug> php -v.

A site with core set still takes PHP and Apache from the image, and the daily pull, but not its WordPress: the checkout is the docroot and the image's core is never copied anywhere. Everything below about volumes is about sites without it.

Core lives in a volume named for the WordPress version, so switching versions picks up the right core. The image's entrypoint copies core in only when the volume has none, and never looks at the version again either way — so a single volume would go on serving whatever booted into it first, no matter what the tag and the config now say. That's the worst way to fail when the reason you pinned a version was to test against it. Databases and uploads live outside that volume, so switching back and forth costs nothing.

The volume is keyed on the version as you wrote it, which has one consequence worth knowing: "wordpress": "7.1" is a single volume even though the tag behind it moves with every patch. A site created on 7.1.3 stays on 7.1.3 — destroy and start again is what picks up 7.1.4. Pin fully ("7.1.3") if you want the version to be unambiguous on sight.

Leftovers pile up. Each version a site has run keeps its core volume and image, and a stopped site keeps its container. destroy clears one site, but only from inside its repo. happy-env prune works from anywhere, across the whole machine. It lists, then asks before removing:

  • site containers stopped more than 30 days;
  • core volumes no remaining container uses;
  • wordpress site and wp-cli images no remaining container needs, including untagged ones a re-pull left behind.

Databases, uploads, and the shared services are never touched. Nothing is forced: if Docker refuses to remove something, prune reports it and moves on. A pruned site's next start recreates its container and core volume, and installs its declared plugins again. Anything installed by hand with cli, such as an undeclared plugin or a language pack, lived in the core volume and is gone.

One MariaDB. Every site is a schema on the shared server, not a container of its own. Adding a site costs a database, and a test database costs the same. It's published on 127.0.0.1:3307 for host tools, since a Homebrew MariaDB usually holds 3306. Sites reach it as hp-mysql over the network.

Routing is by label, not port. Site containers join the hp-env network and Traefik routes on the Host header, so nothing publishes a host port. There are no ports to allocate and none to collide — which is worth stating plainly, because port collisions are the most common way an e2e suite fails, silently running against whatever WordPress answered instead.

Mail is caught, never sent. One Mailpit holds every project's mail, the same way one MariaDB holds every project's database. Sites reach its SMTP listener as hp-mailpit on the shared network, and an mu-plugin mounted into every site points PHPMailer at it — hooked last, so a site configuring its own SMTP transport still has its mail caught rather than delivered from your laptop.

The inbox is at https://mail.<your domain> — derived from the domain your project already uses, so the wildcard DNS that resolves your sites resolves it too and the certificate already covers it. A repo on foo.example.dev puts the inbox on mail.example.dev. Set mailHost if your projects don't share a domain, or if you'd rather name it yourself.

Nothing is published on the host. The usual 1025 and 8025 stay free, so a Homebrew Mailpit or MailHog can go on running beside this one.

Site config is eval'd, not written. The official image's wp-config.php runs if ($configExtra = getenv_docker('WORDPRESS_CONFIG_EXTRA', '')) { eval($configExtra); } just before it requires wp-settings.php, so WP_HOME, WP_SITEURL, and anything in config are applied as part of wp-config — and because core hasn't loaded yet, early enough that it derives WP_CONTENT_URL and WP_PLUGIN_URL from the right URL. No mu-plugin is involved. The image also sets $_SERVER['HTTPS'] from X-Forwarded-Proto on its own, so terminating TLS at Traefik needs no handling at all.

Certificates

One certificate serves every project on the machine. It lives in ~/.happy-env/certs rather than in the package: npm may replace node_modules wholesale on any reinstall, and a copy per project would be worse than useless.

Its names are derived from the hosts your projects declare — each host's own parent, deduplicated — and recorded in ~/.happy-env/certs/coverage.json. start reissues only when a project brings a name that isn't covered yet, and restarts the proxy when it does, since Traefik reads the certificate once at startup.

Names accumulate across projects. A machine running one project on *.example.dev and another on *.other.dev has one certificate carrying both; dropping a name the moment a project stopped using it would mean reissuing every time you switched projects. A stale name costs nothing. happy-env cert --only reissues from just the current project when you want it gone.

Where a wildcard is used, and where it isn't. Hostname verification refuses a wildcard that spans a registry-controlled domain: *.dev and *.com are rejected, and an unknown TLD counts as a registry too — which is why the *.test scheme this replaced could never work. So:

Host Certificate name
mysite.example.dev *.example.dev
www.foo.example.dev *.foo.example.dev
mysite.dev.localhost *.dev.localhost
mysite.localhost mysite.localhost
example.dev example.dev

The last two get no wildcard — the name above them is a registry — so each such host is named outright and a sibling means reissuing. That's the reason to prefer three labels.

Wildcards are also only one label deep: *.example.dev does not cover www.foo.example.dev. Deriving each host's own parent is what handles that, so nesting needs no thought.

Sharing ports

Only one proxy can hold :80/:443 at a time under OrbStack. start checks first and names whatever is holding the ports, rather than letting Docker report an address and leave you guessing.

This is worth explaining, because the obvious fix doesn't work. Traefik binds a loopback alias precisely so it could sit beside something on 127.0.0.1. Under Docker that does work. Under OrbStack it doesn't: ports are allocated by number and routed to a single container regardless of which host IP the binding names. Two proxies on 127.0.0.1 and 127.0.0.2 both start happily, and then one of them silently answers for both addresses — verified by watching one stack's proxy serve another's certificate while its own sat there healthy and unreachable. Picking a third loopback address changes nothing.

It doesn't help that some stacks publish '80:80' with no host IP at all, which is every interface — so they conflict with anything on those ports no matter what.

It doesn't have to be a container. Laravel Valet, a Homebrew nginx, an Apache left running from something else — anything holding :80/:443 on the host does this too, and that case is the more confusing one, because nothing reports it. compose up succeeds, Traefik starts and logs nothing wrong, and the published port simply never listens: every site is unreachable while the proxy looks perfectly healthy. start checks for these too and names the process. For Valet, valet stop frees the ports.

So, under OrbStack: mutual exclusion, until either it honours host IPs or one proxy fronts both stacks. For Altis Local Server, the shared proxy does front both; see An Altis project.

Working offline

Public DNS needs a network, so nothing resolves on a plane. Add the hosts you need to /etc/hosts:

127.0.0.2 mysite.example.dev blog.example.dev

That's deliberately manual. A local dnsmasq answering *.example.dev would be automatic, but it would also hijack the real example.dev and www on your own machine — it can't wildcard a domain you actually use without shadowing it. It would also bind :53, which is its own conflict to manage.

.localhost hosts resolve offline with no help at all, which is a reason to prefer them if you fly a lot.

Gotchas worth knowing

  • Compose interpolates $. Anything with a dollar sign in a compose file — PHP like $_SERVER, most obviously — is expanded as a variable and silently becomes an empty string, leaving wp-config.php syntactically broken and taking wp-cli down with it. Values are escaped ($$) on the way out; see escapeForCompose().
  • WORDPRESS_CONFIG_EXTRA is eval'd, so it must be bare PHP with no opening tag, and a syntax error breaks the site and wp-cli.
  • Never mount a directory at wp-content. It becomes the parent of every other mount, so Docker creates mountpoints inside the real repo — writing stray files into it, and failing outright for single files. Mount pieces into the container's own wp-content instead.
  • Every wp-cli call is a container that loads WordPress, which is seconds on a large site. Batch them. An already-installed site should make one call and stop.
  • Changing php or wordpress changes the image tag, but core lives in a volume that survives. happy-env destroy <site> and start again if a version change doesn't take.

Not solved yet

Content. A repo is wp-content, so there's no database in git and happy-env start gives you an empty WordPress. Uploads have an answer now — uploads.fallback serves missing media from production — but the database doesn't. Seeding a sanitized dump is the piece still missing, and lifecycle hooks (afterInstall) are the shape it probably wants.

Extra services. Redis and Elasticsearch have nowhere to go today. A services key merged into the generated compose file is the natural hook; nothing in the current structure fights it.

xdebug. The official image doesn't ship it, so this needs a derived image.

Working on this package

A project pins a version, so editing environment doesn't change what that repo runs until the version is bumped and reinstalled. That's the point — a teammate's npm ci gets byte-identical tooling — but it makes for a slow loop when you're changing the CLI itself. Either run the working copy directly:

node ../../environment/bin/happy-env.mjs start

or npm link it into the project you're testing against.

To release: bump version, commit, tag vX.Y.Z, push the tag, npm publish, then bump the dependency in each consuming repo.

About

No description, website, or topics provided.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages