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
This commit was merged in pull request #16.
This commit is contained in:
1 parent
f0439000cb
commit
fe44cac3a2
7 files changed
+837
No files matched your search
@@ -0,0 +1,230 @@
|
||||
// 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}
|
||||
}
|
||||
Reference in new issue
Block a user