Development

Add-ons

Install versioned GoLazy capabilities with reproducible source edits and typed runtime hooks.

Portrait of Petra, the GoLazy documentation agent By Petra - Published Updated

Install an add-on

Run lazy add from the application root. PostgreSQL is the first bundled add-on:

lazy add postgres

The command resolves an exact version, shows conflicts before writing, and applies one baseline-bound changeset. It adds the Go module and generated runtime bridge, creates the application migration tree, installs PostgreSQL mise tasks, and registers the local service. The application records both its direct selection and resolved lock data in addons.toml.

Add-on configuration is non-secret committed state. For example, this tells the PostgreSQL runtime which environment variable contains the connection URL:

lazy add --config database_url_variable=APP_DATABASE_URL postgres

Repeat --config key=value to merge more keys. Unmentioned keys remain in place; use --unset-config key to remove one. The generated runtime selection receives these values from addons.toml. Never put a password, token, or URL containing credentials in add-on configuration—put it in the referenced environment variable instead.

Add the durable jobs integration separately:

lazy add postgres/jobs

postgres/jobs requires postgres, so installing it into a new app resolves the base add-on first. Removing it leaves PostgreSQL installed when PostgreSQL was also selected directly.

Preview any change without writing files:

lazy add --dry-run postgres
lazy remove --dry-run postgres
lazy update --dry-run

Inspect and maintain the resolved selection with:

lazy addons
lazy update postgres
lazy remove postgres/jobs

Application state

addons.toml lives at the application root beside lazy.toml and js.toml. It combines desired state and lock data: direct and transitive selections, exact package and module versions, the manifest source and digest, and hashes for generated or rewritten files. It also records non-secret per-add-on configuration. Commit it with the source changes.

Generated files carry a GoLazy header. If a generated file no longer matches the recorded hash, update and removal stop instead of overwriting local work. Syntax-aware edits remember the property they own, so removing the final owner does not discard unrelated application configuration.

Local add-on development

An add-on package declares one lazyaddon.toml. Test it against an app without publishing a registry entry:

lazy add --manifest ../lazyseo/lazyaddon.toml [email protected]

The manifest pins its package and add-on version, Go import and module, required and conflicting add-ons, and declarative contributions. A bare same-package requirement inherits the package's exact version; cross-package requirements use [email protected]. Contributions may add generated or copied application files, JavaScript libraries and entrypoints, source edits, mise tasks, and named build phases. The installer reads those declarations and builds a lazycode changeset; it does not run an unrestricted package install hook.

Here is the portable contribution surface in one add-on entry:

[[addons]]
id = "seo"
description = "SEO controller helpers and browser metadata"
go_import = "example.com/lazyseo/addon/seo"
go_module = "example.com/lazyseo"
go_version = "v1.2.3"
requires = ["[email protected]"]

files = [
  "app/controllers/seo.go=>app/controllers/seo_addon.go",
  "app/views/seo/meta.html.tpl=>app/views/seo/meta.html.tpl",
  "tasks/generate=>.mise/tasks/seo/generate",
]
source_edits = ["edits/seo.json"]
js_libraries = ["@hotwired/[email protected]"]
js_entrypoints = ["seo=example.com/lazyseo/browser"]
tasks = ["seo:generate"]
build_hooks = ["before-build=seo:generate"]

File mappings use package-source=>application-target. Sources stay confined to the package, targets stay inside the application even through symlinked parents, and contributed Go files receive a generated header. Identical files can be shared by multiple add-ons; different content is a planning conflict.

source_edits points to package-local JSON. Schema 1 supports only explicit, versioned TOML string/string-array sets and Go imports in an existing file:

{
  "schema": 1,
  "operations": [
    {
      "kind": "toml.set_string.v1",
      "path": "settings.toml",
      "table": "seo",
      "key": "title",
      "value": "My site"
    },
    {
      "kind": "toml.set_strings.v1",
      "path": "settings.toml",
      "table": "seo",
      "key": "features",
      "values": ["canonical", "robots"]
    },
    {
      "kind": "go.ensure_import.v1",
      "path": "app/services/services.go",
      "import": "example.com/lazyseo/runtime",
      "name": "_"
    }
  ]
}

The Go import name must be an explicit Go identifier or _; a named import is not removed while application code still uses its selector. Descriptor bytes are part of the immutable release digest. Each property records its owners and prior value, so identical edits can be shared and final removal restores the app value. Drift or incompatible edits stop the plan. New JSON, JavaScript, or richer Go operations require new schema operation versions rather than opaque installer code.

Controller methods that should feel native are contributed as generated files inside the application's controller package, for example app/controllers/seo_addon.go. Go cannot add a method to an application type from the add-on package itself. Do not use _seo.go: Go ignores source files whose names begin with _ or ..

Build hooks are mise task references, using before-build=<task> or after-build=<task>. The task must be listed by the same add-on and mapped from a packaged source to an installer-owned, inspectable .mise/tasks file. A pre-existing application task cannot be adopted as a hook. Development and native builds use one lock snapshot and run dependency hooks before dependent add-ons; a failing task fails the build.

tasks = ["seo:generate"]
build_hooks = ["before-build=seo:generate"]
files = ["tasks/generate=>.mise/tasks/seo/generate"]

The generated runtime selection carries every exact locked version, so startup rejects a different package version even if Go MVS selected a newer module. The installed Go package registers runtime behavior with typed golazy.dev/lazyaddon hooks. lazyapp currently exposes phases for layered views and public files, dependencies, migrations, jobs, routes, template helpers, and the development control plane. Dependencies run before the add-on that requires them. Values shared between add-ons use versioned typed capabilities rather than process globals.

Files and migrations

Add-on views, public files, and migrations use golazy.dev/lazyfs. Layers are deterministic: framework defaults are lowest, add-ons are in resolved order, and application files win. Development keeps the application layer on disk so live edits are not hidden by embedded files.

Migration add-ons mount filesystems into a lazymigrate.Catalog under stable namespaces. The base postgres add-on provides the pool and migration backend; postgres/jobs mounts only the jobs migrations and supplies the PostgreSQL jobs backend. App-owned PostgreSQL migrations remain under db/postgres/migrations.

Development panels

An add-on may register a development-only control-plane endpoint and a panel descriptor that points to it. The development panel discovers descriptors from the running application and exposes them under its Add-ons tab. Only exact, same-owner, GET-capable paths are accepted; the CLI proxy connects only to the current loopback app, caps response time and size, strips upstream headers, and renders add-on HTML in a sandboxed frame.

Panels can expose functionality with named PanelAction entries. Every action must reference a separate exact, same-owner, POST-only endpoint. The trusted outer panel renders its button, checks a process-random CSRF token and browser origin, re-resolves the panel/action IDs from discovery, and sends an empty loopback POST. It never forwards a browser URL, query, cookies, headers, or body, while add-on HTML remains script- and form-disabled. The bundled PostgreSQL panel uses this contract for Ping database.

Production builds do not include _lazydev.go registrations. A future paid registry can add entitlement checks, signed manifests, and verified artifact delivery at the source boundary without changing the application runtime or allowing registry code to execute in the installer. The installer binds every delivered artifact to its declared path and mode; the registry source is responsible for publisher authentication, entitlement, signature checks, and artifact-digest verification before resolution.

See also