feat(lifecycle/cli): run native commands with selected modules (#16)
Documentation / docs (push) Successful in 16s
ci / check (push) Successful in 27s

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:
end committed 2026-10-28 14:46:11 -07:00
1 parent f0439000cb
commit fe44cac3a2
7 files changed
+837

No files matched your search

+230
View File
@@ -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}
}
+428
View File
@@ -0,0 +1,428 @@
package cli
import (
"bytes"
"context"
"errors"
"fmt"
"io"
"log/slog"
"os"
"reflect"
"strings"
"syscall"
"testing"
"time"
urfavecli "github.com/urfave/cli/v3"
"go.auvem.com/corestack/lifecycle"
)
func config() lifecycle.Config {
return lifecycle.Config{Logger: slog.New(slog.NewTextHandler(io.Discard, nil)), Signals: make(chan os.Signal, 2), ShutdownTimeout: time.Second}
}
func command(action urfavecli.ActionFunc) *urfavecli.Command {
return &urfavecli.Command{Name: "app", Writer: io.Discard, ErrWriter: io.Discard, Commands: []*urfavecli.Command{{Name: "inspect", Action: action, Flags: []urfavecli.Flag{&urfavecli.BoolFlag{Name: "verbose"}}}}}
}
func TestNativeLifecycleFlow(t *testing.T) {
for _, fail := range []bool{false, true} {
t.Run(fmt.Sprint(fail), func(t *testing.T) {
var order []string
boom := &os.PathError{Op: "inspect", Path: "db", Err: io.ErrUnexpectedEOF}
var selected []lifecycle.Module
for _, name := range []string{"cli-action", "cli-action-", "database"} {
selected = append(selected, lifecycle.Module{Name: name,
Setup: func(context.Context) error { order = append(order, "setup "+name); return nil },
Run: func(context.Context) error { t.Error("one-off service started"); return nil },
Teardown: func(context.Context) error { order = append(order, "close "+name); return nil }})
}
cliRoot := command(Action(config(), selected, func(_ context.Context, cmd *urfavecli.Command) error {
if !cmd.Bool("verbose") {
t.Error("native flag missing")
}
order = append(order, "inspect")
if fail {
return boom
}
return nil
}))
cliRoot.Commands = append(cliRoot.Commands, &urfavecli.Command{Name: "unused", Action: Serve(config(), []lifecycle.Module{{Name: "unused", Setup: func(context.Context) error { t.Error("unselected setup"); return nil }, Teardown: func(context.Context) error { return nil }}})})
code, err := Run(context.Background(), cliRoot, []string{"app", "inspect", "--verbose"})
if fail && (code != 1 || !errors.Is(err, boom)) || !fail && (code != 0 || err != nil) {
t.Fatalf("Run = %d, %v", code, err)
}
want := []string{"setup cli-action", "setup cli-action-", "setup database", "inspect", "close database", "close cli-action-", "close cli-action"}
if !reflect.DeepEqual(order, want) {
t.Fatalf("order %v", order)
}
})
}
}
func TestNativeUsageAndHelp(t *testing.T) {
for _, tc := range []struct {
args string
code int
}{
{"", 0}, {"--help", 0}, {"inspect --help", 0}, {"help inspect", 0}, {"group", 0}, {"group --help", 0},
{"missing", 2}, {"help missing", 2}, {"group missing", 2}, {"group help missing", 2},
{"--bad", 2}, {"inspect --bad", 2}, {"inspect --count", 2}, {"inspect --count nope", 2}, {"inspect", 2},
{"help --bad", 1}, {"h --bad", 1}, {"group help --bad", 1}, {"group h --bad", 1},
} {
t.Run(tc.args, func(t *testing.T) {
cliRoot := command(Action(config(), []lifecycle.Module{{Name: "db", Setup: func(context.Context) error { t.Error("setup during usage/help"); return nil }, Teardown: func(context.Context) error { return nil }}}, func(context.Context, *urfavecli.Command) error { t.Error("action during usage/help"); return nil }))
cliRoot.Commands[0].Flags = append(cliRoot.Commands[0].Flags, &urfavecli.IntFlag{Name: "count", Required: true})
cliRoot.Commands = append(cliRoot.Commands, &urfavecli.Command{Name: "group", Commands: []*urfavecli.Command{{Name: "child"}}})
var output, diagnostic bytes.Buffer
cliRoot.Writer = &output
cliRoot.ErrWriter = &diagnostic
code, err := Run(context.Background(), cliRoot, append([]string{"app"}, strings.Fields(tc.args)...))
if code != tc.code || (err != nil) != (tc.code != 0) {
t.Fatalf("Run=%d,%v; want %d", code, err, tc.code)
}
if output.Len() == 0 && tc.code != 1 {
t.Error("missing native help")
}
if (diagnostic.Len() > 0) != (tc.code != 0) {
t.Fatalf("diagnostic %q", diagnostic.String())
}
})
}
}
func TestNativeValidation(t *testing.T) {
for _, kind := range []string{"environment", "validator", "validator-exit", "flag-action", "required-argument", "argument-parse", "exclusive"} {
t.Run(kind, func(t *testing.T) {
cliRoot := command(func(context.Context, *urfavecli.Command) error {
t.Error("invalid invocation reached action")
return nil
})
cmd := cliRoot.Commands[0]
args := []string{"app", "inspect"}
want := 1
switch kind {
case "environment":
t.Setenv("CORESTACK_CLI_COUNT", "oops")
cmd.Flags = []urfavecli.Flag{&urfavecli.IntFlag{Name: "count", Sources: urfavecli.EnvVars("CORESTACK_CLI_COUNT")}}
case "validator", "validator-exit":
cmd.ArgValidator = func(context.Context, *urfavecli.Command) error {
if kind == "validator-exit" {
return urfavecli.Exit("invalid", 2)
}
return errors.New("invalid")
}
if kind == "validator-exit" {
want = 2
}
case "flag-action":
cmd.Flags = []urfavecli.Flag{&urfavecli.BoolFlag{Name: "bad", Action: func(context.Context, *urfavecli.Command, bool) error { return errors.New("invalid") }}}
args = append(args, "--bad")
case "required-argument":
cmd.Arguments = []urfavecli.Argument{&urfavecli.StringArg{Name: "target", Required: true}}
want = 2
case "argument-parse":
cmd.Arguments = []urfavecli.Argument{&urfavecli.IntArg{Name: "count"}}
args = append(args, "oops")
want = 2
case "exclusive":
cmd.MutuallyExclusiveFlags = []urfavecli.MutuallyExclusiveFlags{{Flags: [][]urfavecli.Flag{{&urfavecli.BoolFlag{Name: "a"}}, {&urfavecli.BoolFlag{Name: "b"}}}}}
args = append(args, "--a", "--b")
want = 2
}
code, err := Run(context.Background(), cliRoot, args)
if code != want || err == nil {
t.Fatalf("Run=%d,%v; want %d", code, err, want)
}
})
}
}
func TestFailureCategoriesAndCauses(t *testing.T) {
for _, kind := range []string{"native-exit2", "native-after", "native-exit0", "native-exit9", "action-exit2", "teardown", "setup", "action-after", "usage-after", "nested-after", "unknown-after"} {
t.Run(kind, func(t *testing.T) {
cause := &os.PathError{Op: "read", Path: "db", Err: io.ErrUnexpectedEOF}
after := errors.New("after")
parent := errors.New("parent after")
original := urfavecli.Exit(cause, 2)
cfg := config()
modules := []lifecycle.Module{{Name: "db", Setup: func(context.Context) error { return nil }, Teardown: func(context.Context) error { return nil }}}
action := func(context.Context, *urfavecli.Command) error { return original }
want := 1
args := []string{"app", "inspect"}
switch kind {
case "native-exit2":
want = 2
case "native-exit0":
original = urfavecli.Exit(cause, 0)
case "native-exit9":
original = urfavecli.Exit(cause, 9)
case "setup":
modules[0].Setup = func(context.Context) error { return cause }
case "teardown":
modules[0].Teardown = func(context.Context) error { return after }
}
cliRoot := command(action)
if !strings.HasPrefix(kind, "native-") {
cliRoot.Commands[0].Action = Action(cfg, modules, action)
}
if kind == "native-after" || kind == "action-after" || kind == "nested-after" || kind == "usage-after" {
cliRoot.Commands[0].After = func(context.Context, *urfavecli.Command) error { return after }
}
if kind == "nested-after" {
cliRoot.After = func(context.Context, *urfavecli.Command) error { return parent }
}
if kind == "usage-after" {
cliRoot.Commands[0].Flags = append(cliRoot.Commands[0].Flags, &urfavecli.StringFlag{Name: "needed", Required: true})
}
if kind == "unknown-after" {
args = []string{"app", "help", "missing"}
cliRoot.After = func(context.Context, *urfavecli.Command) error { return after }
}
code, err := Run(context.Background(), cliRoot, args)
if code != want || err == nil {
t.Fatalf("Run=%d,%v", code, err)
}
if kind != "usage-after" && kind != "unknown-after" {
var path *os.PathError
if !errors.Is(err, cause) || !errors.As(err, &path) || path != cause || !errors.Is(err, io.ErrUnexpectedEOF) {
t.Fatalf("cause lost: %v", err)
}
if kind != "setup" && !errors.Is(err, original) {
t.Fatalf("original identity lost: %v", err)
}
}
if kind == "teardown" || strings.HasSuffix(kind, "-after") {
if !errors.Is(err, after) {
t.Fatalf("After/cleanup lost: %v", err)
}
}
if kind == "nested-after" && !errors.Is(err, parent) {
t.Fatal("parent cause lost")
}
if kind == "native-after" || kind == "action-after" || kind == "nested-after" || kind == "usage-after" {
var multi urfavecli.MultiError
if !errors.As(err, &multi) || !errors.Is(err, multi) {
t.Fatalf("native aggregate lost: %v", err)
}
if err.Error() != multi.Error() {
t.Fatalf("duplicated formatting: %v", err)
}
}
})
}
}
func TestValidationBeforeSetup(t *testing.T) {
for _, kind := range []string{"nil-context", "nil-root", "nil-function", "nil-logger", "negative-timeout", "invalid-module", "duplicate-module", "nil-child", "exit-hook", "usage-hook", "not-found-hook"} {
t.Run(kind, func(t *testing.T) {
cfg := config()
noop := func(context.Context) error { return nil }
modules := []lifecycle.Module{{Name: "db", Setup: func(context.Context) error { t.Error("setup before validation"); return nil }, Teardown: noop}}
var ctx context.Context = context.Background()
fn := urfavecli.ActionFunc(func(context.Context, *urfavecli.Command) error { t.Error("action before validation"); return nil })
switch kind {
case "nil-context":
ctx = nil
case "nil-function":
fn = nil
case "nil-logger":
cfg.Logger = nil
case "negative-timeout":
cfg.ShutdownTimeout = -1
case "invalid-module":
modules = append(modules, lifecycle.Module{Name: "invalid"})
case "duplicate-module":
modules = append(modules, modules[0])
}
cliRoot := command(Action(cfg, modules, fn))
switch kind {
case "nil-root":
cliRoot = nil
case "nil-child":
cliRoot.Commands = append(cliRoot.Commands, nil)
case "exit-hook":
cliRoot.Commands[0].ExitErrHandler = func(context.Context, *urfavecli.Command, error) {}
case "usage-hook":
cliRoot.Commands[0].OnUsageError = func(context.Context, *urfavecli.Command, error, bool) error { return nil }
case "not-found-hook":
cliRoot.Commands[0].CommandNotFound = func(context.Context, *urfavecli.Command, string) {}
}
code, err := Run(ctx, cliRoot, []string{"app", "inspect"})
if code != 1 || err == nil {
t.Fatalf("Run=%d,%v", code, err)
}
})
}
}
func await[T any](t *testing.T, ch <-chan T) T {
t.Helper()
select {
case value := <-ch:
return value
case <-time.After(3 * time.Second):
t.Fatal("callback blocked")
var zero T
return zero
}
}
func TestSignalsAndParent(t *testing.T) {
for _, mode := range []string{"first", "second", "parent", "serve"} {
t.Run(mode, func(t *testing.T) {
signals := make(chan os.Signal, 2)
cfg := config()
cfg.Signals = signals
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
ready := make(chan struct{})
canceled := make(chan struct{})
exited := make(chan struct{})
cleanup := make(chan struct{})
release := make(chan struct{})
defer func() { close(release); await(t, exited) }()
callback := func(ctx context.Context) error {
close(ready)
defer close(exited)
<-ctx.Done()
close(canceled)
if mode == "second" {
<-release
}
return ctx.Err()
}
modules := []lifecycle.Module{{Name: "db", Setup: func(context.Context) error { return nil }, Teardown: func(context.Context) error { close(cleanup); return nil }, Run: callback}}
action := Action(cfg, modules, func(ctx context.Context, _ *urfavecli.Command) error { return callback(ctx) })
if mode == "serve" {
action = Serve(cfg, modules)
}
done := make(chan struct {
code int
err error
}, 1)
go func() {
code, err := Run(ctx, command(action), []string{"app", "inspect"})
done <- struct {
code int
err error
}{code, err}
}()
await(t, ready)
if mode == "parent" {
cancel()
} else {
signals <- syscall.SIGINT
}
await(t, canceled)
await(t, cleanup)
if mode == "second" {
signals <- syscall.SIGTERM
}
result := await(t, done)
want := 0
if mode == "second" || mode == "parent" {
want = 1
}
if result.code != want || (result.err != nil) != (want != 0) {
t.Fatalf("Run=%d,%v", result.code, result.err)
}
if want == 1 && !errors.Is(result.err, context.Canceled) {
t.Fatalf("cancellation lost: %v", result.err)
}
// The adapter must leave the injected channel open and reusable.
signals <- syscall.SIGTERM
})
}
}
func TestSharedShutdownDeadline(t *testing.T) {
cfg := config()
cfg.ShutdownTimeout = 100 * time.Millisecond
ready := make(chan struct{})
shutdown := make(chan time.Time, 1)
exited := make(chan struct{})
release := make(chan struct{})
defer func() { close(release); await(t, exited) }()
var first time.Time
noop := func(context.Context) error { return nil }
modules := []lifecycle.Module{
{Name: "blocked", Setup: noop, Teardown: func(ctx context.Context) error {
defer close(exited)
deadline, _ := ctx.Deadline()
shutdown <- deadline
<-release
return nil
}},
{Name: "first", Setup: noop, Teardown: func(ctx context.Context) error {
first, _ = ctx.Deadline()
time.Sleep(20 * time.Millisecond)
return nil
}},
}
done := make(chan error, 1)
go func() {
_, err := Run(context.Background(), command(Action(cfg, modules, func(context.Context, *urfavecli.Command) error { close(ready); return nil })), []string{"app", "inspect"})
done <- err
}()
await(t, ready)
deadline := await(t, shutdown)
if !first.Equal(deadline) {
t.Fatal("shutdown deadline reset between hooks")
}
if err := await(t, done); !errors.Is(err, context.DeadlineExceeded) {
t.Fatalf("deadline lost: %v", err)
}
}
func TestEmptySelectionsAndOwnedSignals(t *testing.T) {
for _, oneOff := range []bool{false, true} {
cfg := config()
cfg.Signals = nil
action := Serve(cfg, nil)
if oneOff {
action = Action(cfg, nil, func(context.Context, *urfavecli.Command) error { return nil })
}
if code, err := Run(context.Background(), command(action), []string{"app", "inspect"}); code != 0 || err != nil {
t.Fatalf("Run=%d,%v", code, err)
}
}
cfg := config()
signals := make(chan os.Signal)
close(signals)
cfg.Signals = signals
if code, err := Run(context.Background(), command(Serve(cfg, nil)), []string{"app", "inspect"}); code != 0 || err != nil {
t.Fatalf("closed signals: %d,%v", code, err)
}
}
func TestPartialSetupAndMixedCleanup(t *testing.T) {
for _, setupFailure := range []bool{false, true} {
t.Run(fmt.Sprint(setupFailure), func(t *testing.T) {
initiating := urfavecli.Exit(io.ErrUnexpectedEOF, 2)
cleanup := errors.New("cleanup")
var order []string
modules := []lifecycle.Module{
{Name: "first", Setup: func(context.Context) error { order = append(order, "first setup"); return nil }, Teardown: func(context.Context) error { order = append(order, "first cleanup"); return cleanup }},
{Name: "second", Setup: func(context.Context) error {
order = append(order, "second setup")
if setupFailure {
return initiating
}
return nil
}, Teardown: func(context.Context) error { order = append(order, "second cleanup"); return nil }},
}
cliRoot := command(Action(config(), modules, func(context.Context, *urfavecli.Command) error { order = append(order, "action"); return initiating }))
code, err := Run(context.Background(), cliRoot, []string{"app", "inspect"})
if code != 1 || !errors.Is(err, initiating) || !errors.Is(err, cleanup) || !errors.Is(err, io.ErrUnexpectedEOF) {
t.Fatalf("Run=%d,%v", code, err)
}
want := []string{"first setup", "second setup", "first cleanup"}
if !setupFailure {
want = []string{"first setup", "second setup", "action", "second cleanup", "first cleanup"}
}
if !reflect.DeepEqual(order, want) {
t.Fatalf("order %v", order)
}
})
}
}
+51
View File
@@ -0,0 +1,51 @@
package cli_test
import (
"context"
"fmt"
"io"
"log/slog"
urfavecli "github.com/urfave/cli/v3"
"go.auvem.com/corestack/lifecycle"
"go.auvem.com/corestack/lifecycle/cli"
)
func Example() {
cfg := lifecycle.Config{Logger: slog.New(slog.NewTextHandler(io.Discard, nil))}
for _, args := range [][]string{{"app", "serve"}, {"app", "inspect", "--verbose"}} {
var database string
db := lifecycle.Module{Name: "database",
Setup: func(context.Context) error { database = "connected"; fmt.Println("open database"); return nil },
Teardown: func(context.Context) error { fmt.Println("close database"); database = ""; return nil },
}
server := lifecycle.Module{Name: "server",
Setup: func(context.Context) error { fmt.Println("open server"); return nil },
// A real server blocks until cancellation; this finite service keeps the example executable.
Run: func(context.Context) error { fmt.Println("serve:", database); return nil },
Teardown: func(context.Context) error { fmt.Println("close server"); return nil },
}
cliRoot := &urfavecli.Command{Name: "app", Commands: []*urfavecli.Command{
{Name: "serve", Action: cli.Serve(cfg, []lifecycle.Module{db, server})},
{Name: "inspect", Flags: []urfavecli.Flag{&urfavecli.BoolFlag{Name: "verbose"}},
Action: cli.Action(cfg, []lifecycle.Module{db}, func(_ context.Context, cmd *urfavecli.Command) error {
fmt.Println("inspect:", database, "verbose:", cmd.Bool("verbose"))
return nil
})},
}}
code, err := cli.Run(context.Background(), cliRoot, args)
fmt.Println("exit:", code, err)
// main may call os.Exit(code) here, after adapter cleanup.
}
// Output:
// open database
// open server
// serve: connected
// close server
// close database
// exit: 0 <nil>
// open database
// inspect: connected verbose: true
// close database
// exit: 0 <nil>
}