// 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} }