Runtime commands
Generated projects wire steward.CLI in main.go, which turns your app
binary into its own operations tool — migrations are Go code compiled into
the app, so runtime operations run in the app, not in the global
steward generator.
steward.CLI(steward.App{
Build: app.Build, // constructs the configured *steward.Panel
Migrations: migrations.All, // the app's own migrations
Jobs: registerJobs, // optional: recurring jobs (worker only)
})Commands
| Command | Does |
|---|---|
serve [-addr :8080] | start the panel (default command) |
worker | run App.Jobs on the scheduler — no HTTP |
migrate up | apply pending migrations |
migrate down [-steps N] [-force] | roll back (default: the last batch) |
migrate status | list every migration with batch and timestamp |
menu:sync | re-sync menu entries from registered resources |
admin:create-user -username u [-password p] | create a panel account |
help | list the commands — answered without touching the database |
An unknown command or flag is refused before the panel is built, so a typo is reported as a typo rather than as whatever the database had to say about being unreachable. The message names the nearest command that does exist.
Scripts and CI
admin:create-user prompts for a password when -password is absent. It only
prompts when stdin is a terminal: in a pipe or a CI job it refuses and names
the flag, rather than blocking forever or reading the next line of the script
as the password.
Every failure exits 1, including a bad flag — nothing leaves through
flag’s own exit code. -yes is accepted wherever a confirmation flag is.
Rolling back
migrate down reverses the last batch — every migration applied by the same
migrate up. On a database migrated from nothing that batch is all of them,
the panel’s own tables included, so the command refuses:
migrate down would roll back all 7 applied migrations, including the panel's
own tables: they were applied as one batch, so there is no earlier state to
return to.
Roll back fewer with -steps N, or say -force if emptying the database is what
you want-steps N rolls back a definite number and is never refused; -force (or
-yes) says the refusal was wrong. The guard only ever stops the case where “undo the last
change” and “empty the database” are the same command.
Stopping a panel
A panel built with the queued-export worker on holds one goroutine polling for
work. Close ends it and waits for an export already running to finish:
app, err := steward.New(cfg)
// ...
defer func() { _ = app.Close() }()It deliberately leaves Config.DB alone — you opened that connection and you
own it — so close the panel first and the database after. It is safe to call
more than once, and on a panel that was never built.
Long-lived processes rarely need it: a panel that runs until the process exits has nothing to tidy. Tests do. Without it every panel a suite builds keeps polling a database the test has already closed, which is a leaked goroutine per test and a log full of errors nobody can act on.
srv := httptest.NewServer(app)
t.Cleanup(func() {
srv.Close()
_ = app.Close()
})The worker process
Background jobs deliberately do not run inside serve: if they did,
every extra web replica would fire every cron job again, and panel deploys
would kill mid-flight work. Instead, register jobs on App.Jobs and run
the same binary twice:
Jobs: func(a *steward.Panel, s steward.Scheduler) error {
err := s.Add("@every 10m", "cleanup-sessions", func(ctx context.Context) error {
return a.DB().WithContext(ctx).Exec("DELETE FROM ...").Error
})
if err != nil {
return err
}
return s.Add("0 3 * * *", "nightly-report", sendReport)
},./app serve # deployment 1: the panel, scale as you like
./app worker # deployment 2: exactly one instance runs the jobsOne build artifact, two independently deployable processes. The worker
stops gracefully on SIGINT/SIGTERM.
Schedule specs: @every <duration> (any time.ParseDuration string),
@hourly, @daily, @weekly, or a five-field cron expression
(30 2 * * 1-5, minute resolution).
Note
Run a single worker instance unless your jobs are idempotent — there is no distributed locking between workers.
Deployment sketch
FROM golang:1.26 AS build
WORKDIR /src
COPY . .
RUN CGO_ENABLED=0 go build -o /app .
FROM gcr.io/distroless/static
COPY --from=build /app /app
ENTRYPOINT ["/app"]
CMD ["serve"] # worker deployment overrides: CMD ["worker"]Set STEWARD_SECRET (scaffolded apps read it for the session key) and your
database DSN via the environment; run /app migrate up as a release step.
Verify
Build() fails on what makes a panel unservable. Verify() runs Build() and
then reports everything else it noticed — the mistakes that otherwise show up as
something quietly missing rather than as an error:
- a field, column, filter, or detail path that is not on the model
- an icon name that does not resolve
- a validation rule the engine does not know. This one matters most: the
rule engine skips a name it does not recognise, so
Rules("requried|max:255")silently drops the required check and the field looks validated - a disk a field names that was never configured, which would otherwise send the upload to the default disk
- a badge colour outside the palette, which
otherwise renders as
secondary - a dashboard with a chart widget when Chart.js is not in the asset layers. It ships with the module, so this means something is shadowing it
- a
RelationGridtarget that is not a registered resource
It then runs the statements those declarations build — reading the table by
column name, each quick search and command search, each filter, each sortable
column — every one bounded with LIMIT 0. The database parses, analyses and
plans the query, which is where these failures are raised, and returns without
reading a row.
That is the half no amount of reading the Go type can reach:
- a column the model declares and no migration ever added.
SELECT *hides it — the rows come back and the field stays at its zero value, so the panel shows an empty column forever. Asked for by name, the database says it is not there - a predicate the dialect refuses for that column’s type. This is how quick
search on a
uuidcolumn brought down every search on PostgreSQL, fixed in v0.1.1 and shipped in the first place because nothing ran the query
A table that does not exist yet is a migration that has not run, not a
declaration that is wrong, and is passed over in silence. Set
DisableQueryProbe to skip the probe entirely.
What a report reads like
Each one names the nearest valid value, the set to choose from, and the line it was declared on — the line that has to change:
posts: grid column: unknown field "Titel" (admin/posts.go:24)
did you mean: Title
available: AuthorID, Body, Cover, CreatedAt, ID, Keywords, Status, TitleThe candidate list is sorted and capped at fifteen with a count of the rest, so an unknown icon answers with the fifteen nearest names rather than all 1,637. The same set always reads the same way, which is what makes two runs comparable.
None of these stop the panel from serving, which is exactly why they are worth asserting:
func TestAdminVerifies(t *testing.T) {
app, err := buildAdmin()
if err != nil {
t.Fatal(err)
}
if err := app.Verify(); err != nil {
t.Fatal(err)
}
}One test, and a typo fails in CI instead of turning up as a blank icon, an uncoloured badge, or a field nobody validated.