Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
f6122c6
fix(env): keep a php-array config parseable when keys share a parent
geodro Aug 11, 2026
43f5025
fix(init): offer SQLite from its own wiring rather than a service entry
geodro Aug 11, 2026
7c01c6e
fix(ui): keep a run from wedging on an over-long output line
geodro Aug 11, 2026
cbe3b91
fix(ui): release finished runs on an idle daemon
geodro Aug 11, 2026
e99b9c6
fix(ui): stop mangling backslashes in the run log
geodro Aug 11, 2026
b47d2c0
fix(dashboard): read worker marks off the newest cached definition
geodro Aug 11, 2026
53c97d1
fix(init): keep a project's pinned Node version on the PHP path
geodro Aug 11, 2026
9cbce75
fix(services): restart a service when only its unit changed
geodro Aug 11, 2026
72909ea
perf(ui): resolve every engine's databases in one pass over the sites
geodro Aug 11, 2026
dd32a75
fix(setup): pass over a step the re-derived plan no longer offers
geodro Aug 11, 2026
bad3f45
fix(env): drop a php-array edit that overlaps one already applied
geodro Aug 12, 2026
76a7305
fix(init): tell an unpinned Node answer from one nobody was asked
geodro Aug 12, 2026
9b7f051
fix(env): create the SQLite file the framework's own values name
geodro Aug 12, 2026
9ac6904
fix(env): stop the php-array writer corrupting what it cannot edit
geodro Aug 12, 2026
f97c804
fix(init): the Node question decides who may clear the pin, kind incl…
geodro Aug 12, 2026
f492fe8
fix(env): create the SQLite file where the final values say it lives
geodro Aug 12, 2026
04646f9
fix(ui): let the wizard resume what it parked, however long ago
geodro Aug 12, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 5 additions & 2 deletions docs/features/project-setup.md
Original file line number Diff line number Diff line change
Expand Up @@ -177,5 +177,8 @@ If a step fails, you are prompted to continue or abort:
`--list-steps` and `--step` are how a caller with no terminal works through the
same list the selector shows: the dashboard's site wizard enumerates the steps
for a project, shows them as checkboxes, and runs the ticked ones one at a time.
Naming a step this directory does not offer fails with the list it does offer,
so a stale plan is reported rather than half-run.
The plan is re-derived on every invocation, and its steps are gated on live
state: whether the site is already secured, what a worker scan finds, a file the
previous step wrote. So a step named here that the plan no longer offers is
work that no longer needs doing, and it is reported as such and passed over
rather than failing the run and stopping the rest of a queued list.
29 changes: 27 additions & 2 deletions docs/usage/framework-definitions.md
Original file line number Diff line number Diff line change
Expand Up @@ -101,9 +101,26 @@ A service a project picks in its `.lerd.yaml` is expected to appear in the env f

### Offering SQLite

A framework that can run on a file database declares a `sqlite` service alongside its others, and that declaration is what puts SQLite in the database choice at `lerd init`. A framework declaring none is never offered it, since picking it would configure a project for a database its application cannot open. A project lerd recognises no framework for keeps the option: nothing has declared otherwise.
A framework that can run on a file database declares an `env.sqlite` block, and that declaration is what puts SQLite in the database choice at `lerd init`. It takes the same `detect` and `vars` a service mapping takes: the detect rules say a project is already on a file database, the vars are what lerd writes to point it at one. A framework declaring none is never offered it, since picking it would configure a project for a database its application cannot open. A project lerd recognises no framework for keeps the option: nothing has declared otherwise.

Choosing it records nothing in `.lerd.yaml`. SQLite has no preset, no container and nothing to install, so a `services:` entry for it is one every surface then has to explain away, and the project's own configuration already says it is on SQLite, which is what lerd reads to answer that question. An entry left by an older lerd is ignored where it is found.
```yaml
env:
file: .env
sqlite:
detect:
- key: DB_CONNECTION
value_prefix: sqlite
vars:
- DB_CONNECTION=sqlite
- DB_DATABASE=database/database.sqlite
services:
mysql:
# …
```

It sits beside `services:` rather than among them because SQLite is not a service. Nothing installs it, starts it or draws a card for it, and a binary that read it as a service entry would announce it and then try to start a container that does not exist. That is also why it is a separate field rather than a `services:` key: a published definition reaches every install within a day, whatever version it runs, and an unknown field is ignored while an unknown service is not.

Choosing it records nothing in `.lerd.yaml`, for the same reason: the project's own configuration already says it is on SQLite, which is what lerd reads to answer that question. An entry left by an older lerd is ignored where it is found.

### Drop-in services

Expand Down Expand Up @@ -163,6 +180,14 @@ env:
command: key:generate
fallback_prefix: "base64:"

