diff --git a/README.md b/README.md
index bf41d40d1..32b9b7108 100644
--- a/README.md
+++ b/README.md
@@ -47,9 +47,9 @@ Lerd is built for PHP developers on Linux who want frictionless local developmen
- 📦 **Node.js isolation.** Node 22 or 24 per project, through the bundled fnm or an nvm you already have, switchable from the dashboard. Or **bun** as the JS runtime on the host and, opt-in, inside the container.
-- 🪄 **No per-framework setup.** Workers, env values and the nginx vhost are all configured for you when you link a project.
+- 🪄 **No per-framework setup.** Workers, env values and the nginx vhost are all configured for you when you link a project. "Env" means whatever file your framework actually reads: a `.env`, WordPress's `wp-config.php`, Magento's `env.php` or the `$databases` array Drupal keeps in `settings.php`, written in place with the rest of the file left exactly as it was.
-- 🧩 **Framework store.** Community definitions for Laravel, Symfony, WordPress, Drupal, Magento, CakePHP and Statamic with versioned auto-detection, back to the majors that still run on PHP 7.4.
+- 🧩 **Framework store.** Community definitions for Laravel, Symfony, WordPress, Drupal, Magento, CakePHP, CodeIgniter, Statamic and Tempest with versioned auto-detection, back to the majors that still run on PHP 7.4. Each one carries its own mark and brand colour, so a framework published tomorrow arrives complete without a new lerd release.
### Services and databases
@@ -73,6 +73,10 @@ Lerd is built for PHP developers on Linux who want frictionless local developmen
- 🖥️ **Built-in Web UI.** Sites and services dashboards, live widgets, a global Cmd+K command palette, and install/remove of PHP and Node versions from the System page. Available in fourteen languages. Reachable from another machine behind credentials when you expose it, where the actions that touch the host itself, terminals, filesystem browsing, raw `.env` reads, stay local until you grant them explicitly.
+- ✨ **Start a project from the dashboard.** The `+` in Sites scaffolds a new project from the framework store or links one you already have, asks the same questions `lerd init` asks about PHP version, Node, HTTPS and services, and then runs the setup steps with composer and the JS build streaming into the modal. Close the tab mid-install and it picks back up where it was.
+
+- 📚 **The documentation, offline.** Every page ships inside the binary, searchable and rendered in the dashboard, so the one moment you most need the docs, a machine with no internet, is not the moment they stop working. `lerd man` reads the same pages in the terminal.
+
- 💻 **Terminal dashboard** (`lerd tui`). A btop-style TUI with live status, site detail pane, inline domain and version editing, shell drop-in, log tailing, and filter/sort, the same operations surface as the web UI, for tmux and SSH workflows.
- ✏️ **Edit config in the browser.** Per-site and global nginx, `php.ini` with the version's own file and the shared scope side by side, `.env` files, and database/service runtime tuning, each validated (`nginx -t` where it applies), with timestamped backups and one-click restore.
@@ -87,9 +91,11 @@ Lerd is built for PHP developers on Linux who want frictionless local developmen
- 🧰 **Environment doctor** (`lerd doctor`). Checks the host lerd itself depends on and repairs what it safely can with `--fix`: missing directories, linger, a missing PHP image, the DNS wiring. Anything needing sudo is printed as a command and never run for you, and `--dry-run` shows it first.
-- 🩺 **Site doctor.** Health checks that apply to any project (env drift, application key, composer and node install state, security audits, database presence, PHP version range) plus extra checks for your framework, with one-click fixes. From the web UI, the TUI, `lerd site:doctor` and MCP.
+- 🩺 **Site doctor.** Health checks that apply to any project (env drift, application key, composer and node install state, security audits, database presence, PHP version range, a key an env file sets twice) plus extra checks for your framework, with one-click fixes. It installs and starts the services a site declares, rewrites an nginx vhost that has drifted from what lerd would write today, and points a project at the database it picked, all from the button that reported the problem. From the web UI, the TUI, `lerd site:doctor` and MCP.
-- ⚒️ **Worker self-heal.** Failed queue, schedule, horizon, reverb and stripe workers are surfaced everywhere (CLI, dashboard banner, TUI, MCP) and recovered with one click or `lerd worker heal`.
+- ⚒️ **Worker self-heal.** Failed queue, schedule, horizon, reverb and stripe workers are surfaced everywhere (CLI, dashboard banner, TUI, MCP) and recovered with one click or `lerd worker heal`. A worker that keeps failing can be stopped from the same banner rather than restarted into the same wall.
+
+- 💾 **Nothing destructive without a way back.** A `service remove --purge` or a `reinstall --reset-data` takes a full snapshot of every database first, while the data is still where the engine expects it, so getting it back is an ordinary `db:restore -A` rather than a data directory only the old image could open. Each engine declares how long it gets to shut down, so a database finishes its checkpoint instead of being killed mid-write.
- 💤 **Idle-suspend.** Activity-driven suspension of a site's workers (queue, schedule, horizon, reverb, stripe, Vite) after a configurable idle timeout, resumed on the next request, CLI command, MCP call or file save, with per-site pinning.
@@ -249,8 +255,12 @@ Once it's installed, `lerd dashboard` opens the app instead of the browser.
cd my-laravel-project
lerd link
# → https://my-laravel-project.test
+lerd open # the site in your browser
+lerd code # the project in your editor
```
+Starting from nothing, `lerd new` asks which framework and which major from the store, scaffolds it, and links the result, so you land on a served site rather than on three commands to type.
+
`lerd install` already starts everything for you on first run, so you can `lerd link` immediately. Day-to-day:
```bash
diff --git a/docs/.vitepress/theme/components/LandingPage.vue b/docs/.vitepress/theme/components/LandingPage.vue
index c041a7858..32a568e8a 100644
--- a/docs/.vitepress/theme/components/LandingPage.vue
+++ b/docs/.vitepress/theme/components/LandingPage.vue
@@ -525,11 +525,11 @@ onBeforeUnmount(() => {
-
+ NEW
- v1.32.0 dev digest
- A public share through the reverse proxy you already run, managed engines reachable from the LAN, and daemons that stop working while the machine is idle.
+ v1.33.0 dev digest
+ Every framework's own config file read and written where it lives, a dashboard that creates a project and takes it through setup, and marks and brand colours that ship from the store.Read the digest →
diff --git a/docs/changelog.md b/docs/changelog.md
index 0e81486f7..ea6566c39 100644
--- a/docs/changelog.md
+++ b/docs/changelog.md
@@ -7,6 +7,85 @@ Lerd uses [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
---
+## [1.33.0] - 2026-08-13
+
+The second design law says no feature may know the name Laravel, and this release is what happened when a Drupal site was linked and every surface was asked about it in turn. The databases card read `DB_HOST` and `DB_DATABASE` out of a `.env` that a Drupal or WordPress project does not have, so neither was ever shown as owning the database it runs on. The doctor reported an empty database and offered migrations against a 0-byte file the application never opens, took its migrate button from the literal word Laravel spells that command with so Symfony and Drupal got a finding naming a remedy the user then had to go and find, and called a site healthy while it picked mysql in `.lerd.yaml` and served every request off SQLite. `lerd env` reported success and appended PHP constants to a `settings.php` sitting next to the `$databases` array Drupal actually reads. `mail()` could not deliver anywhere in any container while Mailpit ran the whole time, and Laravel escaped that one only because it speaks SMTP itself. These were never separate bugs so much as one assumption made in a dozen places, and each of them is answered from the framework's own declaration now: which file, in which format, at which keys, with which command.
+
+The dashboard stopped being somewhere you watch lerd and became somewhere work starts. The `+` in Sites was a directory browser that streamed a link and stopped there; it is a wizard now that either creates a project from the catalogue the store publishes or links one that exists, asks the questions `lerd init` asks instead of taking the defaults and letting the user find out later, and runs the setup steps with their output streaming into the modal, so the composer install and the JS build happen where the work was started rather than back in a terminal. Those questions and that step list are not a copy of the terminal's, they are the same plan, which is what `lerd setup --list-steps` and `--step` exist for. The documentation moved to the copy already embedded in the binary, searchable and rendered offline, which is the one moment a developer most needs it. Sites, services and workers are drawn the same way wherever you meet them now rather than as three hand-rolled row layouts, and both stores learned to carry the mark and the brand colour each thing is drawn with, so a service published tomorrow arrives with its own logo and no Go change.
+
+More of what lerd does is a line of YAML than it was a release ago: how long an engine gets to shut down, which of its complaints are structural noise on a restore, the command that clears a framework's cache, the one that runs its migrations, whether its request pipeline does enough of its own querying to decline the repeated-query warning, whether a setup step belongs only on a project nobody has bootstrapped yet, and the mark and colour it wears. That is only safe because a definition reaches every install within a day whatever binary it runs, with no version gate in the index or the schema, and this cycle is when that stopped being a hope. Writing an env format a binary had never heard of used to fall through to the dotenv writer, which appends `key=value` lines into a PHP file and takes the site down with a parse error on a machine whose owner did nothing but leave lerd running. It refuses now, says so, and leaves the project exactly as it was. The same rule shaped every key added to either schema since: a new field is either ignorable by an older binary, or it is paired with one that is.
+
+The rest is the long tail. A Homebrew upgrade retired the keg that every service unit and every shim had recorded, so the whole install stopped at once on a machine where the developer changed nothing, behind a guard written for exactly that which could never fire on Linux. Deleting a project directory left its certificates, its per-site container, its worktree vhosts and its open shares behind. phpMyAdmin opened on whichever engine happened to sort first rather than the one its preset declares, so a user's mysql databases looked like they had vanished, and every POST it made came back as a connection error because the dashboard overlay put its session cookie in third-party territory. `service remove --purge` renamed the data dir aside and called that recoverable when only the image that wrote it could ever open it again. And the resources widget reported lerd-ui holding almost two gigabytes while the process held fifty megabytes, measured by a `podman stats` stream whose entire cost was charged to the process doing the measuring.
+
+### Added
+
+- **A site is created, linked and taken through setup from the dashboard** (#1473). The `+` in Sites opened one screen that browsed the host directories and streamed a link. It could not create a project, it never asked what `lerd init` asks, so a site linked from the dashboard took the defaults for PHP version, HTTPS and services with the user finding out later, and nothing ran the setup steps, which is where composer install, the JS install and build, and the workers actually happen. It is a wizard now. The first question is whether to link something that exists or create something new; creating asks for a parent directory and a name and then which framework and which major, from the catalogue the store publishes rather than a list the frontend carries; linking keeps the directory browser it always had, marks directories that are already sites with the domain serving them and hands over to that site rather than linking it twice. Both paths meet at the `lerd init` questions, saved to the project's own `.lerd.yaml` so the answers stay portable, and both end on the setup step list with the ticked steps running one at a time. The questions are not a copy of the terminal's: deciding what to ask about a directory moved into one place the terminal form renders too, and setup step planning came out of `runSetup` as `lerd setup --list-steps`, which prints the plan for a directory as JSON and configures nothing on the way, and `--step`, which runs the steps it names and nothing else. Everything that reaches the host goes through one run endpoint that builds every argv itself from a named kind, so nothing a page sends decides what executes, and it sits behind the same authority the editor and the terminal do. Scaffolding is minutes of composer, so a run lives in `lerd-ui` rather than in the request that started it, its output buffered and replayed when a page attaches, which is what lets the modal be closed or the tab reloaded mid-install and picked back up. A flow left running keeps a bubble in the corner offering to resume it.
+- **`lerd new` is a wizard, and the config files the frameworks it offers actually read are written** (#1435). It scaffolded Laravel without asking and ended by printing three commands to type. On a terminal it asks which framework and which major, offering what the store publishes, defaulting to the current release, then changes into the new project and runs the link flow, so scaffolding lands on a served site. Naming a framework skips the question and a run with no terminal skips all of them, leaving scripts and CI as they were. The catalogue only offers what can actually start a project, since a definition with no create command was being offered and then refused after every question had been answered. Scaffolding straight into a served site made two things reachable that a link and a setup always could have: a fresh Drupal has no `settings.php`, and creating one with a statement per key left a file Drupal's own installer cannot rewrite, so new keys are gathered under the node they belong to and assigned once, the shape a framework writes for itself. CakePHP's connection went to a `config/.env` the skeleton never reads while the file it does read holds a `localhost` that PDO resolves as a socket, which needed the php-array reader to carry a value it cannot evaluate as its source text rather than refusing the file, and the writer to edit only the spans that change instead of reprinting the parsed tree, which on a hand-edited config discarded every comment and the `use function` import its own values depend on.
+- **The documentation is served in the dashboard, offline** (#1456). The documentation button opened lerd.sh in an iframe, so the one moment a developer most needs the docs, a machine with no internet, is the moment the dashboard had nothing to offer. The pages have been embedded in the binary all along, which is how `lerd man` reads them, so the dashboard reads the same set: a list grouped by section, full-text search across every page, and the rendered page beside it. The markdown is written for VitePress and neither glamour nor a plain CommonMark renderer knows what to do with its containers, so a shared normaliser rewrites them into portable markdown before either surface sees a page, which also stops `lerd man` printing container markers and code-group fence labels as literal text. Links between pages resolve to dashboard routes and keep their anchors, screenshots are served out of the binary, and the address follows the page you are reading so any page stays a bookmark. The routes sit outside `/api` because that is what the service worker caches, and the `lerd.localhost` vhost proxies them for the same reason, which existing installs pick up on the next `lerd start`.
+- **A framework's own PHP settings file is lerd's to read and write** (#1388, #1400). Drupal keeps its database in a `$databases` array its installer writes into `settings.php` and reads back on every request, and lerd had no way to speak that file: php-const writes constants Drupal never looks at, and php-array expects a file that returns an array while Drupal's is a series of top-level assignments. The php-vars format addresses those assignments by a dotted path rooted at the variable, reading flattens every top-level assignment in file order the way PHP would run them, and writing rewrites only the statements whose values actually change and leaves the rest of the file byte for byte, which matters because a settings file is mostly guidance a user may have edited. The owning statement is the last one to reach a key, not the first, since PHP runs the file top to bottom and a local override at the end is exactly a later statement reaching the same key; writing the first one leaves the file saying one thing and the application using another, which is worse than either. A hardened file is handled too: Drupal's installer leaves `settings.php` and its directory read-only, which is right for a deployed site and no reason to refuse to configure a local one, so the writer restores permission for the duration and puts the mode back exactly, even when the write fails.
+- **The doctor brings up the services a site declares, and repairs the vhost serving it** (#1421, #1383). The check that catches a missing service printed a command to copy, which was the worst place to stop short given it names the most likely reason a site does not boot at all; both halves are a button now, installing what is missing and starting what is stopped through the same host-side flow the services page uses, so the image pull and the readiness wait are reported as they happen. What counts as declared widens to match, since a project naming a service in its `.lerd.yaml` is saying it needs it as plainly as a framework requiring one. Separately, a vhost is written when a site is linked, secured, renamed or moved to another PHP version and nothing ever looked at it again, so anything that changed what lerd would write left the file saying something else with no surface reporting it. The doctor renders the vhost lerd would write right now and compares it with the file serving the site, naming the first line the two disagree on, and carries a fix that rewrites it and reloads nginx. A vhost lerd swapped on purpose is never reported: a paused site is serving its landing page, an idle-suspended host proxy is serving the waking page, and a worktree has a vhost no site path resolves to. Rendering a vhost and writing one were the same step in eight generators, which is why nothing could ask what lerd would write without writing it.
+- **The doctor reports the services a project picks that its env file does not point at** (#1385). A site picking mysql in `.lerd.yaml` and running on SQLite was called healthy, because every check that reads env content sat behind a dotenv gate and a project whose config is php-const, php-array or php-vars skipped all of them and passed on the strength of its env file merely existing, which is WordPress, Magento and Drupal alike. The comparison needs no parsing and no framework knowledge: the store already declares which services a framework wires into env, and a project points at one when its config names that service's container, which is a text question every format answers the same way. A service the framework does not wire is picked for its own sake and never expected in an env file, a drop-in is measured against the block it stands in for, and services declared externally managed are deliberately not pointed at a container.
+- **A key an env file sets twice is reported, and the editor resolves it** (#1403). A site showed postgres on every surface while the application ran on SQLite, and nothing said why: its env file set `DATABASE_URL` twice, lerd read the first and the framework read the last, and both were behaving correctly. Symfony parses into an array so the last wins, Laravel loads through phpdotenv's immutable writer so the first does, and no single rule can be right for both, which is why this reports rather than resolves. The finding names the key, how many times it is set, the value lerd uses and the value at the end of the file. The env editor shows the same conflict against the buffer rather than against disk so it tracks edits, listing each occurrence with its line number, and keeping one drops the others as an ordinary unsaved change the user reviews and saves. Only dotenv files are ambiguous this way, since a PHP file's duplicate has one answer the language decides.
+- **Workers are cards on the dashboard, with a way out of one that keeps failing** (#1480). The workers widget was a run of anonymous text rows that said nothing about what each unit was. Every worker type is a line of its own now carrying its mark, its name and a summary of the states present, opening to one row per unit with the site it runs for and, for a failing one, the last line it printed. Groups start closed except one holding a failing worker, and what you open is remembered. A worker that keeps failing had no way out: systemd restarted it until the rate limit and the only action offered was to heal it, so the choice was to start it again or dismiss the banner and watch it come back. The banner offers to stop every worker it is reporting, running the same teardown the site's own toggle does, and a disabled unit is one the detector deliberately leaves alone afterwards. A downed worker was also announced twice on an open dashboard, once as a banner and once as a persistent toast, and a worker's log tab vanished the moment it stopped, taking the only view of what it printed on the way down; a tab is shown for every worker the site has now, drawn muted when it is stopped.
+- **Services and frameworks ship their own mark and brand colour from the store** (#1457, #1458, #1465, #1468). A preset named its icon in YAML while the icon itself lived in the binary, so a service added to the store could only borrow one of fourteen bundled glyphs, and the colour was in the binary too, every service taking the tint of its category, which is why mysql, mariadb and postgres all rendered the same indigo. Both stores carry an SVG beside the definition and a colour declared in it now, fetched and cached the way the definition already is, served to the dashboard by `lerd-ui` from its own copy so a mark keeps drawing offline and over remote access, with a sweep on the index refresh cadence caching the marks of presets that are not installed so the discovery grid shows a logo before you have ever run one. The mark is a monochrome silhouette rendered through `currentColor`, since full-colour brand marks would sit badly beside the outlines they render next to and would not survive a theme flip, and because it is remote markup heading for an HTML block it is cut down on the way in to a plain drawing subset. The colour arrives as a custom property rather than a class, validated as a plain hex, with a tone derived per theme so a near-black brand still separates from the dark card. A framework's mark is per family since Laravel 11 and Laravel 12 are the same logo, while its colour is declared in each version file. An admin UI with no mark of its own draws the mark of the engine it administers, read from the `admin_for` its preset already declares, so pgAdmin comes up as PostgreSQL and no service is named anywhere in Go.
+- **A framework rule can require a file to be absent** (#1452). A rule could only assert that something is present, so a step that bootstraps a project had no way to say it belongs on a project nobody has bootstrapped yet, and `default: false` is not protection since `lerd setup --all` runs every step it is offered and a setup step has no confirm. Rules take `missing_file` now, which matches while the named file does not exist. Only a genuine absence counts, so an unreadable path leaves the step hidden rather than offering it on a live project, and an older binary that has never heard of the field reads an empty rule, which never matches, so the step stays hidden there too rather than appearing unguarded.
+- **The framework cache is cleared when the database it was built against changes** (#1409). Repointing a site at another database left it serving errors from container definitions the framework had cached against the old one, while the connection lerd wrote was correct and the console could bootstrap against it perfectly well. A definition names the console subcommand that clears its caches and lerd runs it through the console binary the definition already declares, since every framework spells it differently. It runs only when the site actually moved database, matched against the keys the framework's own database services declare, so a rewritten URL or mail setting is not a reason to rebuild a cache.
+- **A framework can decline the repeated-query warning** (#1414). Browsing a content management system's admin fires it again and again and none of it is actionable, because the entity, config and cache layers issue the repeats during an ordinary request. lerd cannot tell those apart from a developer's own loop, so a definition can set `notifications.nplusone: false`. A framework that says nothing keeps the warning, which is most of them, since where the queries come from code a developer writes the warning is worth having and the existing global toggle would take it from those sites too.
+- **A service declares how long it gets to shut down** (#1439). Every container was held to the same five seconds before podman sent SIGKILL, a window picked so a slow image could not stall a stop, which is right for selenium and wrong for a database checkpointing a large buffer pool: killed part-way through, its data files are left dirty and the next start spends minutes replaying the write-ahead log. Services declare `stop_timeout` now, defaulting to the existing five. The unit gets that window plus fifteen seconds, since podman only starts counting once the stop reaches it and still has to reap the container afterwards, and nothing set a manager timeout before, which left it inheriting a `DefaultTimeoutStopSec` that the Arch family ships at ten seconds, short enough to cut even the old default.
+- **A destructive service operation takes a snapshot first** (#1447). `service remove --purge` and `service reinstall --reset-data` renamed the data dir aside and called that recoverable, when it is only recoverable by the image that wrote it, so a reinstall landing on another version leaves a directory nothing installed can open, which is exactly the case where somebody wants their data back. Both wipes take an all-databases snapshot before anything stops and while the data is still where the engine expects it, named `pre-remove` or `pre-reset-data` with a timestamp so it still reads for what it is months later, and getting it back is the ordinary `db:restore -A`. It is automatic rather than offered, because the person it exists for is the one who did not think to ask, with `--no-snapshot` for an engine that will not come up or data known to be disposable, and a snapshot that cannot be taken stops the operation rather than wiping without one.
+- **An import action declares the complaints its engine always makes** (#1449). Restoring an all-databases snapshot on postgres ended with a warning naming six errors and the restore had worked: the dump's own preamble drops and recreates the role it is connected as and the template databases the image ships, so four of those failures are structural and reproduce in two throwaway containers with no user data in play. An import action can declare `expected_errors`, matched as substrings against the engine's own complaint lines and left out of the tally. Which of an engine's complaints are structural belongs next to the image that produces them, so this is a line in the definition rather than a list of postgres strings in Go. The filter stays narrow, since a real failure in the same load is the whole reason the tally exists.
+- **The environment is torn down when macOS signals a logout** (#1427). The Podman Machine VM was killed abruptly on restart, so a timescaledb service came back into a multi-minute crash recovery replaying its write-ahead log, because no lerd process was acting on the SIGTERM launchd sends at logout. The watcher already runs under launchd for the whole session, so it runs the same teardown `lerd quit` does before it exits, containers and workers first, then the VM, then the remaining host processes, with that logic moved into a shared package rather than the daemon shelling out. Two things make the signal ambiguous and both are handled: the watcher must never stop its own unit, since asking launchd to bootout the job it is running inside blocks until that process exits, and `lerd install`, `lerd update` and `lerd quit` all stop the watcher with the same SIGTERM, so every path that does marks it first. The watcher plist alone gets a 180 second `ExitTimeOut`, because the containers stop before the VM does and a database is entitled to the `stop_timeout` its service declares. It is macOS-only: podman runs natively on Linux so no VM outlives the session, systemd already stops the container units honouring each declared timeout, and a plain `systemctl --user stop lerd-watcher` there would otherwise take the whole stack with it.
+- **The framework store is seeded on install, and scaffolding uses the published definition** (#1429). A fresh install had no framework store on disk and the cached index only appeared once the watcher reached its first refresh, so until then the only definitions lerd could resolve were the two compiled into the binary and detection was narrower than it would be a few minutes later. Install pulls the index early, before the vhost pass that resolves a definition per site, then fetches every definition the index publishes. The refresh at the end of install built its target list by reading the store directory, so it could only revisit definitions already there and did nothing on a machine with none; it targets everything the index publishes now, and keeps refreshing a definition the store has since stopped publishing rather than leaving a project on a copy that never updates again. `lerd new` resolved its definition through a lookup that returned the built-in immediately for laravel and symfony, so it scaffolded with the create command baked into the binary rather than the one the store publishes today.
+- **`lerd code` opens the project in the configured editor** (#1451). `lerd open` sends the site to the browser and nothing sent it to the editor outside the dashboard's own links. It takes the same shapes as `lerd open`, so no argument opens the site rooted at the current directory, a name comes from the registry, and inside a git worktree it opens the checkout you are standing in rather than the parent whose registration it inherits. The resolution behind the dashboard links moved into a package of its own so the two share one editor probe list, a configured template is reused with the directory in place of `{file}` and `{line}` dropped along with whatever introduced it, and with none of the known GUI editors present the command says to set the editor key rather than falling back to the platform opener, which would hand the directory to the file manager.
+- **The testing database is offered along with the one it pairs with** (#1459). Databases are created in pairs, so dropping one half left the other in the list with nothing pointing at it. The drop confirm carries a checkbox naming the testing database in full, ticked by default, appearing only on the half that has a pair. Both names travel in one request that either takes the pair or reports which half it could not, keeping the naming convention in the daemon that owns it, and both are validated before either is touched so a sibling whose suffix pushes past the length limit cannot cost the database it tests.
+- **A query trace's file path is copyable** (#1466). The primary file path in a query trace gains a button that copies the `file:line` reference with a brief confirmation, contributed by @raisilhamn.
+
+### Changed
+
+- **The dashboard draws a site, a service and a worker the same way everywhere** (#1454, #1455, #1460, #1465). The Sites and Services widgets each hand-rolled their own rows, a status dot next to a label, while the tabs behind them drew the same things as proper cards, so one object read two ways depending on where you met it. Both widgets render the tile the grid already uses, in a compact variant that reaches the card shell and the icon, which brings the running state in words, the update marker, the app name, the PHP version, the TLS padlock, the tunnel and LAN markers and a dot per running worker onto the dashboard for free. What stays with a widget is the selection rather than the markup. A service rendered one pill per site under its header, so a service a dozen sites use pushed the pane down before anything about the service was on screen, and every pill carried a dot hardcoded to grey that read as a status indicator while telling the user nothing; those collapse into a single button beside the header's action menu, dropping down a row per site coloured by what that site is doing and closed by the mark of the framework it runs. The PHP version pane and the Node.js card, which pasted every site on a version underneath it as a chip, use the same control. A site with no favicon led with a status dot that said only whether FPM was up, in the one slot a row has for identity, while the indicators beside it already carried that state; its framework's mark goes there instead.
+- **The resource widget measures from the cgroups rather than from `podman stats`** (#1425, #1422). Refreshing it spawned `podman stats` and streamed it for three seconds to keep the second sample, and that client runs as a child of `lerd-ui`, so every cycle of it was charged to the process doing the measuring: about eighty milliseconds of CPU per refresh against a four millisecond baseline, with the 900 ms rate window deliberately opened on top of that work, so the row read 8 to 9 percent of a core while the honest average was near one. Every lerd container is a quadlet unit and `cpu.stat` sits in the same cgroup directory, so one pass over a handful of files measures the containers and lerd's own daemons alike: eighty milliseconds of CPU became one, a three second stream became twenty six milliseconds of wall time, and one subprocess became none. The rate is taken between refreshes rather than inside one, so a row reports what that service cost over the interval, idle stretches included. CPU reads as a share of the whole machine on every row now, the way the memory column already did, so the rows and the headline are finally the same measurement and the list adds up to the total it sits under. podman stays the source on macOS and on any Linux install whose units do not answer.
+- **SQLite stops being treated as a service a project installs** (#1404). A site page showed sqlite as a service that was not installed, with an offer to install it. There is nothing to install: it has no preset, never runs in a container, and is a file the application opens. It got there because the database step offered it to every framework, hardcoded at the top of the list in Go, and the choice was written into `.lerd.yaml` as a services entry alongside the services lerd really does run, with everything downstream treating it as one. Which databases a framework can use is the definition's to declare, so the option comes from a declared sqlite wiring now, sitting in a field of its own beside the services rather than as a key among them, because an already-released binary reads that map as things to start. Choosing it records nothing, since the project's own configuration already says it is on SQLite, and projects carrying the entry from an older lerd are unchanged on disk and it is ignored where it is found.
+- **Which SSH provider, which port and which stop window a thing gets are the store's to say, not the binary's.** The pattern behind `stop_timeout`, `expected_errors`, `missing_file`, `migrate_command`, the cache-clear command, the nplusone declaration, the sqlite wiring, and the marks and colours is the same one: a capability ships in the binary and the values follow separately, so an install that never takes a new release still picks up the data. Every key added this cycle was checked against a binary built before it, and the ones an older binary could act on wrongly are paired rather than published alone.
+
+### Fixed
+
+- **A site whose configuration is not a `.env` was invisible to its own database** (#1373). A linked WordPress site was never shown as the owner of its database: the card read `DB_HOST` and `DB_DATABASE` out of the site `.env`, and a WordPress project has neither, its configuration living in `wp-config.php` as PHP constants with its database called `DB_NAME`. The db commands had the same assumption one layer down, resolving the file and format properly and then still asking for the Laravel keys by name, and the service usage count scanned `.env` for the literal `lerd-`, so a project keeping its configuration anywhere else was invisible to that too. All of it was already published in the definition. Which database on which service a site uses is answered once now, from the env file the definition names, read in the format it names, at the keys derived from its service vars. A framework wiring its database through a single connection string is read from that value instead, and a project lerd recognises no framework for still resolves through the default keys and a walk for a DSN aimed at a lerd engine. Worktree database isolation had grown its own near-identical copy of the binding logic and it is gone.
+- **A project newer than the newest published definition resolved no framework at all** (#1374). A WordPress 7 site on a machine that had never installed one asks the store for `wordpress/7.yaml`, a request that can only 404, and the recovery only ever looked downward, so the site ended up with no framework and lost its workers, doctor checks, env wiring, custom commands and database with it. What a project version is measured against is the published list as well as the files installed now, so a legacy project on a fresh machine clamps to the oldest published definition and one above the range is served the newest, which is the same one the on-disk fallback always picked. A version the cached index does not list is no longer asked for, which also drops a store round trip that fired on every single resolution for such a project.
+- **A site linked before its definition existed carried an empty framework forever** (#1376). A WordPress site showed no framework badge while `lerd run` in the same directory listed its WordPress commands, because every framework field on an enriched site starts from the copy of the name held in the registry and an empty name was refused before any definition was looked at. That copy is written once, when a link plan is built, and nothing revisited it. It falls back to detecting one from the project when the registry holds nothing, so an affected site repairs itself on the next read instead of needing a relink, while a registry entry that has a name still wins. The empty value got there because resolving a framework for a link dropped the name entirely when no definition could be found; a project that names a framework is that framework whether or not lerd has a definition to hand.
+- **A framework's document root was outvoted by one lerd guessed at link time** (#1382). A Drupal site answered 403 for every request, its vhost rooting the site at the project directory while Drupal serves from `web/`, so nginx found no index file and the request never reached PHP. That root is a guess whenever the project never declared one, since linking walks the well-known names and takes the first holding an `index.php`, and a project whose dependencies are not installed yet has none. Resolution goes through one place now, taking the `public_dir` a project commits first, then the one recorded for the site, then the definition's, with the recorded root giving way only in the single case that says it was a guess that has expired: it holds no `index.php` and the definition's does. The nginx vhost, `lerd which` and the idle engine's Vite lookup each had their own copy of this in three different shapes.
+- **The framework version reported was the definition's, not the project's** (#1394). A site running WordPress 7.0.2 read "WordPress 6", because the store publishes 5 and 6 and every surface reported the borrowed definition's version. The project's own version was recorded only for the legacy clamp, because one flag carried two claims at once, which version to report and whether the borrowed definition's PHP range may constrain the project. They are separate now: the detected version is recorded whenever it differs from the definition serving the project, and the clamp keeps the narrower meaning it always had.
+- **The migrate fix was matched against the word Laravel uses** (#1393). A Symfony site was told its database was empty and to run migrations, with no way to run them, because the fix was matched against a constant spelled `migrate` while Symfony declares `doctrine:migrations:migrate` and Drupal declares `updb`. Laravel is the only framework whose command happens to be spelled the way the constant was, which is what kept a Laravel-shaped assumption sitting unnoticed in the framework-agnostic baseline. The definition names it now through a `migrate_command` in its doctor block, and a framework declaring none gets a finding with no fix rather than a button that maps to nothing.
+- **The SQLite check read Laravel's key names wherever it found them** (#1397, #1402). A Symfony project was reported as having an empty database needing migrations, and the fix ran migrations against a project with no migration classes at all; the database it complained about was a 0-byte file the application never opens, while the one the site actually uses was healthy the whole time. The check read `DB_CONNECTION` and `DB_DATABASE` wherever they appeared, so leftovers from something else were read as though they meant something. It reads only keys the project's own framework declares now, taking the vocabulary from the definition's service detection rules and vars, with a DSN read as a DSN. Reading by declared key also means the format no longer matters, so the check moved out of the dotenv-only gate. And a relative database path is relative to whatever the application calls its root: Laravel roots it at the project and Drupal at the document root, so a 20 MB Drupal database was reported missing because lerd looked one directory up from where the application opens it. Both roots are checked now.
+- **`lerd env` wrote to a file the application ignores** (#1395). On a Drupal project it reported success, appended `DB_DRIVER`, `DB_HOST` and `DB_NAME` as PHP constants to `settings.php` next to the `$databases` array pointing at SQLite that Drupal's installer had written and that Drupal actually reads, created the MySQL databases, and left the site running on SQLite with nothing to say so. The definition was never wrong: the `.env` at the project root is lerd's file and `settings.php` is Drupal's, and the fallback exists so an already-installed site can be detected through the file it has. Writing resolves separately now, so a definition naming a primary file means that file is lerd's to write, created when missing from the example the definition names, and any fallback is a read source. Reading is untouched, so an installed site is still detected through its own file. The doctor asks the same question of the same file, since a project read through a fallback still has no env file of its own, and its finding names the command that creates it rather than telling the user to copy an example by hand.
+- **A misconfigured site could say so and offer nothing** (#1406). The wiring finding named the problem and stopped, because when it was written `lerd env` would have appended constants Drupal never reads and the button would have resolved nothing. It offers to write the connection now, from the dashboard and from `lerd site:doctor --fix` alike. And the check that catches a database which does not exist could not see a server one outside Laravel's vocabulary either, reading `DB_CONNECTION`, `DB_HOST` and `DB_DATABASE` by name from behind a dotenv gate; it resolves through the framework declaration like its SQLite sibling, so a project configured in a PHP settings file or behind a DSN is checked like any other, while an engine that cannot be reached still produces no finding rather than a false one.
+- **`mail()` could not deliver anywhere, in any container** (#1389). A Drupal site reported it was unable to send email and a WordPress contact form did nothing at all. The image's `/usr/sbin/sendmail` is BusyBox's, which connects to `127.0.0.1:25` unless told otherwise, and nothing listens there inside the container, so `mail()` returned false while Mailpit sat running the whole time. Laravel escaped it only because it speaks SMTP itself through the keys the store wires into its `.env`. lerd writes a `conf.d` ini naming the catcher on sendmail's `-S` flag and mounts it into every PHP container, FPM and FrankenPHP alike, which needs no framework knowledge and fixes them all at once. The catcher is whichever installed service plays the mailpit role, so a drop-in is used where that is what the machine runs, and an install with no catcher is left exactly as it was. The file is numbered below the shared and per-version `php.ini` so a `sendmail_path` set there still wins.
+- **N+1 warnings flooded the desktop naming aggregated assets** (#1411). Browsing a Drupal admin produced a warning per request naming a script rather than a page. A framework that builds its aggregates through PHP does run queries serving one, so the threshold is genuinely crossed, and each aggregate carries its own hash in the path, so every request looked like a route the tracker had never warned about and the once-per-route guard never bit. None of it is actionable either. The request-timing view has dropped static assets for that reason since long before this, by a predicate the notifier simply never asked; it asks now, so the two surfaces agree on what counts as a route.
+- **Every captured query reported the layer under the call as its origin** (#1415). Of 39 in a Drupal browsing session, 39 pointed at core's PDO wrapper. The frame walk stopped at the first file whose path did not contain `/vendor/`, which is a reasonable definition of "not a dependency" only where every dependency lives there, and Composer installs Drupal core at `web/core`, so core passed the test and the walk stopped inside it; on that site 80 of 195 packages install outside vendor, so the same held for every contrib module and theme. The project already records where its dependencies went, so reading each package's install path from the installed map skips whatever Composer put wherever it put it. A project whose dependencies all sit in vendor sees no change.
+- **The views lens listed entries nobody wrote** (#1417). On an Inertia page two of three were hashed `__components::` names whose template was a file in the compiled cache. Blade registers an inline or anonymous component that way, and the wildcard view composer fires for those exactly as it does for a real template, so a page built from components is mostly that, which buries the one line worth reading. The compiled cache is the distinction, read from the app's own `view.compiled` config rather than assumed. The lens also says what each variable holds rather than only naming it, a scalar showing its value and an array its size, without shipping the values themselves, since a view's data is the whole page payload on an Inertia app and routinely holds the authenticated user. What the framework carries is left out so the table is what the developer passed.
+- **A resources row reported the cache a service can give back as memory it holds** (#1422). The widget showed lerd-ui holding almost two gigabytes when the process had fifty megabytes resident. Host rows subtracted `inactive_file` from `memory.current`, the working-set definition podman and cAdvisor use, chosen so a daemon reading big log files is not reported holding what it can release; a poller that re-reads the same files every tick reads all of them more than once, and the kernel promotes a page to the active list on the second read, so its cache was entirely active and the subtraction removed nothing. The formula takes out the whole file total and keeps shared memory in, since that part cannot be dropped. Container rows came from podman applying the same working-set formula to the payload cgroup, so fixing only the host side would have left one list summing two different measurements into one total; a container's memory comes from the same cgroup read now, which also brings in conmon and the rootless network helper podman starts per container, memory that is spent because those containers run and that nothing in the dashboard could show before.
+- **Profiling a slow route from the site overview showed nothing** (#1424). Arming returned as soon as the toggle endpoint answered, but that endpoint regenerates every PHP-FPM vhost and signals nginx, and a reload drains the old workers rather than swapping the configuration in place, so the request the new tab fired an instant later could still be served with no profiler attached; SPX then opened in the same tick as the navigation, well before the request finished, so the list it landed on could not contain it either. Arming holds until nginx answers with the setting it was just given, through a marker location that returns the setting the serving configuration was generated from, so the guarantee sits below the UI and `lerd profile on` and the MCP toggle get it too. The handover to SPX waits for the capture to exist and says so when nothing lands within twenty seconds. A click that armed the profiler turns it back off once the capture lands, since it is global and profiles every FPM site for as long as it is on. SPX records the `Host` header, which carries the port whenever nginx does not serve on 80, and both the capture count and the existing optimize-route join compared it against the bare domain from the registry, so on those installs the route profile block was always empty with nothing saying why.
+- **A client shim toggle recorded a decision it had not carried out** (#1360, #1371). The write path stored the decision first and reconciled afterwards, so the recorded state said enabled with nothing on disk and the next unrelated toggle read that back and brought the tool up as on. The reconcile was quiet about it too, discarding the write result and skipping a path already held by a file lerd does not own without saying anything. The shim is written first now and the decision recorded only once that succeeded, an enable onto a path lerd will not overwrite fails with that path named, and the state a tool reports follows the shim in the bin dir rather than the recorded intent. In the dashboard the control marked itself failed with no text next to it, which is why the original report saw no visible result at all, so the reason sits under the tool and the control settles on what the server actually holds rather than on whatever the last load happened to say.
+- **A cached store definition could be read mid-write** (#1354). `SaveStoreFramework` wrote with a plain `os.WriteFile`, so a reader arriving mid-write saw whatever bytes had landed, which is quiet rather than loud: a definition cut above the workers key still parses, and the reader ends up with a valid-looking framework that has nothing to run instead of the parse error that would have fallen through to the fallback. There are more writers than it looks, since the fetch hook fires from any call the watcher, the dashboard poll and the vhost renderer reach. The service preset cache had the identical problem, and so did the store index, which is worth spelling out because it looked like the safe example to copy: it went through a temp file and a rename, but the temp name was fixed, so two concurrent fetches truncated and filled the same file and the second kept writing through its open handle into the live index. All three go through one atomic writer now, and content that has not changed is left untouched.
+- **The wizard's multi-select steps rendered nothing, or hid an option** (#1348). On a fresh install the Workers step came up with a title, a description and nothing under it. An unsized multi-select in huh v2 sizes its viewport to the number of options it holds and then subtracts the height of the title and description from that same number, so a two-option list with both lands on a zero-height viewport; v1 returned early in the unsized case and v2's single select still does, which is why the Database step beside it was fine. The ones with a title and no description lose a single row off the bottom, so the Services step had been offering five entries out of six and quietly hiding opensearch. All eight fields go through one helper that gives the field the height it needs.
+- **A Homebrew upgrade broke the whole install at once** (#1443). Homebrew installs every version into its own keg and repoints an opt link at the current one, and everything lerd writes that names its own binary, the `lerd-ui`, `lerd-watcher` and `lerd-tray` user services and the php, composer, laravel and client shims on PATH, recorded the resolved path, which means the keg. The next `brew upgrade` retired it and the daemons failed with a bare exit status 203 while php reported no such file, on a machine where the developer changed nothing. There was a guard for exactly this, but it compared the executable path with its symlink-resolved form, and on Linux the executable path is resolved to begin with, so the two were always equal and it never fired. Everything that writes the binary path goes through one helper that hands back the version-independent opt spelling for a keg, and the shims reach for lerd on PATH when the path they carry has gone. For the installs already broken, `lerd update` re-execs `lerd install --from-update` while brew, apt and dnf swap the binary and run nothing, so lerd records the version its environment was set up for and the first command run at a terminal after a different release lands reapplies that step and says so.
+- **A PHP version card kept its old patch after a base image update** (#1444). The rebuild ran and reported success while the card stayed as it was until `lerd-ui` restarted, because the cached probe was keyed on the containerfile hash and a base update leaves the containerfile untouched, so the key matched either side of the build. It is keyed on the image ID now, which moves on every rebuild whatever caused it. The probe is deliberately asynchronous so the status hot path never blocks on podman, which meant the first load after a rebuild still reported an empty patch, so the rebuild handler probes synchronously and drops the status snapshot before it emits done.
+- **A removed service's leftover config entry kept its port out of circulation** (#1450). Removing a service deletes its definition and its quadlet but keeps its entry in the global config, deliberately, because the port memory is worth keeping. The reservation scans read that entry as a live claim, so a port belonging to a service that no longer exists stayed refused for `lerd service port` and skipped by the automatic shift, out of circulation for good. An entry with no service behind it claims nothing now. Since that memory can be handed to another service while its owner is gone, the ownership guard no longer sticks to a recorded port another installed service already publishes, clearing the stale record instead of putting two units on one port at boot.
+- **phpMyAdmin opened on the wrong engine** (#1463). An admin UI opens on the first host it discovers, and phpmyadmin declares `discover_family:mysql,mariadb` precisely so mysql leads, but the collection sorted the whole list afterwards and threw that order away, so installing mariadb put it ahead of mysql and phpMyAdmin silently started opening on an engine holding none of the databases the user was working in, which looks exactly like the databases having vanished. The sort was there to keep the generated value stable so the container does not churn on every reconcile, which ordering within a family already covers. pgadmin discovers its hosts through the same path.
+- **The admin dashboards lost their session inside the overlay** (#1470). Every POST phpMyAdmin made came back as "Cannot connect: invalid settings", so switching servers from its dropdown failed. The cause is the session cookie rather than the database: the overlay embeds the admin UI at its own origin inside `lerd-ui`, so the cookie is third-party and the Chromium that Electron ships blocks it by default. A GET still renders because phpMyAdmin starts a fresh session; a POST cannot, and falls back to a server config it does not have. pgAdmin sets the same `Secure, SameSite=None` cookie and Mongo Express carries one with no override at all. All three go through the same-origin proxy that already serves RabbitMQ and RedisInsight, which puts the cookie back in first-party territory and lets the forced HTTPS and the `SameSite=None` hacks go with it. The prefix an upstream is told to serve under and the decision to proxy it come from one predicate, so the two halves cannot land apart, and eligibility reads the flag from the preset rather than the copy saved at install time so a store change reaches a service installed before it. Switching between two open dashboards also stranded the overlay behind pgAdmin's unload prompt, because the single iframe was re-pointed rather than replaced.
+- **Deleting a project directory left most of the site behind** (#1475). Deleting a directory has always unlinked the site, but the sweep behind that reimplemented a subset of the unlink core rather than calling it, and the two drifted: a reaped site kept its certificate pair, a custom-FPM site kept its per-site container and quadlet, an open share stayed bound with nothing left to reach it by, and its recorded request timings and idle state outlived it. The unlink core is split into the per-site teardown and the install-wide tail, and the sweep runs the teardown, so a reaped site is torn down exactly like an unlinked one while the container hosts rewrite, the FPM quadlets and the nginx reload stay batched across the sweep. Removing a site's certificates turned out to be dead code on every path, unlink included, deleting `certs/.crt` while site certificates live under `certs/sites/`, so a secured site has been leaving its pair behind all along. Worktrees were part of no teardown at all: their subdomain vhosts stayed in `conf.d` so nginx went on serving branches of a project that no longer existed, and their LAN shares and tunnels stayed up with their registry entries. Isolated worktree databases are deliberately kept, the way `lerd worktree remove` keeps them unless you ask, so a directory that disappears because a disk went away costs no data.
+- **`lerd install` put a project-declared service back on a definition frozen at link time** (#1482). A project can declare a service inline in its `.lerd.yaml` and lerd registers that definition under the config directory when the site is linked, and every service command reads the registered file from then on, except install, which rewrote the quadlet from the copy still sitting in the site file. That copy is frozen, so a service installed months ago came back with the image and the host port it carried that day: where a preset had since moved port, install put the unit back on the old one, restarted the service, found the port already published by another admin UI, and spent the restart budget before a later pass regenerated it correctly. The run still ended healthy, which is why it read as a warning rather than a broken install, and it repeated every time.
+- **A project asking for HTTPS was served on HTTP until after setup returned** (#1442). A project carrying `secured: true` was registered on http and secured afterwards, with the certificate step sitting after the summary had been printed and after the setup prompt had been answered, so the summary named http, setup built its step list against a site that was not secured yet and offered the secure checkbox for something the project had already asked for, and the site turned to https once setup returned, contradicting everything the screen had said. The link plan has resolved the secured state ever since the linker took that work over, so the late call was repeating a decision already made and making it after everything that reads the result. Running `lerd setup` on a site linked a moment ago also linked it again, repeating the framework detection, the runtime provisioning, the certificate and the summary for a site already being served; setup resolves the plan a link would carry out and matches it against the registration the registry holds, saying already linked on one line when the two agree. Only the fields a link decides take part in that comparison, since everything else the registry carries is the site's own state and any of it would otherwise re-link a site for having been used.
+- **The wizard's Node version field described something it cannot do** (#1440). It said "Leave blank to skip", and whatever you type goes into `.lerd.yaml` as `node_version`, which sits above `.nvmrc`, `.node-version`, package.json engines and the machine default, so a blank answer never skipped Node, it only declined to pin a version, and the question the field actually asks was answered nowhere on screen. It prefills the way the PHP field above it does, from the saved value and otherwise from the version the project already resolves to, and says what an empty box falls back to. Working that out means resolving the version with the pin left out of the order, so the Node detector reports which file the answer came from, with detection order and results unchanged for every existing caller.
+- **`db:snapshot` had never worked on a mariadb service** (#1447). The dump wraps the export in `set -o pipefail` with the shell's complaint redirected away, and dash, which is `/bin/sh` on the Ubuntu-based mariadb images, exits on the unknown option before the export runs. The mysql and postgres images ship bash, which is why every path that was exercised worked. `pipefail` is probed before it is set now.
+
+### Docs
+
+- A page covers updating an install from before the lerd-env move (#1343). Anyone on a build older than 1.26 cannot update and nothing released can reach them, so the docs are the only way across: how to tell whether you are affected, that only the release lookup is broken while the store, the changelog, the archives and the base images all still resolve through GitHub redirects, and the route for each way lerd can be installed. It also calls out that the update notice in `lerd status` and `lerd doctor` fails quietly here, so a good number of affected users are not seeing an error at all, they simply stopped hearing that new releases exist.
+- The Symfony and WordPress guides drop the hand-written framework definition step (#1453). Both opened by telling the reader to save a YAML into `~/.config/lerd/frameworks` and register it with `lerd framework add --from-file`, which is no longer true and left the reader worse off than skipping it: the definitions in the docs were thinner than what the store publishes, and because `--from-file` writes a user definition, that thinner copy was merged on top of the real one rather than ignored. The WordPress walkthrough now creates `wp-config.php` from the sample before linking, since the resolver only picks it up when it already exists, and the Symfony setup list, worker list and log paths match what `symfony@8` ships.
+- The project setup pages spell out that the Node version field pins a version rather than switching Node on or off, and the wizard transcripts match what the wizard now prints (#1440).
+
+---
+
## [1.32.0] - 2026-08-05
Sharing a site was the 1.31.0 story and it finishes here. A public share no longer has to go through somebody else's tunnel service at all: point a wildcard you control at the machine, set the base domain once, and lerd answers on a stable hostname through the reverse proxy you already run, with the site's ordinary `.test` vhost untouched beside it. Pinggy joins the SSH tools as a table entry rather than another special case, which is what the mode should have been from the start. The other half of reaching a site from somewhere else is the services behind it, so LAN access for managed engines becomes an opt-in you can actually grant, and the dashboard actions that reach the host itself sit behind a switch of their own rather than behind an assumption about who is connecting.
diff --git a/docs/public/assets/digest/v1.33.0.png b/docs/public/assets/digest/v1.33.0.png
new file mode 100644
index 000000000..47ecc65c4
Binary files /dev/null and b/docs/public/assets/digest/v1.33.0.png differ
diff --git a/docs/public/assets/featured-preview.png b/docs/public/assets/featured-preview.png
new file mode 100644
index 000000000..fd2ae630f
Binary files /dev/null and b/docs/public/assets/featured-preview.png differ
diff --git a/docs/public/assets/social-preview.png b/docs/public/assets/social-preview.png
index d664bd1d9..9f06db716 100644
Binary files a/docs/public/assets/social-preview.png and b/docs/public/assets/social-preview.png differ
diff --git a/docs/public/digest/v1.33.0.html b/docs/public/digest/v1.33.0.html
new file mode 100644
index 000000000..8f3a6d209
--- /dev/null
+++ b/docs/public/digest/v1.33.0.html
@@ -0,0 +1,826 @@
+
+
+
+
+
+lerd v1.33.0 — dev digest
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ L
+ lerdlerd-env/lerd
+
+ v1.32.0…v1.33.0 ↗
+
+
+
+
+
+
ENGINEERING DIGEST · 2026-08-05 → 2026-08-13
+
v1.33.0
+
EVERY FRAMEWORK'S OWN FILE · and a dashboard that starts the work
+
Link a Drupal site and ask lerd about it, and a dozen places answered as though every project on earth keeps a .env with Laravel's key names in it. The databases card could not see the database. The doctor offered migrations against a file the application never opens, and called a site healthy while it picked mysql and served every request off SQLite. lerd env reported success and wrote to a file Drupal ignores. This release answers all of it from the framework's own declaration: which file, in which format, at which keys, with which command, so lerd writes the $databases array a site actually runs on. Meanwhile the + in Sites became a wizard that scaffolds or links a project, asks what lerd init asks, and runs setup with composer streaming into the modal, the docs moved into the binary so they work with no internet, and both stores learned to ship the mark and brand colour every service and framework is drawn with.
+
+
+
115
commits
+
66
merged PRs
+
495
files
+
+47,811
insertions
+
−20,945
deletions
+
3
contributors
+
+
+
+
+
+
+
+
+
+
+
+
+ web UI 32%
+ cli 17%
+ config 14%
+ env + doctor 12%
+ services + store 8%
+ rest 17%
+
+
+
+
+
+
+
+ 01
+
Frameworks
+
FIXREFACTOR
+
+
The second design law says no feature may know the name Laravel. This is what happened when a real Drupal site was linked and every surface was asked about it in turn. None of these were separate bugs so much as one assumption made in a dozen places, each of them found by driving the actual application rather than a fixture.
+
+
+
WHERE A SITE'S DATABASE LIVESand what lerd was looking at instead
+
+
Laravel
.env
DB_CONNECTION, DB_DATABASE
+
WordPress
wp-config.php
define('DB_NAME', …)
+
Magento
env.php
return [ … ]
+
Drupal
settings.php
$databases['default']…
+
+
Three of the four were invisible. Every one of them is published in the framework definition and always was, file, format, host key and database key. lerd was fetching them and then asking for Laravel's names anyway.
Which database on which service a site uses is answered once now: the env file the definition names, read in the format it names, at the keys derived from its service vars. A framework wiring through a single DSN is read from that value instead. Worktree isolation had grown its own near-identical copy of the binding logic; it is gone.
The fix was matched against a constant spelled "migrate". Symfony declares doctrine:migrations:migrate and Drupal declares updb, so neither ever matched and both got a finding naming a remedy the user had to go and find. Laravel is the only framework spelled the way the constant was, which is what kept the assumption invisible.
A Drupal site answered 403 on every request: the vhost rooted it at the project while Drupal serves from web/. That root is a guess whenever the project never declared one, since linking walks for an index.php and a project without dependencies has none. It expires now when it holds no index and the definition's does.
A WordPress 7 site asks the store for wordpress/7.yaml, a request that can only 404, and the recovery only ever looked downward. The site ended up with no framework at all, losing its workers, doctor checks, env wiring and database with it.
+
FIX
The version it runs, not the one it borrowed #1394
A site running 7.0.2 read "WordPress 6". One flag carried two claims at once, which version to report and whether the borrowed definition's PHP range may constrain the project. A definition is borrowed in both directions; only one of those claims is.
BusyBox's sendmail connects to 127.0.0.1:25 and nothing listens there inside a container, so every framework that sends through mail() failed while Mailpit ran the whole time. One conf.d ini mounted into every PHP image fixes all of them at once, with no framework knowledge.
+
+
+
+
+
+
+ 02
+
Env Files
+
FEATSAFETY
+
+
Reading a framework's own configuration is half of it. Writing one is harder, because settings.php is mostly guidance a user may have edited and a config file lerd mangles is a dead site. The php-vars format addresses top-level assignments by a dotted path, rewrites only the statements whose values change, and leaves the rest of the file byte for byte.
+
+
+
the statement PHP actually runs is the last one, not the firstphp
+
// before: lerd wrote here, the site used the line below it
+$databases['default']['default'] = ['host' => 'lerd-mysql']; // lerd wrote here
+$databases['default']['default'] = ['host' => 'other']; // the site uses this
+
+// after: the owning statement is the last one to reach the key
+$databases['default']['default'] = [
+ 'host' => 'old',
+ 'port' => 3307,
+];
+$databases['default']['default']['host'] = 'lerd-mysql'; // reads back host lerd-mysql, port 3307
+
+
+
+
A DEFINITION REACHES EVERY INSTALL WITHIN A DAYwhatever binary it runs, with no version gate anywhere
+
+
1
A new env format is published
The store has no engine version field. A definition naming php-vars lands on a machine running a binary from before it existed.
+
2
Three write switches fall through to dotenv
Which appends key=value lines into a PHP file: Parse error: syntax error, unexpected token "default". A dead site, on a machine whose owner did nothing but leave lerd running.
+
3
Writing goes through one function that refuses #1401
An unknown format is named and the project is left exactly as it was. Reading one returns nothing rather than inventing keys out of whatever the file happens to contain.
+
4
And a definition can carry both answers
app_file and app_format name the file the application reads; file and fallback_file say what an older binary should do with the same definition. An unknown field is ignored on parse, so every install behaves as well as its version allows. Verified both ways against one definition.
Drupal's installer leaves settings.php and its directory read-only, which is right for a deployed site and no reason to refuse to configure a local one. The writer restores permission for the duration and puts the mode back exactly, even when the write fails.
lerd env appended constants to settings.php next to the array Drupal reads, created the databases, and left the site on SQLite with nothing to say so. A definition naming a primary file means that file is lerd's to write; a fallback is a read source.
A site showed postgres everywhere while running on SQLite: DATABASE_URL set twice, lerd reading the first and Symfony the last, both correct. Laravel's loader keeps the first, Symfony's array keeps the last, so no single rule is right for both and this reports rather than resolves. The editor lists each occurrence with its line and keeping one drops the others as an ordinary unsaved change.
+
+
+
+
+
+
+ 03
+
Dashboard
+
FEATUI
+
+
The + in Sites opened one screen that browsed the host directories and streamed a link, and stopped there. It could not create a project, it never asked the questions lerd init asks, and nothing ran the setup steps, which is where composer install, the JS build and the workers actually happen. Someone working from the dashboard ended up back in a terminal to finish what they started.
+
+
+
ONE FLOW, NO TERMINALand the same plan the terminal renders
+
+
create or link
store catalogue
framework + major
+
──►
+
the init questions
.lerd.yaml
PHP, Node, HTTPS, services
+
──►
+
setup steps
--list-steps
run one at a time
+
──►
+
a served site
https://…test
nothing left to type
+
+
The questions are not a copy of the terminal's, they are the same plan. Deciding what to ask about a directory moved out of the terminal form, and step planning came out of runSetup as lerd setup --list-steps and --step, useful at the terminal in their own right.
Everything reaching the host goes through one run endpoint that builds every argv itself from a named kind, behind the same authority the editor and terminal sit behind. Scaffolding is minutes of composer, so a run lives in lerd-ui rather than in the request that started it: close the tab mid-install and it replays where it was.
The docs button opened lerd.sh in an iframe, so the one moment you most need the docs, a machine with no internet, is the moment it had nothing. The pages have been embedded all along for lerd man; a shared normaliser rewrites VitePress containers into portable markdown so both surfaces read the same set.
It scaffolded Laravel without asking and ended by printing three commands to type. It asks which framework and major, then links the result. The catalogue only offers what can start a project, since a definition with no create command was offered and then refused after every question had been answered.
The Sites and Services widgets hand-rolled their own rows while the tabs behind them drew cards, so one thing read two ways depending on where you met it. Both render the tile the grid uses, in a compact variant, which brings the app name, PHP version, padlock, share markers and per-worker dots along for free.
The widget was a run of anonymous text rows. Each worker type is a line with its mark and a summary now, opening to one row per unit with the last line a failing one printed. A worker that keeps failing had only heal on offer, which is starting it again; the banner stops them instead, and the detector leaves a disabled unit alone.
A service a dozen sites use pushed the pane down before anything about the service was on screen, every pill carrying a grey dot that read as a status and said nothing. One button beside the action menu, a row per site coloured by what that site is doing. The Node card's chips went the same way.
+
+
+
+
+
+
+ 04
+
The Store
+
FEAT
+
+
More of what lerd does is a line of YAML than it was a release ago. A store change ships to every install within a day with no binary release and no Go code, which is the whole point of the first design law, and this cycle pushed several more decisions across that line.
It heads for an html block, so it is cut down on the way in to a plain drawing subset: script, foreignObject, event handlers, external references and any fill or style of its own are dropped. A monochrome silhouette through currentColor, because full-colour brand marks would sit badly beside the outlines they render next to and would not survive a theme flip.
Tints are full static class strings picked from a fixed record, so a value out of the store can never become a class. It arrives as a custom property instead, validated as a plain hex, with a tone derived per theme so a near-black brand still separates from the dark card.
An admin UI with no mark of its own draws the mark of the engine it administers, read from the admin_for its preset already declares. pgAdmin comes up as PostgreSQL, and Mongo Express, Elasticvue, RedisInsight and the OpenSearch dashboards stop drawing stand-ins as a side effect.
+
+
+
+
FEATFIX
A fresh install holds the catalogue an established one does #1429
Nothing seeded the framework store, and the cached index only appeared once the long-running watcher reached its first refresh, so until then the only definitions lerd could resolve were the two compiled into the binary and detection was narrower than it would be a few minutes later. Install pulls the index early, before the vhost pass that resolves a definition per site, then fetches every definition the index publishes. The refresh at the end of install built its target list by reading the store directory, so it could only revisit what was already there and did nothing at all on a machine with none. Two things this leans on were wrong: definitions were ranked by sorting their names as strings, which puts laravel@9 above laravel@12 and hands the newest definition to nobody, and the index cache was left untouched when a refresh found the catalogue unchanged, so a store that published nothing new for a day looked like a store nobody had reached in one.
internal/store/refresh.go
+
+
+
+
+
+
+ 05
+
Doctor
+
FEATFIX
+
+
A check that names a problem and offers nothing is half a check. Three of them stopped at a command to copy, and one of those names the most likely reason a site does not boot at all, which made it the worst place to stop short.
+
+
+
BEFORE / AFTERa Drupal site picking mysql and running on SQLite
+
+
a Drupal site picking mysql and serving every request off SQLitebash
+
# before: healthy, on a database nobody chose
+✓ Service Wiring
+✓ Database
+
+lerd site:doctor --fix
+⚠ Service Wiring Drupal picks mysql, settings.php points at sqlite fix: env_sync
+ → wrote the connection values into the $databases array
+✓ Service Wiring ✓ Database
+
+# settings.php mode before -r--r--r--, after -r--r--r--, HTTP 200 throughout
+
+
Every check that reads env content sat behind a dotenv gate, so a project whose config is php-const, php-array or php-vars skipped all of them and passed on the strength of its env file merely existing. That is WordPress, Magento and Drupal alike, which is to say the frameworks least likely to be wired the way lerd assumes.
Both halves are a button: what is missing gets installed, what is stopped gets started, through the host-side flow the services page uses, so the image pull and the readiness wait report as they happen. Install state comes from the source of truth the services list uses, since a leftover unit had a site told it was merely stopped.
A vhost is written on link, secure, rename or a PHP switch and nothing looks at it again. The doctor renders what lerd would write right now and names the first line the two disagree on. Rendering and writing were the same step in eight generators, which is why nothing could ask the question without doing it.
The comparison needs no parsing and no framework knowledge: the store declares which services a framework wires, and a project points at one when its config names that service's container, which is a text question every format answers the same way. A drop-in is measured against the block it stands in for.
+
+
+
+
+
+
+ 06
+
Lifecycle
+
FIXFEAT
+
+
Installing, upgrading, stopping and removing: four moments where lerd is holding somebody's data or somebody's PATH, and four places this cycle found something that could lose either.
Homebrew retires the keg each version lives in. Every user service and every shim recorded the resolved path, meaning that keg, so the next brew upgrade left the daemons failing with a bare exit status 203 and php reporting no such file, on a machine where nothing was changed. There was a guard for exactly this: it compared the executable path with its symlink-resolved form, and on Linux the executable path is already resolved, so the two were always equal and it never fired.
--purge and --reset-data renamed the data dir aside and called that recoverable. It is only recoverable by the image that wrote it, so a reinstall landing on another version leaves a directory nothing installed can open, which is exactly the case where somebody wants their data back. It is automatic rather than offered, because the person it exists for is the one who did not think to ask.
The sweep reimplemented a subset of the unlink core rather than calling it, and the two drifted: certificates, per-site containers, an open share and the recorded timings all outlived the project. Removing certificates turned out to be dead code on every path, deleting certs/<domain>.crt while site certificates live under certs/sites/.
The Podman Machine was killed abruptly on restart, so timescaledb came back into a multi-minute crash recovery. The watcher already runs under launchd for the whole session, so it runs the same teardown lerd quit does. It must never stop its own unit, since asking launchd to bootout the job you are running inside blocks until that process exits. macOS only: on Linux systemd already does it, so running it there would only cost something.
Every service command reads the registered definition, except install, which rewrote the quadlet from the copy still sitting in the site file. Frozen at link time, so a service came back on the host port it carried that day, found it published by another admin UI, and spent its restart budget every single install.
Removing a service keeps its config entry, deliberately, because the port memory is worth having. The reservation scans read that entry as a live claim, so a port belonging to a service that no longer exists stayed refused and skipped by the automatic shift.
+
+
+
+
+
+
+ 07
+
Measurement
+
PERFFIX
+
+
The resources widget reported lerd-ui holding almost two gigabytes while the process held fifty megabytes, and it spent eighty milliseconds of CPU per refresh finding that out. Both halves were the measurement rather than the thing measured.
+
+
+
MEASURING THE MEASURERpodman stats, streamed for three seconds, as a child of lerd-ui
+
+
before
~80 ms CPU
3 s stream, 1 subprocess
+
──►
+
after
~1 ms CPU
26 ms wall, no subprocess
+
──►
+
lerd-ui row
4–10% → 0.01%
share of the machine
+
+
Every lerd container is a quadlet unit and cpu.stat sits in the same cgroup directory the memory read uses, so one pass over a handful of files measures the containers and lerd's own daemons alike. The rate is taken between refreshes rather than inside one, so a row reports what a service cost over the interval, idle stretches included, instead of a spike caught while lerd was busy measuring itself.
+
+
+
+
FIX
Cache it can give back is not memory it holds #1422
Host rows subtracted inactive_file, the working-set formula podman and cAdvisor use, chosen so a daemon reading big log files is not reported holding what it can release. A poller re-reads the same files every tick, and the kernel promotes a page to the active list on the second read, so its cache was entirely active and the subtraction removed nothing.
Container rows came from podman applying the same formula to the payload cgroup, so one list summed two different measurements into one total. CPU reads as a share of the whole machine now, the way memory already did: a row pegging one core of a 32-thread box is three percent, not a hundred. Twenty-six rows summing to 0.1817% against a reported 0.1817%.
Arming returned as soon as the toggle answered, but that regenerates every vhost and an nginx reload drains the old workers rather than swapping config in place, so the request the new tab fired could still be served unprofiled. SPX then opened in the same tick as the navigation. Both failures look identical from outside: the profiler simply seems not to have run.
+
+
+
+
+
+
+ 08
+
Fixes & Docs
+
FIXDOCS
+
+
The long tail, and the things that only show up when you drive the real application rather than a fixture.
+
+
+
What broke
Why
PR
+
+
phpMyAdmin opened on the wrong engine
The preset declares discover_family:mysql,mariadb so mysql leads, and the collection sorted the whole list afterwards. Installing mariadb made a user's mysql databases look like they had vanished
The overlay embeds them at lerd-ui's origin, so the session cookie is third-party and Chromium blocks it. A GET renders on a fresh session, a POST cannot. All three moved behind the same-origin proxy
A plain os.WriteFile, and a definition cut above the workers key still parses, so the reader gets a valid-looking framework with nothing to run. The index went through a temp file with a fixed name, which is worse
huh v2 sizes an unsized multi-select's viewport to its option count, then subtracts the title and description from that same number. Two options plus both lands on zero height. Services had been hiding opensearch
A framework building aggregates through PHP does run queries, and each aggregate carries its own hash, so the once-per-route guard never bit. The timing view has dropped static assets for years by a predicate the notifier never asked
The frame walk stopped at the first path without /vendor/, and Composer installs Drupal core at web/core. On that site 80 of 195 packages install outside vendor
The probe was keyed on the containerfile hash, and a base image update leaves the containerfile untouched. Keyed on the image ID now, which moves on every rebuild whatever caused it
Anyone on a build older than 1.26 cannot update and nothing released can reach them, so the docs are the only way across. The update notice fails quietly there, so a good number of affected users see no error at all: they simply stopped hearing that new releases exist.
The Symfony and WordPress guides opened by having the reader hand-write a framework YAML. Because --from-file writes a user definition, that thinner copy was merged on top of the real one rather than ignored, so the guide walked the reader into shadowing a better definition with a worse one.
Thanks to @raisilhamn for the copy button on a query trace's file path, and to @retr0ripper for the logout teardown that keeps a Podman Machine from being killed out from under a running database.
+
+
+
+
+
+
+
+
diff --git a/docs/public/share.html b/docs/public/share.html
index b3a8e7f7c..727bdc949 100644
--- a/docs/public/share.html
+++ b/docs/public/share.html
@@ -52,6 +52,12 @@
color: var(--ink);
}
+ /* Featured-article variant: the same composition on a 2:1 frame, shot at
+ 2x for 2200x1100. Add ?featured to the URL, or the class from a
+ capture script. The window keeps its size and re-centres on the
+ shorter frame, so only the vertical breathing room changes. */
+ .frame.featured { height: 600px; }
+
/* Ambient field — subtle grid, masked to the top, plus warm + cool washes. */
.frame::before {
content: "";
@@ -240,5 +246,10 @@