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 + lerd lerd-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.
+
+ +
+
FIX

The database, through the declaration #1373

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.

internal/config/sitedb.go
+
FIX

The migrate button, from the definition #1393

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.

internal/sitedoctor
+
FIX

The document root it declares #1382

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.

internal/config/docroot.go
+
+ +
+
FIX

Newer than the newest definition #1374

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.

+
FIX

mail() had nowhere to go #1389

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.

+
+
+ +
+
FEAT

php-vars, and a hardened file #1388 #1406

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.

internal/envfile/phpvars.go
+
FIX

The project's file, not its fallback #1395

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.

+
FEATUI

A key set twice #1403

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.
+
+ +
+
FEATSEC

Nothing a page sends decides what runs #1473

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.

internal/ui/runs.go
+
FEAT

The docs, offline #1456

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.

internal/docs/normalise.go
+
FEAT

lerd new is a wizard #1435

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.

+
+ +
+
UIREFACTOR

One object, one look #1454 #1455

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.

+
FEATUI

Workers as cards, and a way out #1480

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.

+
UI

Sites collapse into a dropdown #1460 #1465

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.

+ +
+ + + + + + + + + +
What moved to the storeKeyWhy it could not stay in Go
Shutdown grace #1439stop_timeoutFive seconds is right for chromium and wrong for a database checkpointing a buffer pool, which comes back replaying its write-ahead log
Structural complaints #1449expected_errorsA pg_dumpall replay always fails on the role and templates it drops; which complaints are noise belongs next to the image that makes them
First-run steps #1452missing_filesetup --all runs every step it is offered, so Magento's setup:install needed a way to say "only where nobody has bootstrapped yet"
Cache clearing #1409cache_clearcr, cache:clear, optimize:clear: every framework spells it differently and nothing but the definition can say
Declining a warning #1414notifications.nplusoneA CMS's entity and cache layers issue the repeats themselves, and lerd cannot tell those from a developer's loop
Marks and colour #1457 #1458<name>.svg, colorA service could borrow one of fourteen bundled glyphs, and mysql, mariadb and postgres all rendered the same category indigo
+ +
+
FEATSEC

A mark is remote markup #1457

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.

internal/store/marks.go
+
FEAT

A colour that survives Tailwind #1457

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.

+
FEAT

No service is named in Go #1465

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.
+
+ +
+
FEAT

Bring up the services a site declares #1421

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.

internal/sitedoctor/services.go
+
FEATREFACTOR

A vhost that drifted #1383

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.

internal/nginx/render.go
+
FEAT

Wiring the env file never points at #1385

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.

+ +
+
FIX

A brew upgrade broke everything at once #1443

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.

internal/config/lerd_binary.go
+
FEAT

A wipe takes a snapshot first #1447

--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.

internal/serviceops/wipe.go
+
FIX

Deleting a directory left the site behind #1475

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/.

+
+ +
+
FEAT

Teardown on logout #1427

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.

internal/lifecycle/teardown.go
+
FIX

A frozen copy of a service definition #1482

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.

+
FIX

A port out of circulation for good #1450

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.

internal/stats/cgroup.go
+
PERF

Rows that add up to their own total #1425

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%.

+
FIX

Profiling a route that had not landed yet #1424

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 brokeWhyPR
phpMyAdmin opened on the wrong engineThe 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#1463
Every admin dashboard POST failedThe 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#1470
A cached definition read mid-writeA 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#1354
The wizard's Workers step was emptyhuh 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#1348
N+1 warnings on aggregated assetsA 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#1411
Every query blamed the PDO wrapperThe 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#1415
A shim toggle recorded what it had not doneThe decision was stored first and reconciled after, so the state said enabled with nothing on disk and the next toggle read that back#1360
db:snapshot never worked on mariadbset -o pipefail with the complaint redirected away, and dash, which is /bin/sh on those images, exits on the unknown option before the export runs#1447
A PHP card kept its old patchThe 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#1444
+ +
+
DOCS

Updating from before the 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. 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.

+
DOCS

Guides that shadowed the store #1453

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.

+
FEAT

Community #1466 #1427

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.

+
+
+ +
+
+
+

SHIP IT

+

Install on Linux or macOS, or lerd update if you're already on board. Full + story in the changelog.

+
+
+
bash
+
+
# Linux & macOS
+
$ curl -fsSL https://lerd.sh/install.sh | bash
+
# macOS via Homebrew
+
$ brew install lerd-env/lerd/lerd && lerd install
+
# already installed
+
$ lerd update # → v1.33.0
+
+
+
+
+ lerd v1.33.0 · 2026-08-13 · ← v1.32.0 digest + v1.32.0…v1.33.0 · docs · github +
+
+ + + + 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 @@

Local PHP dev.
Native on Linux.