sqlite: # wiring for a file database (optional). Not a
detect: # service: nothing installs, starts or draws it.
- key: DB_CONNECTION # Declaring it is what offers SQLite at `lerd init`.
value_prefix: sqlite
vars:
- "DB_CONNECTION=sqlite"
- "DB_DATABASE=database/database.sqlite"

# Per-service env detection and variable injection for `lerd env`
#
# Template variables available in vars values:
Expand Down
49 changes: 32 additions & 17 deletions internal/cli/env.go
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import (
phpDet "github.com/geodro/lerd/internal/php"
"github.com/geodro/lerd/internal/podman"
"github.com/geodro/lerd/internal/serviceops"
"github.com/geodro/lerd/internal/sitedoctor"
"github.com/geodro/lerd/internal/sitetpl"
"github.com/spf13/cobra"
)
Expand Down Expand Up @@ -461,8 +462,10 @@ func projectUsesSQLite(lerdYAMLServices map[string]bool, envMap map[string]strin
if fw == nil || externalDB || userPickedDBFromYAML(lerdYAMLServices) {
return false
}
def, ok := fw.Env.Services["sqlite"]
return ok && frameworkServiceDetected(def, envMap)
if fw.Env.SQLite == nil {
return false
}
return frameworkServiceDetected(*fw.Env.SQLite, envMap)
}

