Files
go/lifecycle/cli/cli.go
T
end fe44cac3a2
Documentation / docs (push) Successful in 16s
ci / check (push) Successful in 27s
feat(lifecycle/cli): run native commands with selected modules (#16)
Add native urfave/cli command adapters with explicit module selection,
signal-aware execution, reverse cleanup and documented exit-code mapping.
Include focused tests, an executable example and the accepted CLI ADR.

Closes #8
2026-10-28 14:46:11 -07:00

231 lines
6.6 KiB
Go

// Package cli runs caller-owned urfave/cli trees inside selected lifecycle modules.
// It owns temporary signal subscriptions and exit classification, not process exit,
// configuration, resource handles or unsupervised goroutines.
package cli
import (
"context"
"errors"
"fmt"
"os"
"os/signal"
"slices"
"syscall"
urfavecli "github.com/urfave/cli/v3"
"go.auvem.com/corestack/lifecycle"
)
// Run executes cliRoot with args including argv[0], returning 0 for success/help,
// 2 for usage, and 1 for runtime failures. Lifecycle failures always take priority.
// It prints a final diagnostic to cliRoot.ErrWriter and never exits the process.
// Run reserves ExitErrHandler, OnUsageError and CommandNotFound throughout the
// declared tree. Construct fresh trees for independent runs; do not mutate them
// concurrently. Native generated-help parse and environment validation failures
// may return 1 and print native diagnostics in addition to the final diagnostic.
func Run(ctx context.Context, cliRoot *urfavecli.Command, args []string) (int, error) {
var err error
if ctx == nil || cliRoot == nil {
err = errors.New("cli: context and root command are required")
} else {
err = validateTree(cliRoot)
}
if err == nil {
var missing error
installHooks(cliRoot, &missing)
err = cliRoot.Run(ctx, args)
if missing != nil {
if err == nil {
err = missing
} else {
err = errors.Join(err, missing)
}
}
}
if err == nil {
return 0, nil
}
code := classify(err, 0)
err = expose(err)
if cliRoot != nil && cliRoot.ErrWriter != nil {
fmt.Fprintln(cliRoot.ErrWriter, err)
} else {
fmt.Fprintln(os.Stderr, err)
}
return code, err
}
func validateTree(cmd *urfavecli.Command) error {
if cmd == nil {
return errors.New("cli: nil command")
}
if cmd.ExitErrHandler != nil || cmd.OnUsageError != nil || cmd.CommandNotFound != nil {
return fmt.Errorf("cli: command %q sets a reserved error hook", cmd.Name)
}
for _, child := range cmd.Commands {
if err := validateTree(child); err != nil {
return err
}
}
return nil
}
func installHooks(cmd *urfavecli.Command, missing *error) {
cmd.ExitErrHandler = func(context.Context, *urfavecli.Command, error) {}
cmd.OnUsageError = func(_ context.Context, selected *urfavecli.Command, err error, _ bool) error {
_ = urfavecli.ShowSubcommandHelp(selected)
return &failure{err, 2}
}
cmd.CommandNotFound = func(_ context.Context, selected *urfavecli.Command, name string) {
*missing = errors.Join(*missing, &failure{fmt.Errorf("unknown command or help topic %q", name), 2})
_ = urfavecli.ShowSubcommandHelp(selected)
}
for _, child := range cmd.Commands {
installHooks(child, missing)
}
}
// Serve returns a native action running exactly modules, including their services,
// in registration order with reverse cleanup. Bindings capture declarations without
// starting work; resources and declarations must not be mutated concurrently.
// Nil config.Signals subscribes to SIGINT/SIGTERM for the invocation, buffered for
// two signals; an injected channel and its subscription remain caller-owned.
// The private invocation App is unavailable for managed-worker admission.
func Serve(config lifecycle.Config, modules []lifecycle.Module) urfavecli.ActionFunc {
return bind(config, modules, nil, false)
}
// Action returns a native one-off action using exactly modules with their Run
// callbacks suppressed. All setups precede fn; completion triggers reverse cleanup.
// fn receives lifecycle's signal-aware context and the selected native command.
// Nil fn is a runtime configuration error. Signal and ownership rules match Serve;
// hooks outside this action are outside the lifecycle shutdown guarantee.
func Action(config lifecycle.Config, modules []lifecycle.Module, fn urfavecli.ActionFunc) urfavecli.ActionFunc {
return bind(config, modules, fn, true)
}
func bind(config lifecycle.Config, modules []lifecycle.Module, fn urfavecli.ActionFunc, oneOff bool) urfavecli.ActionFunc {
modules = slices.Clone(modules)
return func(ctx context.Context, cmd *urfavecli.Command) error {
err := execute(ctx, cmd, config, modules, fn, oneOff)
if err != nil {
return &failure{err, 1}
}
return nil
}
}
func execute(ctx context.Context, cmd *urfavecli.Command, config lifecycle.Config, modules []lifecycle.Module, fn urfavecli.ActionFunc, oneOff bool) error {
if oneOff && fn == nil {
return errors.New("cli: one-off function is required")
}
var signals chan os.Signal
if config.Signals == nil {
signals = make(chan os.Signal, 2)
config.Signals = signals
}
app, err := lifecycle.New(config)
if err != nil {
return err
}
names := make(map[string]bool, len(modules))
for _, module := range modules {
names[module.Name] = true
if oneOff {
module.Run = nil
}
if err := app.Register(module); err != nil {
return err
}
}
if oneOff {
name := "cli-action"
for names[name] {
name += "-"
}
noop := func(context.Context) error { return nil }
if err := app.Register(lifecycle.Module{Name: name, Setup: noop, Teardown: noop,
Run: func(ctx context.Context) error { return fn(ctx, cmd) }}); err != nil {
return err
}
}
if signals != nil {
signal.Notify(signals, syscall.SIGINT, syscall.SIGTERM)
defer signal.Stop(signals)
}
return app.Run(ctx)
}
type failure struct {
error
code int
}
func (e *failure) Unwrap() error { return e.error }
func children(err error) []error {
switch e := err.(type) {
case interface{ Unwrap() []error }:
return e.Unwrap()
case interface{ Unwrap() error }:
if child := e.Unwrap(); child != nil {
return []error{child}
}
case urfavecli.MultiError:
return e.Errors()
}
return nil
}
// invariant: runtime branches dominate usage; aggregate containers add no failure.
func classify(err error, inherited int) int {
if e, ok := err.(*failure); ok {
if e.code == 1 {
return 1
}
inherited = e.code
} else if e, ok := err.(urfavecli.ExitCoder); ok && inherited == 0 {
inherited = 1
if e.ExitCode() == 2 {
inherited = 2
}
}
code := 0
for _, child := range children(err) {
if child == nil {
continue
}
next := classify(child, inherited)
if next == 1 {
return 1
}
code = next
}
if code != 0 {
return code
}
if inherited != 0 {
return inherited
}
return 1
}
// exposed retains native identity and formatting while making opaque MultiError
// descendants reachable through standard errors.Is and errors.As traversal.
type exposed struct {
error
causes []error
}
func (e *exposed) Unwrap() []error { return e.causes }
func expose(err error) error {
causes := []error{err}
for _, child := range children(err) {
if child != nil {
causes = append(causes, expose(child))
}
}
return &exposed{err, causes}
}