231 lines
6.6 KiB
Go
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}
|
|
}
|