func userPickedDBFromYAML(lerdYAMLServices map[string]bool) bool {
Expand Down Expand Up @@ -908,23 +911,15 @@ func runEnv(_ *cobra.Command, _ []string) error {
}

// 3a-bis. SQLite is not a containerized service but is a valid choice from
// the init wizard / runtime DB prompt. Apply the framework's sqlite env vars
// and ensure the database file exists so migrations can run immediately. No
// service to start, no SQL DB to create.
if projectUsesSQLite(lerdYAMLServices, envMap, fw, externalDBPicked(extServices)) {
// the init wizard / runtime DB prompt. Apply the framework's sqlite env vars;
// the database file itself is created after step 4e, once every value that
// can name it, the personal override included, has had its say.
sqliteWired := projectUsesSQLite(lerdYAMLServices, envMap, fw, externalDBPicked(extServices))
if sqliteWired {
envApplyLine("sqlite", !lerdYAMLServices["sqlite"])
for _, kv := range serviceEnvVars("sqlite") {
for _, kv := range sqliteVarsFor(fw) {
k, v, _ := strings.Cut(kv, "=")
updates[k] = v
}
sqlitePath := filepath.Join(cwd, "database", "database.sqlite")
if _, statErr := os.Stat(sqlitePath); os.IsNotExist(statErr) {
if err := os.MkdirAll(filepath.Dir(sqlitePath), 0o755); err == nil {
if f, err := os.Create(sqlitePath); err == nil {
_ = f.Close()
envInfo(" Created %s\n", filepath.Join("database", "database.sqlite"))
}
}
updates[k] = applySiteHandle(v, tplCtx)
}
}

Expand Down Expand Up @@ -1079,6 +1074,26 @@ func runEnv(_ *cobra.Command, _ []string) error {
}
}

// The SQLite file is created here, not at 3a-bis, so it is the one the
// final values name: an override pointing the database somewhere else, or a
// DSN like Symfony's naming var/data.db, must not leave an empty stray file
// at a default path while the file the application opens is still missing.
// Where it lands follows the doctor's own resolution rules.
if sqliteWired {
if rel, ok := sitedoctor.SQLiteFileFromValues(updates, fw); ok {
if target, create := sitedoctor.SQLiteCreationTarget(cwd, fw, filepath.FromSlash(rel)); create {
if err := os.MkdirAll(filepath.Dir(target), 0o755); err == nil {
if f, err := os.Create(target); err == nil {
_ = f.Close()
if shown, relErr := filepath.Rel(cwd, target); relErr == nil {
envInfo(" Created %s\n", shown)
}
}
}
}
}
}

// 5. Rewrite the env file preserving order, comments, and blank lines.
// Whether the connection actually moved is worked out before writing, by
// comparing what is about to be written against what the file already
Expand Down
14 changes: 8 additions & 6 deletions internal/cli/env_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -839,16 +839,18 @@ func TestWiredVarsFor_ExternalUnmappedKeepsThePresetKeys(t *testing.T) {
// the file itself, and the first request 500'd on a database that was never
// created. The project's own configuration is the signal instead.
func TestProjectUsesSQLite(t *testing.T) {
laravelish := &config.Framework{Env: config.FrameworkEnvConf{Services: map[string]config.FrameworkServiceDef{
"sqlite": {
laravelish := &config.Framework{Env: config.FrameworkEnvConf{
SQLite: &config.FrameworkServiceDef{
Detect: []config.FrameworkServiceDetect{{Key: "DB_CONNECTION", ValuePrefix: "sqlite"}},
Vars: []string{"DB_CONNECTION=sqlite", "DB_DATABASE=database/database.sqlite"},
},
"mysql": {
Detect: []config.FrameworkServiceDetect{{Key: "DB_CONNECTION", ValuePrefix: "mysql"}},
Vars: []string{"DB_CONNECTION=mysql", "DB_HOST=lerd-mysql"},
Services: map[string]config.FrameworkServiceDef{
"mysql": {
Detect: []config.FrameworkServiceDetect{{Key: "DB_CONNECTION", ValuePrefix: "mysql"}},
Vars: []string{"DB_CONNECTION=mysql", "DB_HOST=lerd-mysql"},
},
},
}}}
}}
sqliteEnv := map[string]string{"DB_CONNECTION": "sqlite"}

for _, tc := range []struct {
Expand Down
14 changes: 8 additions & 6 deletions internal/cli/init.go
Original file line number Diff line number Diff line change
Expand Up @@ -500,6 +500,9 @@ func runCustomContainerWizard(cwd string, defaults *config.ProjectConfig, gcfg *
Container: containerCfg,
AppURL: defaults.AppURL,
Domains: defaults.Domains,
// This wizard never asks about Node, so a pin the project carries is
// not its to drop.
NodeVersion: defaults.NodeVersion,
}, nil
}

Expand Down Expand Up @@ -730,15 +733,14 @@ func persistedServices(dbChoice string, nonDB []string) []string {
}

// frameworkSupportsSQLite reports whether a framework can be wired to a file
// database, which the definition says by declaring a sqlite service alongside
// its others. A project with no framework at all keeps the option: nothing has
// declared otherwise, and lerd should not decide for it.
// database, which the definition says with its own sqlite wiring. A project
// with no framework at all keeps the option: nothing has declared otherwise,
// and lerd should not decide for it.
func frameworkSupportsSQLite(fw *config.Framework) bool {
if fw == nil || len(fw.Env.Services) == 0 {
if fw == nil || !fw.HasEnvConfig() {
return true
}
_, ok := fw.Env.Services["sqlite"]
return ok
return fw.Env.SQLite != nil
}

func formatDBOptionLabel(name string) string {
Expand Down
19 changes: 12 additions & 7 deletions internal/cli/project_questions.go
Original file line number Diff line number Diff line change
Expand Up @@ -454,12 +454,23 @@ func SaveProjectAnswers(cwd string, answers ProjectAnswers) error {
// carrying over the parts of an existing config the questions never ask about
// (the public dir, the app URL, extra domains, custom workers).
func projectConfigFromAnswers(cwd string, defaults *config.ProjectConfig, a ProjectAnswers, httpsAvailable bool) (*config.ProjectConfig, error) {
// An empty answer means two different things. The dashboard only renders
// the Node question for a PHP project on a machine where lerd manages Node,
// and there it offers a "Not pinned" entry whose value is exactly this empty
// string, so empty is the user clearing the pin. Everywhere else, proxy and
// container kinds included, the question was never asked and empty must not
// erase what the project pins.
nodeVersion := a.NodeVersion
if nodeVersion == "" && !(a.Kind == ProjectKindPHP && lerdManagesNode()) {
nodeVersion = defaults.NodeVersion
}

cfg := &config.ProjectConfig{
PublicDir: defaults.PublicDir,
Secured: persistedSecured(a.Secured, httpsAvailable, defaults.Secured),
AppURL: defaults.AppURL,
Domains: defaults.Domains,
NodeVersion: a.NodeVersion,
NodeVersion: nodeVersion,
CustomWorkers: defaults.CustomWorkers,
}

Expand All @@ -476,9 +487,6 @@ func projectConfigFromAnswers(cwd string, defaults *config.ProjectConfig, a Proj
cfg.Proxy.HostEnvKey = defaults.Proxy.HostEnvKey
cfg.Proxy.InjectHost = defaults.Proxy.InjectHost
}
if cfg.NodeVersion == "" {
cfg.NodeVersion = defaults.NodeVersion
}
return cfg, nil

case ProjectKindContainer:
Expand All @@ -496,9 +504,6 @@ func projectConfigFromAnswers(cwd string, defaults *config.ProjectConfig, a Proj
cfg.Container.Target = defaults.Container.Target
cfg.Container.SSL = defaults.Container.SSL
}
if cfg.NodeVersion == "" {
cfg.NodeVersion = defaults.NodeVersion
}
return cfg, nil
}

Expand Down
78 changes: 78 additions & 0 deletions internal/cli/project_questions_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -211,3 +211,81 @@ func TestSaveProjectAnswersRejectsProxyWithoutPort(t *testing.T) {
t.Fatal("a proxy answer with no port should be refused")
}
}

// A pinned Node version is the project's, not the wizard's to drop. Where lerd
// does not manage Node the question is never filled in, so the empty answer that
// comes back must not erase what .lerd.yaml already pins.
func TestProjectConfigFromAnswersKeepsAPinnedNodeVersion(t *testing.T) {
setNodeManaged(t, false)
defaults := &config.ProjectConfig{NodeVersion: "20"}

for _, a := range []ProjectAnswers{
{Kind: ProjectKindPHP, PHPVersion: "8.3"},
{Kind: ProjectKindProxy, ProxyCommand: "npm run dev", ProxyPort: 5173},
{Kind: ProjectKindContainer, ContainerPort: 8080},
} {
cfg, err := projectConfigFromAnswers(t.TempDir(), defaults, a, true)
if err != nil {
t.Fatalf("%s: %v", a.Kind, err)
}
if cfg.NodeVersion != "20" {
t.Errorf("%s: node_version = %q, want the pin kept", a.Kind, cfg.NodeVersion)
}
}
}

// setNodeManaged persists the Node-management choice the wizard reads, so a test
// can put itself on either side of what an empty answer means.
func setNodeManaged(t *testing.T, managed bool) {
t.Helper()
t.Setenv("XDG_CONFIG_HOME", t.TempDir())
t.Setenv("XDG_DATA_HOME", t.TempDir())
cfg, err := config.LoadGlobal()
if err != nil {
t.Fatal(err)
}
cfg.SetNodeManaged(managed)
if err := config.SaveGlobal(cfg); err != nil {
t.Fatal(err)
}
}

// Where lerd manages Node the wizard offers an unpinned entry whose value is the
// empty string, so an empty answer is the user clearing the pin and has to be
// saved as one. Restoring the old value would leave no way to unpin from the
// dashboard at all.
func TestProjectConfigFromAnswersHonoursClearingTheNodePin(t *testing.T) {
setNodeManaged(t, true)
defaults := &config.ProjectConfig{NodeVersion: "20"}

cfg, err := projectConfigFromAnswers(t.TempDir(), defaults, ProjectAnswers{
Kind: ProjectKindPHP, PHPVersion: "8.3", NodeVersion: "",
}, true)
if err != nil {
t.Fatal(err)
}
if cfg.NodeVersion != "" {
t.Errorf("node_version = %q, want the pin cleared", cfg.NodeVersion)
}
}

// The dashboard renders the Node question only for the PHP kind, so a proxy or
// container answer arrives empty even on a machine where lerd manages Node.
// That empty is nobody having been asked, and the pin stays.
func TestProjectConfigFromAnswersKeepsThePinWhereTheQuestionIsNotAsked(t *testing.T) {
setNodeManaged(t, true)
defaults := &config.ProjectConfig{NodeVersion: "22"}

for _, a := range []ProjectAnswers{
{Kind: ProjectKindProxy, ProxyCommand: "npm run dev", ProxyPort: 5173},
{Kind: ProjectKindContainer, ContainerPort: 8080},
} {
cfg, err := projectConfigFromAnswers(t.TempDir(), defaults, a, true)
if err != nil {
t.Fatalf("%s: %v", a.Kind, err)
}
if cfg.NodeVersion != "22" {
t.Errorf("%s: node_version = %q, want the pin kept", a.Kind, cfg.NodeVersion)
}
}
}
17 changes: 13 additions & 4 deletions internal/cli/services.go
Original file line number Diff line number Diff line change
Expand Up @@ -27,15 +27,24 @@ import (
// Backed by the preset YAMLs so adding a default preset surfaces here automatically.
func knownServices() []string { return config.DefaultPresetNames() }

// sqliteEnvVars are the Laravel-standard env values for the sqlite "service"
// (which isn't a podman container — just a per-project file). Kept hardcoded
// because there's no preset YAML to host it: sqlite has no image, no port,
// and no install flow.
// sqliteEnvVars are the keys a framework that declares no sqlite wiring of its
// own gets. They are the dotenv names Laravel and the frameworks built on it
// use, which is what every project reaching this fallback has.
var sqliteEnvVars = []string{
"DB_CONNECTION=sqlite",
"DB_DATABASE=database/database.sqlite",
}

// sqliteVarsFor returns the env values that point a project at its file
// database. Which keys those are is the definition's to say, like every other
// wiring: a Datasources driver reaches CakePHP and DB_CONNECTION does not.
func sqliteVarsFor(fw *config.Framework) []string {
if fw != nil && fw.Env.SQLite != nil && len(fw.Env.SQLite.Vars) > 0 {
return fw.Env.SQLite.Vars
}
return sqliteEnvVars
}

// serviceEnvVars returns the recommended Laravel .env KEY=VALUE pairs for a
// default-preset service or sqlite. Returns nil for any other name.
func serviceEnvVars(name string) []string {
Expand Down
Loading
Loading