Name
go-app — Public documentation for gomatic/go-app — the urfave/cli/v3 application framework and run harness shared by gomatic CLIs.
go-app is the gomatic ecosystem’s urfave/cli/v3 application framework and run harness, shared by every gomatic CLI. It supplies the generic, command-agnostic glue — the Runner/Default action combinators, the logger-in-metadata convention, the standard global flags, the standard libpq PG* flag set in flags/pg, and the signal-aware Run harness — and nothing tied to any one command. It composes go-log and go-output on top of urfave/cli/v3; command-specific flags, errors, and command trees stay in their own repositories.
- Source:
gomatic/go-app - API reference: pkg.go.dev/github.com/gomatic/go-app
Install
go get github.com/gomatic/go-appWhy a shared framework
Every gomatic CLI repeats the same plumbing: parse global logging flags, build a logger, stash it where actions can reach it, decode a result to JSON or YAML, and run the root command under signal-aware cancellation. go-app owns that plumbing once so each command repository carries only its own flags, config, and work. A command becomes a small Runner — a typed function over a config and positional arguments — wired into a *cli.Command with the standard flags and hooks.
The library owns the mechanism only: it ships no command definitions, no command-specific flags, and no error values. Each consumer declares those in its own package and composes them with the combinators here.
Usage
Define a command
Write the command’s work as a Runner, bind it with Default, and run the root command through Run for SIGINT/SIGTERM-aware cancellation:
package main
import (
"context"
"log/slog"
"os"
app "github.com/gomatic/go-app"
"github.com/gomatic/go-log"
"github.com/urfave/cli/v3"
)
type config struct{}
type result struct {
Message string `json:"message" yaml:"message"`
}
// greet is the command's work: it receives the bound config and positional
// arguments and returns a result the action combinator encodes.
func greet(_ context.Context, logger *slog.Logger, _ config, args ...string) (result, error) {
logger.Info("greeting", "args", args)
return result{Message: "hello"}, nil
}
func main() {
var cfg config
var logCfg log.LoggerConfig
logFlags := app.LoggerFlags{Config: &logCfg, EnvPrefix: "GREETER_"}
cmd := &cli.Command{
Name: "greeter",
Metadata: map[string]any{},
Flags: []cli.Flag{
logFlags.LevelFlag(),
logFlags.FormatFlag(log.FormatText),
app.OutputFlag("GREETER_"),
},
Before: app.LoggerBefore(func(*cli.Command) *slog.Logger {
return logCfg.NewLogger(os.Stderr)
}),
Action: app.Default(&cfg, greet),
}
app.Run(context.Background(), cmd, os.Args, os.Exit)
}Each global flag resolves from its --flag, the matching GREETER_* environment variable, or its default. OutputFlag selects the result encoding (json or yaml), which the Default action applies when writing the runner’s result to the root command’s writer.
The standard global flags
The logging flags bind through LoggerFlags — a binder: the destination log.LoggerConfig pointer and the EnvPrefix travel as fields, so the flag constructors take no pointer parameters while each flag’s Destination still writes into the one shared config. OutputFlag remains a plain constructor:
logFlags := app.LoggerFlags{Config: &logCfg, EnvPrefix: "GREETER_"}
logFlags.LevelFlag() // --log-level / GREETER_LOG_LEVEL (default "info")
logFlags.FormatFlag(log.FormatText) // --log-format / GREETER_LOG_FORMAT
app.OutputFlag("GREETER_") // --output, -o / GREETER_OUTPUT (default "json")LevelFlag and FormatFlag write into the config’s Level and Format fields — since v0.5.1, go-app tracks the go-log v0.3 LoggerConfig field names (Level/Format). The def argument to FormatFlag lets a CLI default to text while a daemon defaults to json.
The libpq PG* flags (flags/pg)
The flags/pg subpackage (package pg, added in v0.5.0) supplies the standard libpq-compatible PG* flag set and the connection Config it binds. pg.Binder follows the same binder pattern as app.LoggerFlags: the Config pointer lives in a field, so the binder travels by value while its flags write into the one Config instance the command’s action reads.
The flags’ environment sources are the canonical bare PG* names — PGHOST, PGPORT, PGDATABASE, PGUSER, PGPASSWORD, PGSSLMODE — never app-prefixed, so the flags interoperate with psql, pgx, and every other libpq tool. The flag names are exported as constants (HostFlag through SSLModeFlag) for reuse in tests and help text. Config’s fields (Host, Name, User, Password, SSLMode, Port) are exported because the CLI binds them by pointer, but their types are unexported named domain types.
Config.ConnString builds a libpq keyword/value connection string from the explicit flag values, omitting empty fields so the driver falls back to the PG* environment variables (and ~/.pgpass / ~/.pg_service.conf). Opening a pool stays with the consumer:
package main
import (
"context"
"github.com/gomatic/go-app/flags/pg"
"github.com/jackc/pgx/v5/pgxpool"
"github.com/urfave/cli/v3"
)
// config embeds the connection Config beside the command's own settings.
type config struct {
PG pg.Config
}
// flags appends the PG* flag set to the command's own flags.
func flags(cfg *config) []cli.Flag {
return append(
[]cli.Flag{ /* the command's own flags */ },
pg.Binder{Config: &cfg.PG}.Flags()...,
)
}
// connect opens the pool from the bound config; empty fields defer to the
// PG* environment (and ~/.pgpass / ~/.pg_service.conf).
func connect(ctx context.Context, cfg config) (*pgxpool.Pool, error) {
return pgxpool.New(ctx, cfg.PG.ConnString())
}The logger-in-metadata convention
The logger built in the Before hook is stored in the root command’s Metadata under LoggerMetadataKey and retrieved by every action through GetLogger:
Before: app.LoggerBefore(func(c *cli.Command) *slog.Logger {
return logCfg.NewLogger(os.Stderr)
}),LoggerBefore wraps a GetLoggerFunc into a cli Before hook; the Default action then passes the stored logger to the runner. GetLogger falls back to slog.Default when no logger is present or the command is nil, so a runner always receives a usable logger.
Design
- Command-agnostic. The package ships no command definitions, command-specific flags, or error values — only the combinators, flags, and harness every CLI reuses. Each consumer owns its own command tree.
- Generic combinators.
Runner[CONFIG, RESULT]andDefaultare parameterized over a command’s config and result types, so a command’s work stays a plain typed function with no cli coupling. - Injected seams.
Runtakes the args slice and theexitfunction as parameters, and the logger source is injected throughLoggerBefore— so amainis exercised end to end in tests. - Signal-aware.
Runderives asignal.NotifyContextfor SIGINT/SIGTERM, logs any non-nil error under the command’s name, and exits non-zero via the injectedexit. - Marked as a library. A
library.gobuild-tagged marker (never compiled) flags the repo as a library rather than a CLI for gomatic tooling and conventions.
Who uses it
Every gomatic CLI builds on go-app, composing it with go-log and go-output: renderizer, template.cli, and the other gomatic/go-* libraries.