go-app(docs) gomatic manual

Name

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

Install

sh
go get github.com/gomatic/go-app

Why 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:

go
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:

go
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* namesPGHOST, 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:

go
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:

go
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] and Default are 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. Run takes the args slice and the exit function as parameters, and the logger source is injected through LoggerBefore — so a main is exercised end to end in tests.
  • Signal-aware. Run derives a signal.NotifyContext for SIGINT/SIGTERM, logs any non-nil error under the command’s name, and exits non-zero via the injected exit.
  • Marked as a library. A library.go build-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.