+ diff --git a/docs/reference/commands.md b/docs/reference/commands.md index fbec29d52..0e6eadbe0 100644 --- a/docs/reference/commands.md +++ b/docs/reference/commands.md @@ -146,7 +146,7 @@ Supported PHP versions: **8.5**, **8.4**, **8.3**, **8.2**, **8.1**, and the fro | `lerd use ` | Set the global PHP version and build the FPM image if needed | | `lerd isolate ` | Pin PHP version for cwd: writes `.php-version` and updates `.lerd.yaml` if present, then re-links | | `lerd php:list` | List all installed PHP-FPM versions | -| `lerd php:rebuild [--local]` | Force-rebuild all installed PHP-FPM images (pulls pre-built base by default; `--local` builds from source) | +| `lerd php:rebuild [version] [--local]` | Force-rebuild PHP-FPM images, or install a version this machine does not have (pulls pre-built base by default; `--local` builds from source) | | `lerd fetch [version...] [--local]` | Pull pre-built PHP FPM base images from ghcr.io for the given (or all supported) versions; `--local` builds from source instead | | `lerd xdebug on [version] [--mode MODE] [--on-demand]` | Enable Xdebug for a PHP version. `--mode` defaults to `debug`; accepts `coverage`, `develop`, `profile`, `trace`, `gcstats`, or comma combos like `debug,coverage`. `--on-demand` sets `start_with_request=trigger` so nothing auto-connects | | `lerd xdebug off [version]` | Disable Xdebug | diff --git a/docs/usage/php.md b/docs/usage/php.md index 60f6db38d..a4049d624 100644 --- a/docs/usage/php.md +++ b/docs/usage/php.md @@ -7,7 +7,7 @@ | `lerd use ` | Set the global PHP version and build the FPM image if needed | | `lerd isolate ` | Pin PHP version for cwd: writes `.php-version` and updates `.lerd.yaml` if it exists, then re-links | | `lerd php:list` | List all installed PHP-FPM versions | -| `lerd php:rebuild [--local]` | Force-rebuild all installed PHP-FPM images; `--local` builds from source instead of pulling a base | +| `lerd php:rebuild [version] [--local]` | Force-rebuild PHP-FPM images, or add a version this machine does not have yet; `--local` builds from source instead of pulling a base | | `lerd fetch [version...] [--local]` | Pull pre-built PHP FPM base images from ghcr.io; `--local` builds from source instead | | `lerd xdebug on [version] [--mode MODE] [--on-demand]` | Enable Xdebug for a PHP version with the given mode (default `debug`) and restart the FPM container. `--on-demand` sets `start_with_request=trigger` so nothing auto-connects | | `lerd xdebug off [version]` | Disable Xdebug and restart the FPM container | @@ -158,6 +158,8 @@ Lerd automatically manages which PHP-FPM containers are running based on which v **Build on first use**: when a link lands on a PHP version this machine has never built (an older framework clamps to a version below the ones you have, say), `lerd link` builds that version's image before starting it, so the site serves rather than answering 502. The build streams its progress as a link step. If the build cannot run (an unattended `lerd park` sweep withholds builds) or fails, the site is still registered and lerd names the one command that finishes the job, `lerd php:rebuild `. +**Adding a version by hand**: `lerd php:rebuild ` names a version this machine has never installed and installs it, registering the unit before it builds so the version shows up in `lerd php:list` either way. That is the command every surface points at when one is missing, so a pin written ahead of the install (`lerd isolate 8.4` in a directory that is not a site yet) has a way to be satisfied without waiting for a link. + **Manual control**: unused PHP versions (no active sites) can be started and stopped manually from the dashboard (System > PHP > Start / Stop). From the CLI: ```bash diff --git a/internal/cli/aidocs/lerd-reference.md b/internal/cli/aidocs/lerd-reference.md index 0e5ce421d..55d7fc515 100644 --- a/internal/cli/aidocs/lerd-reference.md +++ b/internal/cli/aidocs/lerd-reference.md @@ -57,16 +57,17 @@ Actions: `start`, `stop`, `restart`, `pin`, `unpin`, `update`, `rollback`, `migr #### `db` — databases Actions: `list`, `set`, `move`, `create`, `export`, `import`, `snapshot`, `snapshots`, `restore`, `snapshot_delete`, `extension_list`, `extension_add`. - `list` reports an engine's databases with sizes; `service` picks the engine, else it resolves from the project. No introspect command, nothing to report -- `set` picks the project DB (`database`: sqlite, mysql, postgres, or a family alternate like mariadb / postgres-pgvector / postgres-timescaledb / mysql-5-7); persists to `.lerd.yaml`, rewrites `DB_` keys, starts the service, creates the DB + `_testing` +- `set` picks the project DB (`database`: sqlite, mysql, postgres, or a family alternate like mariadb / postgres-pgvector / postgres-timescaledb / mysql-5-7); persists to `.lerd.yaml`, writes the keys the framework declares for that engine, starts the service, creates the DB + `_testing`. sqlite is a wiring the framework declares, not a service: nothing is installed or started and it is not among the site's services, so never report it as stopped or missing. Moving between engines clears the framework's cache, which otherwise serves errors from definitions built against the old database - `move` migrates sites between two installed same-family services (`from`/`to`, `sites: [...]` or `all: true`) and repoints each `.env`; source data is left intact - `create`/`export`/`import` auto-detect service and database; pass `service` to override. `import` drops a hosted provider's ownership/DEFINER statements (which can never apply here) and creates any extension the dump's types need; pass `fresh: true` to empty the database first so a dump replaces what is there instead of colliding with it. What an engine can list and act on is declared in its preset, so this is not a mysql/postgres-only set - `extension_list`/`extension_add` are postgres-only. An `import` already creates whatever extension the dump's types reach for, so use these to see what the engine offers and what the database has, or to add one (`extension: postgis`) before any dump arrives - `snapshot`/`snapshots`/`restore`/`snapshot_delete` are named, restorable snapshots (MySQL/MariaDB/PostgreSQL); `restore` is destructive; `all_databases` covers the whole service -#### `env` — .env management +#### `env` — the file the framework actually reads Actions: `setup`, `check`, `override`. - `setup` configures services, DBs, APP_KEY and APP_URL; on a fresh Laravel clone call `db` `set` first to move off sqlite, then `env setup`, then ALWAYS `framework setup` or migrations never run -- `check` compares `.env` against `.env.example` +- the file and format come from the framework definition, not from an assumption of dotenv: a `.env`, WordPress's `wp-config.php` constants, a returned PHP array (Magento's `env.php`, CakePHP's `app_local.php`) or `$var[...]` assignments (Drupal's `settings.php`). Only changed statements are rewritten, so comments and hand edits survive, and a read-only settings file is unhardened for the write and restored after. Never hand-edit these to wire a service, and never assume Laravel's `DB_CONNECTION`/`DB_DATABASE` mean anything on a project that does not declare them +- `check` compares `.env` against `.env.example`. A key a dotenv file sets twice is a `site_doctor` finding, not an error here: lerd reads the first and Symfony reads the last, so the two disagree silently until someone picks one - `override` manages the personal, gitignored `.env.lerd_override` (its `set` KEY=VALUE win over lerd defaults; `LERD_EXTERNAL_SERVICES=` marks vars lerd writes but won't start) #### `runtime` — PHP/Node versions & extensions @@ -104,14 +105,14 @@ Actions: `list`, `add`, `remove`, `prune`, `search`, `update`, `project_new`, `s - `add` with `name: "laravel"` merges custom workers/setup into the built-in framework - `remove` refuses to drop a definition a linked site still uses (pass `force: true` to override); `prune` removes every framework definition no site uses - `search`/`update` use the community store; definitions auto-fetch on link, so `update` is the manual refresh (no `name` refreshes the catalogue and all installed definitions; with `name` it fetches that one, auto-detecting version from `composer.lock`) -- `project_new` scaffolds a new project (requires absolute `path`, default framework laravel); follow with `site` `link` + `env` `setup` +- `project_new` scaffolds a new project (requires absolute `path`, default framework laravel) from the definition the store publishes today; follow with `site` `link` + `env` `setup`. `lerd setup --list-steps` prints a project's setup plan as JSON and `--step` runs the named ones, which is the scriptable way to drive setup piecemeal - `setup` runs the framework's post-install steps (migrations, storage:link…) — MANDATORY after `env setup` on new/cloned projects; idempotent #### `diag` — diagnostics & observability Actions: `status`, `doctor`, `doctor_fix`, `site_doctor`, `which`, `check`, `dns_diagnose`, `bug_report`, `analyze_queries`, `route_timing`, `optimize_route`, `dumps_recent`, `dumps_status`, `dumps_clear`, `dumps_toggle`, `profiler_toggle`, `profiler_status`, `profiler_clear`, `profiler_report`, `xdebug_on`, `xdebug_off`, `xdebug_status`. - `status` (DNS/nginx/FPM/watcher/tools health) and `doctor` (JSON findings, each tagged with a fix tier) are the first stops when something is broken; `dns_diagnose` walks the DNS chain - `doctor_fix` applies the safe (non-heavy, non-sudo) automatic repairs for environment findings; package installs, `lerd install`, and `lerd cleanup` stay manual -- `site_doctor` runs framework-agnostic app-level checks for one site (env file, env drift, app key, composer/node dependency install + lock, `composer audit`/`npm audit`, PHP range, a `slow_routes` warning for routes whose p95 runs well above the site's typical time or over a second, plus the framework's own checks); pass `site` (name or domain) or `path`, defaults to cwd. A failing check carries a `severity` and, when one applies, a `fix` naming the command that resolves it — run that yourself (`exec`, or the named lerd command); site_doctor itself is read-only. `slow_routes` is the exception: it reads the watcher's request-timing snapshot and has no command fix, the remedy is to profile the route (`profiler_toggle`) +- `site_doctor` runs framework-agnostic app-level checks for one site (env file, env drift, app key, composer/node dependency install + lock, `composer audit`/`npm audit`, PHP range, a `slow_routes` warning for routes whose p95 runs well above the site's typical time or over a second, plus the framework's own checks); pass `site` (name or domain) or `path`, defaults to cwd. A failing check carries a `severity` and, when one applies, a `fix` naming the command that resolves it — run that yourself (`exec`, or the named lerd command); site_doctor itself is read-only. Three fixes are host-side rather than a container command and belong to `lerd site:doctor --fix`, which the user runs: installing or starting a service the site declares, rewriting an nginx vhost that has drifted from what lerd would write today, and repointing a project at the database it picked. `slow_routes` is the exception: it reads the watcher's request-timing snapshot and has no command fix, the remedy is to profile the route (`profiler_toggle`) - reading logs lives in the `logs` tool (below), not here - `which` shows resolved PHP/Node/docroot/nginx for a site; `check` validates `.lerd.yaml` - debug bridge loop: `dumps_toggle` (enable) → `dumps_clear` → hit the page → `analyze_queries` (N+1 / slow-query report with file:line) or `dumps_recent` (filter by site/branch/ctx/kind/since/limit) @@ -154,5 +155,6 @@ Actions: `list`, `create`, `rename`, `delete`, `assign`, `move`. - Built-in service hosts follow `lerd-` (e.g. `lerd-mysql`, `lerd-redis`, `lerd-postgres`); default DB credentials are username `root`, password `lerd` - **Custom container sites** (Node.js, Python, Go, …) — mandatory order: (1) write a Containerfile (default `Containerfile.lerd`); (2) write `.lerd.yaml` with `container: {port: }` (plus optional `domains`, `services`, `secured`); (3) configure the project's `.env` with service hosts (`lerd-mysql`, etc.) and start needed services via `service` `start`; (4) call `site` `link`. Never link before steps 1–3 or the site registers as PHP-FPM; if that happens, `site` `unlink`, write the files, then link again - Worker unit names follow `lerd--` (per-worktree: `lerd---`) +- **Opening a project (CLI-only)**: `lerd open` sends the site to the browser, `lerd code` the directory to the configured editor; inside a worktree both act on the checkout you are standing in - **Host tools (CLI-only)**: `diag` `status` reports Composer, fnm and mkcert against the versions lerd pins, and flags any that differ. Applying an update is `lerd tools:update`, which has no tool here, so tell the user to run it rather than looking for an action - **Sharing a site is CLI-only and deliberate**: `lerd share` (ngrok, cloudflared, Expose, serveo, localhost.run, Pinggy) and the dashboard's share menu put a site on the public internet, the same menu's public share serves it through the user's own reverse proxy on a base domain they control instead of a tunnel service, and `lerd lan:expose` puts it on the local network. None is exposed here, so never claim you can share a site; hand the user the command and let them decide diff --git a/internal/cli/fpm_ensure.go b/internal/cli/fpm_ensure.go index 0d0c85898..8361aa1d7 100644 --- a/internal/cli/fpm_ensure.go +++ b/internal/cli/fpm_ensure.go @@ -242,10 +242,12 @@ func persistWorktreePHPVersion(worktree, version string) { // notInstalledErr builds the error returned when a version isn't installed and // we can't prompt (no TTY) or have nothing to switch to. +// It names php:rebuild rather than `lerd install`, which takes no version and +// so could never add the one being asked for. func notInstalledErr(version string) error { if installed := otherInstalledVersions(version); len(installed) > 0 { - return fmt.Errorf("PHP %s is not installed (installed: %s) — pin one with a .php-version file, or run 'lerd install' to add %s", - version, strings.Join(installed, ", "), version) + return fmt.Errorf("PHP %s is not installed (installed: %s) — pin one with a .php-version file, or run 'lerd php:rebuild %s' to add %s", + version, strings.Join(installed, ", "), version, version) } - return fmt.Errorf("PHP %s is not installed — run 'lerd install' to add it", version) + return fmt.Errorf("PHP %s is not installed — run 'lerd php:rebuild %s' to add it", version, version) } diff --git a/internal/cli/install.go b/internal/cli/install.go index 70f2c3732..bef09b652 100644 --- a/internal/cli/install.go +++ b/internal/cli/install.go @@ -1041,6 +1041,16 @@ func runInstall(cmd *cobra.Command, _ []string) error { startPerSiteContainers() } + // Pull the current preset for every installed service and re-render what it + // changed. A store preset that adds a file mount or moves a dashboard behind + // the lerd-ui proxy reaches a running container only through this pass, and + // the surfaces that read the preset itself switch over the moment it lands, + // so a service left on its old unit answers nowhere the dashboard looks. + // After the start above, not before: the family-discovery pass that follows a + // start rewrites a consumer's unit too, and a reconcile that ran first would + // have its restart undone by a unit written after it. + refreshPresetsThenReconcile() + if wantLaravelInstaller { step("installing Laravel installer") if err := installLaravelInstaller(); err != nil { @@ -1087,7 +1097,6 @@ func runInstall(cmd *cobra.Command, _ []string) error { } refreshStoreFrameworks(storeIndex) - refreshStorePresets() refreshGlobalMCPSkills() refreshProjectMCPSkills() diff --git a/internal/cli/install_preset_refresh_test.go b/internal/cli/install_preset_refresh_test.go new file mode 100644 index 000000000..3bb0c6713 --- /dev/null +++ b/internal/cli/install_preset_refresh_test.go @@ -0,0 +1,29 @@ +package cli + +import ( + "reflect" + "testing" +) + +// The install used to fetch the current store presets at the very end of its +// run, after the services had already started from the quadlets they had. A +// preset that gained a file mount (phpmyadmin's Apache alias, which the +// dashboard proxy needs) therefore landed on disk with nothing left in the run +// to re-render the unit, so lerd-ui — which reads the preset, not the +// install-time copy — served the dashboard at a path the container knew nothing +// about until the user's next `lerd start`. The refresh has to come first and +// the reconcile has to follow it in the same run. +func TestRefreshPresetsThenReconcile_refreshesBeforeReconciling(t *testing.T) { + var order []string + + origRefresh, origReconcile := refreshPresetsFn, reconcileServicesFn + refreshPresetsFn = func() { order = append(order, "refresh") } + reconcileServicesFn = func() { order = append(order, "reconcile") } + t.Cleanup(func() { refreshPresetsFn, reconcileServicesFn = origRefresh, origReconcile }) + + refreshPresetsThenReconcile() + + if want := []string{"refresh", "reconcile"}; !reflect.DeepEqual(order, want) { + t.Errorf("ran %v, want %v", order, want) + } +} diff --git a/internal/cli/isolate.go b/internal/cli/isolate.go index 50e43b9c7..3950d6abd 100644 --- a/internal/cli/isolate.go +++ b/internal/cli/isolate.go @@ -7,6 +7,7 @@ import ( "github.com/geodro/lerd/internal/config" "github.com/geodro/lerd/internal/feedback" + phpDet "github.com/geodro/lerd/internal/php" "github.com/geodro/lerd/internal/siteops" "github.com/spf13/cobra" ) @@ -57,6 +58,9 @@ func runIsolate(_ *cobra.Command, args []string) error { _ = config.SetProjectPHPVersion(cwd, version) feedback.Begin() feedback.Done("PHP pinned to " + feedback.Val(version)) + if note := phpVersionNotInstalledNote(version); note != "" { + feedback.Note(note) + } return nil } @@ -82,6 +86,18 @@ func runIsolate(_ *cobra.Command, args []string) error { return nil } +// phpVersionNotInstalledNote describes a pin that names a PHP version this +// machine does not have, and returns "" when it does. A pin is legitimate on its +// own — link provisions the version when the directory becomes a site — but +// saying only "pinned" left every command run there next failing on a version +// the user was never told was missing. +func phpVersionNotInstalledNote(version string) string { + if phpDet.IsInstalled(version) { + return "" + } + return "PHP " + version + " has no image yet; 'lerd link' provisions it here, or run 'lerd php:rebuild " + version + "' to add it now" +} + // reportImageGap surfaces what the new version's image is missing. Changing // version is exactly when a site loses a custom extension, and lerd knows what // the target image holds, so staying quiet is the bug. diff --git a/internal/cli/isolate_uninstalled_test.go b/internal/cli/isolate_uninstalled_test.go new file mode 100644 index 000000000..b16675a3b --- /dev/null +++ b/internal/cli/isolate_uninstalled_test.go @@ -0,0 +1,36 @@ +package cli + +import ( + "strings" + "testing" +) + +// isolate on a directory that is not yet a site writes the pin and nothing else, +// so pinning a version this machine has never installed reported success and the +// next command in that directory failed with "PHP 8.4 is not installed". The pin +// is still correct; what was missing is saying the version has nowhere to run +// yet, and how to get it. +func TestPHPVersionNotInstalledNote_namesTheMissingVersion(t *testing.T) { + isolateUnitDir(t) + + note := phpVersionNotInstalledNote("8.4") + if note == "" { + t.Fatal("pinning an uninstalled version reported nothing") + } + if !strings.Contains(note, "8.4") { + t.Errorf("note %q does not name the version it is about", note) + } + if !strings.Contains(note, "php:rebuild 8.4") { + t.Errorf("note %q does not name a command that installs the version", note) + } +} + +// A version that is installed needs no note; the pin is all there is to say. +func TestPHPVersionNotInstalledNote_silentForAnInstalledVersion(t *testing.T) { + isolateUnitDir(t) + stageFPMQuadlet(t, "8.4") + + if note := phpVersionNotInstalledNote("8.4"); note != "" { + t.Errorf("note for an installed version: %q", note) + } +} diff --git a/internal/cli/mcp_test.go b/internal/cli/mcp_test.go index eeef65191..30dd1f5b2 100644 --- a/internal/cli/mcp_test.go +++ b/internal/cli/mcp_test.go @@ -588,9 +588,13 @@ func TestIsLerdBuiltImage_matchers(t *testing.T) { // names every route rather than reading as though only tunnels exist, then // 31000 → 31300 for the snapshot a data wipe now takes first: without it an // assistant hands back the renamed data dir as the recovery path, which after -// a version change is a directory nothing installed can read. +// a version change is a directory nothing installed can read, then 31300 → +// 33200 for the env contract, which is what stops an assistant hand-editing a +// settings.php or reading Laravel's key names on a project that declares none, +// plus sqlite as a wiring rather than a service, and the doctor fixes that run +// on the host rather than in the container. func TestLerdReference_underSizeCeiling(t *testing.T) { - const ceiling = 31300 + const ceiling = 33200 if got := len(lerdReference); got > ceiling { t.Errorf("lerd-reference.md is %d bytes, ceiling is %d — trim before raising", got, ceiling) } diff --git a/internal/cli/new_php_version_test.go b/internal/cli/new_php_version_test.go index 7be3ac54f..d5cfbf980 100644 --- a/internal/cli/new_php_version_test.go +++ b/internal/cli/new_php_version_test.go @@ -31,11 +31,13 @@ func fwWithRange(min, max string) *config.Framework { func TestScaffoldPHPVersion_clampsDefaultAboveTheRange(t *testing.T) { setDefaultPHP(t, "8.5") - // No in-range version is installed in the temp home, so the clamp falls back - // to the framework minimum, which is still inside the supported range. + // No in-range version is installed in the temp home, so the clamp lands on a + // boundary. It has to be the ceiling: a default above the range means the + // machine is newer than the framework, and answering the floor sends the + // scaffold to a PHP several releases older than the newest one it supports. got := scaffoldPHPVersion(fwWithRange("8.1", "8.3")) - if got != "8.1" { - t.Errorf("scaffoldPHPVersion = %q, want 8.1 (clamped into 8.1-8.3, not the 8.5 default)", got) + if got != "8.3" { + t.Errorf("scaffoldPHPVersion = %q, want 8.3 (the top of 8.1-8.3, not the 8.5 default)", got) } } diff --git a/internal/cli/php_rebuild.go b/internal/cli/php_rebuild.go index ec6f98d6d..3b20b7a4b 100644 --- a/internal/cli/php_rebuild.go +++ b/internal/cli/php_rebuild.go @@ -143,6 +143,25 @@ func restartInContainerWorkers() { } } +// registerPHPVersionForRebuild writes the FPM quadlet for a version this machine +// has never installed, so an explicit rebuild of it registers the version rather +// than building an image nothing points at. Every surface that reports a missing +// version sends the user here, and without the unit the build was invisible: +// php:list omitted the version, the shims still called it uninstalled, and the +// restart at the end of the rebuild failed on a unit that did not exist. Writing +// it first mirrors the ensure path, which registers before it builds so a failed +// build still leaves the version known. +func registerPHPVersionForRebuild(version string) error { + if phpPkg.IsInstalled(version) { + return nil + } + if err := writeFPMQuadlet(version); err != nil { + return fmt.Errorf("registering PHP %s: %w", version, err) + } + feedback.Note("registered PHP " + version + ", which was not installed") + return nil +} + // NewPhpRebuildCmd returns the php:rebuild command. func NewPhpRebuildCmd() *cobra.Command { cmd := &cobra.Command{ @@ -165,6 +184,9 @@ func runPhpRebuild(cmd *cobra.Command, args []string) error { if err != nil { return err } + if err := registerPHPVersionForRebuild(v); err != nil { + return err + } versions = []string{v} } else { var err error diff --git a/internal/cli/php_rebuild_register_test.go b/internal/cli/php_rebuild_register_test.go new file mode 100644 index 000000000..37a7fcd51 --- /dev/null +++ b/internal/cli/php_rebuild_register_test.go @@ -0,0 +1,76 @@ +package cli + +import ( + "os" + "path/filepath" + "testing" + + "github.com/geodro/lerd/internal/config" +) + +// stageFPMQuadlet writes the unit file that makes a PHP version count as +// installed, so a test can tell a version this machine has from one it doesn't. +func stageFPMQuadlet(t *testing.T, version string) { + t.Helper() + dir := config.QuadletDir() + if err := os.MkdirAll(dir, 0o755); err != nil { + t.Fatal(err) + } + short := "" + for _, c := range version { + if c != '.' { + short += string(c) + } + } + path := filepath.Join(dir, "lerd-php"+short+"-fpm.container") + if err := os.WriteFile(path, []byte("[Container]\n"), 0o644); err != nil { + t.Fatal(err) + } +} + +// php:rebuild is the command every surface names when a version is missing: the +// doctor's fix, isolate's image-gap note, the shim's not-installed error. It +// only ever built the image, so on a version this machine had never installed +// the build landed on nothing — php:list kept omitting it, `lerd php` kept +// calling it uninstalled, and the restart at the end failed with "unit not +// found". The rebuild registers the version first now. +func TestRegisterPHPVersionForRebuild_writesAMissingQuadlet(t *testing.T) { + isolateUnitDir(t) + + var wrote []string + orig := writeFPMQuadlet + writeFPMQuadlet = func(version string) error { + wrote = append(wrote, version) + return nil + } + t.Cleanup(func() { writeFPMQuadlet = orig }) + + if err := registerPHPVersionForRebuild("8.4"); err != nil { + t.Fatalf("registerPHPVersionForRebuild: %v", err) + } + if len(wrote) != 1 || wrote[0] != "8.4" { + t.Errorf("wrote quadlets for %v, want exactly [8.4]", wrote) + } +} + +// A version that is already installed keeps the unit it has: rewriting it would +// churn systemd on every rebuild for no change. +func TestRegisterPHPVersionForRebuild_leavesAnInstalledVersionAlone(t *testing.T) { + isolateUnitDir(t) + stageFPMQuadlet(t, "8.4") + + var wrote []string + orig := writeFPMQuadlet + writeFPMQuadlet = func(version string) error { + wrote = append(wrote, version) + return nil + } + t.Cleanup(func() { writeFPMQuadlet = orig }) + + if err := registerPHPVersionForRebuild("8.4"); err != nil { + t.Fatalf("registerPHPVersionForRebuild: %v", err) + } + if len(wrote) != 0 { + t.Errorf("rewrote the quadlet for an installed version: %v", wrote) + } +} diff --git a/internal/cli/update.go b/internal/cli/update.go index 95a61be29..fcdfc662e 100644 --- a/internal/cli/update.go +++ b/internal/cli/update.go @@ -304,6 +304,24 @@ func frameworkVersionOrder(version string) int { return n } +// Seams for the refresh/reconcile pair, swapped in tests so the order between +// them can be asserted without a network fetch or a systemd reload. +var ( + refreshPresetsFn = refreshStorePresets + reconcileServicesFn = reconcileCustomServices +) + +// refreshPresetsThenReconcile pulls the current store preset for every installed +// service and then re-renders the services those presets describe. The order is +// the point: the reconcile is what carries a store change into a running +// container, and running it against presets that have not been refreshed yet +// leaves the container a release behind while every surface that reads the +// preset directly has already moved on. +func refreshPresetsThenReconcile() { + refreshPresetsFn() + reconcileServicesFn() +} + // refreshStorePresets re-fetches the store preset backing every installed // service so its definition and file mounts keep resolving offline after an // upgrade, mirroring refreshStoreFrameworks. Best-effort: a failed fetch leaves diff --git a/internal/php/detector.go b/internal/php/detector.go index 91be720f9..d3e04f80d 100644 --- a/internal/php/detector.go +++ b/internal/php/detector.go @@ -168,21 +168,24 @@ func ClampToRange(version, min, max string) string { return version } - inRange := true + // Which bound the version misses decides where it lands when nothing + // installed satisfies the range, so track them apart rather than as one + // in-range flag. + belowMin, aboveMax := false, false if min != "" { mMajor, mMinor := parseMajorMinor(min) if mMajor >= 0 && (major < mMajor || (major == mMajor && minor < mMinor)) { - inRange = false + belowMin = true } } if max != "" { mMajor, mMinor := parseMajorMinor(max) if mMajor >= 0 && (major > mMajor || (major == mMajor && minor > mMinor)) { - inRange = false + aboveMax = true } } - if inRange { + if !belowMin && !aboveMax { return version } @@ -199,7 +202,14 @@ func ClampToRange(version, min, max string) string { return best } - // No installed version in range; fall back to min if set. + // Nothing installed satisfies the range, so fall back to the bound the + // version missed. Answering min for a version that sat above max handed the + // caller the oldest release the framework tolerates rather than the newest + // it supports, which is what `lerd new` then tried to scaffold under: a + // default of 8.5 against a framework capped at 8.4 asked for 8.1. + if aboveMax { + return max + } if min != "" { return min } diff --git a/internal/php/detector_test.go b/internal/php/detector_test.go index 36cdeda72..cac813116 100644 --- a/internal/php/detector_test.go +++ b/internal/php/detector_test.go @@ -290,6 +290,10 @@ func TestDetectExtensions_NoExtRequires(t *testing.T) { // ── ClampToRange ───────────────────────────────────────────────────────────── func TestClampToRange(t *testing.T) { + // Stage an empty installed set so every case lands on the boundary fallback + // rather than on whatever the developer happens to have installed, which is + // the branch that decides which version a caller is offered. + installedPHP(t) tests := []struct { version, min, max, want string }{ @@ -304,14 +308,18 @@ func TestClampToRange(t *testing.T) { // Below min — clamps (falls back to min if no installed version). {"8.1", "8.2", "8.4", "8.2"}, {"7.4", "8.2", "8.5", "8.2"}, - // Above max — clamps. + // Above max — clamps to the ceiling, the newest the framework supports. {"8.5", "8.2", "8.4", "8.4"}, + {"8.5", "8.1", "8.4", "8.4"}, + {"8.5", "8.2", "8.2", "8.2"}, + // Above a range with no ceiling — nothing to clamp against, keep the floor. + {"8.5", "8.2", "", "8.5"}, } for _, tt := range tests { got := ClampToRange(tt.version, tt.min, tt.max) - // ClampToRange may pick a different installed version within range, - // but when no installed version matches it falls back to min/max boundary. - // We check that the result is within the stated range. + if got != tt.want { + t.Errorf("ClampToRange(%q, %q, %q) = %q, want %q", tt.version, tt.min, tt.max, got, tt.want) + } if tt.min != "" { gMaj, gMin := parseMajorMinor(got) mMaj, mMin := parseMajorMinor(tt.min) diff --git a/internal/serviceops/reconcile.go b/internal/serviceops/reconcile.go index 1bedd4036..37966d15d 100644 --- a/internal/serviceops/reconcile.go +++ b/internal/serviceops/reconcile.go @@ -3,7 +3,9 @@ package serviceops import ( "errors" "fmt" - "slices" + "os" + "path/filepath" + "time" "github.com/geodro/lerd/internal/config" "github.com/geodro/lerd/internal/podman" @@ -19,8 +21,34 @@ var ( newestFileMtimeFn = config.ServiceFilesNewestMtime containerStartedAtFn = podman.ContainerStartedAt restartUnitFn = podman.RestartUnit + quadletMtimeFn = quadletMtime ) +// quadletMtime reports when a service's unit file was last written, and whether +// it exists at all. +func quadletMtime(name string) (time.Time, bool) { + info, err := os.Stat(filepath.Join(config.QuadletDir(), "lerd-"+name+".container")) + if err != nil { + return time.Time{}, false + } + return info.ModTime(), true +} + +// containerOlderThanUnit reports whether a running container was started before +// the unit file it runs from was last written. A unit rewritten by an earlier +// pass — the install refreshes every service quadlet long before it reaches the +// reconcile — leaves nothing for this pass to change, so asking only "did I just +// rewrite it" let a container keep serving a definition that had already moved +// on. Comparing against the container's own start time catches both. +func containerOlderThanUnit(name string) bool { + started, running := containerStartedAtFn("lerd-" + name) + if !running { + return false + } + mtime, ok := quadletMtimeFn(name) + return ok && mtime.After(started) +} + // ReconcileResult reports what ReconcileServices changed. type ReconcileResult struct { QuadletsRegenerated []string // YAML present, unit was missing and regenerated @@ -63,25 +91,26 @@ func ReconcileServices(emit func(PhaseEvent)) (ReconcileResult, error) { } } unitInstalled := UnitInstalledFn("lerd-" + svc.Name) - // Regenerate the quadlet when the unit is missing, or when the definition - // changed. WriteQuadletDiff is a no-op when the content is identical, so a - // client_shims-only change (which never appears in the quadlet) is free. + // Render the quadlet every pass and let the content decide. What a unit + // renders to is not all in the YAML: file mounts and the proxy env come + // from the preset when the quadlet is written and are never stored on the + // service, so gating this on the definition having changed left a preset + // that added either of them unable to reach the unit at all. WriteQuadletDiff + // is a no-op when the content is identical, so a steady state costs a + // comparison and a change nobody can see in the YAML still lands. // - // Before the drift restart below, not after: a definition that adds a file + // Before the drift restart below, not after: a change that adds a file // mount changes the unit too, and restarting the old one brings the // container back without the mount, leaving it a pass behind until // something restarts it again. - unitChanged := false - if !unitInstalled || slices.Contains(res.DefinitionsRefreshed, svc.Name) { - changed, err := ensureQuadletFn(svc) - if err != nil { - errs = append(errs, fmt.Errorf("regenerating quadlet for %s: %w", svc.Name, err)) - continue - } - unitChanged = changed - if !unitInstalled { - res.QuadletsRegenerated = append(res.QuadletsRegenerated, svc.Name) - } + changed, err := ensureQuadletFn(svc) + if err != nil { + errs = append(errs, fmt.Errorf("regenerating quadlet for %s: %w", svc.Name, err)) + continue + } + unitChanged := changed + if !unitInstalled { + res.QuadletsRegenerated = append(res.QuadletsRegenerated, svc.Name) } if unitInstalled { applied, err := RestartIfConfigDrifted(svc.Name, svc.Preset) @@ -89,7 +118,7 @@ func ReconcileServices(emit func(PhaseEvent)) (ReconcileResult, error) { errs = append(errs, err) } else if applied { res.ConfigsApplied = append(res.ConfigsApplied, svc.Name) - } else if unitChanged { + } else if unitChanged || containerOlderThanUnit(svc.Name) { // The drift check reads the materialised config files and is blind to // the unit itself, so a definition that moved a port, changed the image // or added an environment variable would sit rewritten on disk while the diff --git a/internal/serviceops/reconcile_test.go b/internal/serviceops/reconcile_test.go index 3ee8edb48..803812e3f 100644 --- a/internal/serviceops/reconcile_test.go +++ b/internal/serviceops/reconcile_test.go @@ -387,7 +387,21 @@ func TestReconcileServices_noRestartWhenConfigCurrent(t *testing.T) { } writeQuadlet(t, "mysql", true) + // The staged unit is a stand-in, not what the generator would write, so + // report it unchanged: this test is about the drift decision, and a unit + // that rewrote itself here would restart the container for its own reason. + prevEnsure := ensureQuadletFn + t.Cleanup(func() { ensureQuadletFn = prevEnsure }) + ensureQuadletFn = func(*config.CustomService) (bool, error) { return false, nil } + boot := time.Unix(1_000_000, 0) + // The staged file carries a real mtime while the container's boot is + // synthetic, so date the unit behind it: this service booted from the unit it + // has, which is the case being asserted. + prevMtime := quadletMtimeFn + t.Cleanup(func() { quadletMtimeFn = prevMtime }) + quadletMtimeFn = func(string) (time.Time, bool) { return boot.Add(-time.Hour), true } + restarted := false restore := swapDriftSeams(t, func(*config.CustomService) error { return nil }, @@ -552,3 +566,134 @@ func TestReconcileServices_leavesAStoppedServiceStopped(t *testing.T) { t.Error("a stopped service was started by a unit rewrite") } } + +// A store preset can change the unit without changing the YAML saved when the +// service was installed: file mounts and the proxy env are read from the preset +// at render time and never stored on the service. Gating the re-render on the +// definition having changed meant a preset that moved a dashboard behind the +// lerd-ui proxy landed on disk with the container still on a unit that had no +// mount for it, so the dashboard answered 404 at the path the UI had already +// switched to. What the unit would render to decides, not what the YAML says. +func TestReconcileServices_regeneratesWhenOnlyThePresetChanged(t *testing.T) { + reconcileEnv(t) + if err := config.SaveStorePreset("probe-svc", []byte("name: probe-svc\nimage: example/probe:1\ndashboard: http://localhost:9999\n")); err != nil { + t.Fatalf("store preset: %v", err) + } + // Saved exactly as the preset resolves, so nothing about the definition is + // stale and the refresh reports no change. + if err := config.SaveCustomService(&config.CustomService{ + Name: "probe-svc", + Image: "example/probe:1", + Dashboard: "http://localhost:9999", + Preset: "probe-svc", + }); err != nil { + t.Fatalf("save: %v", err) + } + writeQuadlet(t, "probe-svc", true) + + var order []string + prevEnsure := ensureQuadletFn + t.Cleanup(func() { ensureQuadletFn = prevEnsure }) + ensureQuadletFn = func(*config.CustomService) (bool, error) { + order = append(order, "regenerate") + return true, nil + } + + boot := time.Unix(1_000_000, 0) + restore := swapDriftSeams(t, + func(*config.CustomService) error { return nil }, + func(*config.CustomService) (time.Time, bool) { return time.Time{}, false }, + func(string) (time.Time, bool) { return boot, true }, + func(string) error { order = append(order, "restart"); return nil }, + func(string) bool { return true }, + ) + defer restore() + + res, err := ReconcileServices(nil) + if err != nil { + t.Fatalf("reconcile: %v", err) + } + if len(res.DefinitionsRefreshed) != 0 { + t.Fatalf("the definition was not supposed to change, got %v", res.DefinitionsRefreshed) + } + if len(order) != 2 || order[0] != "regenerate" || order[1] != "restart" { + t.Fatalf("a preset-only change never reached the unit, got %v", order) + } +} + +// A container is stale against a unit that changed at any point, not only +// against one this pass rewrote. The install rewrites service quadlets early and +// leaves an already-running container alone, so by the time the reconcile came +// round the unit on disk was current, nothing looked changed, and the container +// kept serving from the definition it booted with. What the container started +// before decides. +func TestReconcileServices_restartsAContainerOlderThanItsUnit(t *testing.T) { + reconcileEnv(t) + if err := config.SaveCustomService(&config.CustomService{Name: "probe-svc", Image: "example/probe:1"}); err != nil { + t.Fatalf("save: %v", err) + } + writeQuadlet(t, "probe-svc", true) + + prevEnsure := ensureQuadletFn + t.Cleanup(func() { ensureQuadletFn = prevEnsure }) + // Nothing to rewrite: the unit on disk is already what it should be. + ensureQuadletFn = func(*config.CustomService) (bool, error) { return false, nil } + + prevMtime := quadletMtimeFn + t.Cleanup(func() { quadletMtimeFn = prevMtime }) + boot := time.Unix(1_000_000, 0) + quadletMtimeFn = func(string) (time.Time, bool) { return boot.Add(time.Hour), true } + + restarted := false + restore := swapDriftSeams(t, + func(*config.CustomService) error { return nil }, + func(*config.CustomService) (time.Time, bool) { return time.Time{}, false }, + func(string) (time.Time, bool) { return boot, true }, + func(string) error { restarted = true; return nil }, + func(string) bool { return true }, + ) + defer restore() + + if _, err := ReconcileServices(nil); err != nil { + t.Fatalf("reconcile: %v", err) + } + if !restarted { + t.Fatal("a container older than its unit was left running on the old definition") + } +} + +// The same check must not restart a container that already booted from the +// current unit, or every pass would bounce every service. +func TestReconcileServices_leavesAContainerNewerThanItsUnit(t *testing.T) { + reconcileEnv(t) + if err := config.SaveCustomService(&config.CustomService{Name: "probe-svc", Image: "example/probe:1"}); err != nil { + t.Fatalf("save: %v", err) + } + writeQuadlet(t, "probe-svc", true) + + prevEnsure := ensureQuadletFn + t.Cleanup(func() { ensureQuadletFn = prevEnsure }) + ensureQuadletFn = func(*config.CustomService) (bool, error) { return false, nil } + + prevMtime := quadletMtimeFn + t.Cleanup(func() { quadletMtimeFn = prevMtime }) + boot := time.Unix(1_000_000, 0) + quadletMtimeFn = func(string) (time.Time, bool) { return boot.Add(-time.Hour), true } + + restarted := false + restore := swapDriftSeams(t, + func(*config.CustomService) error { return nil }, + func(*config.CustomService) (time.Time, bool) { return time.Time{}, false }, + func(string) (time.Time, bool) { return boot, true }, + func(string) error { restarted = true; return nil }, + func(string) bool { return true }, + ) + defer restore() + + if _, err := ReconcileServices(nil); err != nil { + t.Fatalf("reconcile: %v", err) + } + if restarted { + t.Fatal("a container already running the current unit was restarted for nothing") + } +} diff --git a/internal/systemd/dbus_linux.go b/internal/systemd/dbus_linux.go index 1b3555903..2d3d95b1c 100644 --- a/internal/systemd/dbus_linux.go +++ b/internal/systemd/dbus_linux.go @@ -12,6 +12,7 @@ import ( "github.com/coreos/go-systemd/v22/daemon" "github.com/coreos/go-systemd/v22/dbus" + godbus "github.com/godbus/dbus/v5" ) // errUnitOpTimedOut is the sentinel a single unit-op attempt returns when the @@ -162,12 +163,29 @@ func DBusStartUnit(name string) error { // DBusStopUnit stops a user unit via DBus. func DBusStopUnit(name string) error { if err := dbusUnitOp("stop", "stop", name); err != nil { + if unitNotLoaded(err) { + return nil + } return err } _ = DBusResetFailed(name) return nil } +// unitNotLoaded reports whether an op failed because systemd has no such unit. +// For a stop that is the goal already met, not a failure: the stop set is built +// partly from what a site declares rather than from what is on disk, so a +// machine that never installed a declared worker named units systemd has never +// heard of and a clean shutdown printed a failure for each one. launchd's side +// has always treated its equivalent (exit 36) as success. +func unitNotLoaded(err error) bool { + var dberr godbus.Error + if errors.As(err, &dberr) { + return dberr.Name == "org.freedesktop.systemd1.NoSuchUnit" + } + return false +} + // DBusRestartUnit restarts a user unit via DBus. func DBusRestartUnit(name string) error { return dbusUnitOp("restart", "restart", name) diff --git a/internal/systemd/dbus_linux_test.go b/internal/systemd/dbus_linux_test.go index e0c4d04ea..533309be4 100644 --- a/internal/systemd/dbus_linux_test.go +++ b/internal/systemd/dbus_linux_test.go @@ -2,7 +2,13 @@ package systemd -import "testing" +import ( + "errors" + "fmt" + "testing" + + godbus "github.com/godbus/dbus/v5" +) // withServiceSuffix is the gate every DBus unit op passes through. A bug here // breaks Start/Stop/Restart/Enable/Disable/IsEnabled for every caller, so the @@ -158,3 +164,31 @@ func TestRunUnitOpWithRetry(t *testing.T) { } }) } + +// A stop aimed at a unit systemd has never loaded is already satisfied: there +// is nothing running to stop. `lerd stop` enumerates workers a site declares, +// which on a machine that never installed one names a unit systemd does not +// know, so a clean shutdown reported a screen of failures for workers that were +// never up. +func TestUnitNotLoaded(t *testing.T) { + missing := godbus.Error{ + Name: "org.freedesktop.systemd1.NoSuchUnit", + Body: []any{"Unit lerd-queue-acme.service not loaded."}, + } + if !unitNotLoaded(fmt.Errorf("stop lerd-queue-acme failed: %w", missing)) { + t.Error("a NoSuchUnit error must read as already stopped") + } + + // A unit that exists and genuinely failed to stop must still be an error, or + // a stop that left a container running would report success. + realFailure := godbus.Error{ + Name: "org.freedesktop.systemd1.JobFailed", + Body: []any{"Job for lerd-mysql.service failed."}, + } + if unitNotLoaded(fmt.Errorf("stop lerd-mysql failed: %w", realFailure)) { + t.Error("a genuine stop failure was swallowed") + } + if unitNotLoaded(errors.New("stop lerd-mysql timed out after 30s")) { + t.Error("a timeout was swallowed") + } +} diff --git a/internal/ui/web/demo/README.md b/internal/ui/web/demo/README.md index ac323dd37..8d9f321d8 100644 --- a/internal/ui/web/demo/README.md +++ b/internal/ui/web/demo/README.md @@ -12,7 +12,11 @@ How it works: - `fixtures/` are real `/api/*` responses captured from a running daemon, then sanitized (site names, domains, paths and app names swapped for demo values). Regenerate the same way: snapshot the endpoints, scrub identifying fields. -- The theme is forced to dark to match the landing page. +- The theme follows the system preference, so a capture of the demo has to pin + the browser to dark rather than assume it. +- Service, framework and worker marks come from `fixtures/*-marks.json` and + `fixtures/service-icons.json`, captured off a running daemon the same way + every other fixture is. Recapture them when the stores publish new artwork. Build it: diff --git a/internal/ui/web/demo/fixtures/framework-marks.json b/internal/ui/web/demo/fixtures/framework-marks.json new file mode 100644 index 000000000..d8de46c79 --- /dev/null +++ b/internal/ui/web/demo/fixtures/framework-marks.json @@ -0,0 +1 @@ +{"cakephp":{"color":"#d33c43","svg":""},"codeigniter":{"color":"#ef4223","svg":""},"drupal":{"color":"#0678be","svg":""},"laravel":{"color":"#ff2d20","svg":""},"magento":{"color":"#e85d22","svg":""},"statamic":{"color":"#ff269e","svg":""},"symfony":{"color":"#000000","svg":""},"wordpress":{"color":"#21759b","svg":""}} diff --git a/internal/ui/web/demo/fixtures/service-icons.json b/internal/ui/web/demo/fixtures/service-icons.json new file mode 100644 index 000000000..cd9efe88e --- /dev/null +++ b/internal/ui/web/demo/fixtures/service-icons.json @@ -0,0 +1 @@ +{"clickhouse":"","elasticsearch":"","mailpit":"\n","mariadb":"","meilisearch":"\n","mongo":"","mysql":"\n","opensearch":"","phpmyadmin":"","postgres":"\n","postgres-pgvector":"","postgres-timescaledb":"","rabbitmq":"","redis":"\n","selenium":"","stripe-mock":"","valkey":""} diff --git a/internal/ui/web/demo/fixtures/version.json b/internal/ui/web/demo/fixtures/version.json index 3cd13278a..9de5fc21c 100644 --- a/internal/ui/web/demo/fixtures/version.json +++ b/internal/ui/web/demo/fixtures/version.json @@ -1 +1 @@ -{"current":"1.32.0","latest":"","has_update":false} \ No newline at end of file +{"current":"1.33.0","latest":"","has_update":false} \ No newline at end of file diff --git a/internal/ui/web/demo/fixtures/worker-marks.json b/internal/ui/web/demo/fixtures/worker-marks.json new file mode 100644 index 000000000..a8419f440 --- /dev/null +++ b/internal/ui/web/demo/fixtures/worker-marks.json @@ -0,0 +1 @@ +{"marks":{"horizon":"\n","vite":"\n"},"workers":{"cakephp/queue":{"color":"#d33c43","icon":"queue"},"codeigniter/queue":{"color":"#ef4223","icon":"queue"},"drupal/cron":{"color":"#0678be","icon":"clock"},"laravel/horizon":{"color":"#405263","icon":"horizon"},"laravel/queue":{"color":"#ff2d20","icon":"queue"},"laravel/reverb":{"color":"#ff2d20","icon":"broadcast"},"laravel/schedule":{"color":"#ff2d20","icon":"clock"},"laravel/vite":{"color":"#9135ff","icon":"vite"},"magento/consumers":{"color":"#e85d22","icon":"queue"},"magento/cron":{"color":"#e85d22","icon":"clock"},"statamic/queue":{"color":"#ff269e","icon":"queue"},"statamic/schedule":{"color":"#ff269e","icon":"clock"},"symfony/messenger":{"color":"#000000","icon":"queue"},"symfony/scheduler":{"color":"#000000","icon":"clock"},"tempest/command_bus":{"icon":"bolt"},"tempest/schedule":{"icon":"clock"},"tempest/vite":{"color":"#9135ff","icon":"vite"}}} diff --git a/internal/ui/web/demo/stubs.ts b/internal/ui/web/demo/stubs.ts index 8fea5b46b..d8c134c7c 100644 --- a/internal/ui/web/demo/stubs.ts +++ b/internal/ui/web/demo/stubs.ts @@ -6,6 +6,9 @@ import version from './fixtures/version.json'; import sitesFixture from './fixtures/sites.json'; import servicesFixture from './fixtures/services.json'; import presetsFixture from './fixtures/presets.json'; +import serviceIcons from './fixtures/service-icons.json'; +import frameworkMarks from './fixtures/framework-marks.json'; +import workerMarks from './fixtures/worker-marks.json'; import statusFixture from './fixtures/status.json'; import accessMode from './fixtures/access-mode.json'; import settings from './fixtures/settings.json'; @@ -571,9 +574,11 @@ window.fetch = async (input: RequestInfo | URL, init?: RequestInit): Promise // -X github.com/geodro/lerd/internal/version.Date= var ( - Version = "1.32.0" + Version = "1.33.0" Commit = "none" Date = "unknown" )