diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..7970236 --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +coverage.out +coverage.html +*.test diff --git a/README.md b/README.md index 9126e5b..711b95e 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,49 @@ # appcli -appcli provides boilerplate dependency handling for command line apps based on [urfave/cli](https://github.com/urfave/cli) that utilize [app](https://gitea.auvem.com/go-toolkit/app). +Bridge between [urfave/cli/v3](https://github.com/urfave/cli/v3) and [app](https://gitea.auvem.com/go-toolkit/app) lifecycles. + +## Install + +```bash +go get gitea.auvem.com/go-toolkit/appcli +``` + +## Example + +```go +func main() { + lc := app.NewLifecycle() + defer lc.Teardown() + + root := appcli.NewRootCommand(&cli.Command{ + Name: "myapp", + Usage: "My application", + Commands: []*cli.Command{ + appcli.NewCommand(&cli.Command{ + Name: "serve", + Usage: "Run the server", + Action: func(ctx context.Context, c *cli.Command) error { + // modules already loaded via DepFn + return nil + }, + }, func(l *app.Lifecycle, c *cli.Command) ([]*app.Module, error) { + return []*app.Module{myModule}, nil + }), + }, + }) + + if err := appcli.Run(context.Background(), lc, root, os.Args); err != nil { + log.Fatal(err) + } +} +``` + +## NewRootCommand vs NewCommand + +- `NewRootCommand` adds global `--verbose` / `-v` (unless already defined) +- Both wrap `Before` to call `lifecycle.Require` with modules from `DepFn` +- Wrapped commands are copied; the original `*cli.Command` is not mutated + +## DepFn + +The `*cli.Command` argument is the command whose `Before` hook is running. For nested commands, parent hooks receive the parent command; use `VerboseFromCommand` to read root flags from subcommands. diff --git a/appcli.go b/appcli.go deleted file mode 100644 index d676465..0000000 --- a/appcli.go +++ /dev/null @@ -1,96 +0,0 @@ -package appcli - -import ( - "context" - "errors" - - "gitea.auvem.com/go-toolkit/app" - "github.com/urfave/cli/v3" -) - -// DepFn is a function type that returns a slice of dependencies. -type DepFn func(*app.Lifecycle, *cli.Command) ([]*app.Module, error) - -// DepList is a convenience function that returns a DepFn that uses a -// predefined list of dependecies. -func DepList(deps ...*app.Module) DepFn { - return func(_ *app.Lifecycle, _ *cli.Command) ([]*app.Module, error) { - if len(deps) == 0 { - return nil, errors.New("no dependencies provided") - } - return deps, nil - } -} - -// NewCommand creates a new CLI command with the specified configuration and -// and dependencies. Returns a standard *cli.Command that can be used directly -// with urfave/cli/v3 types. If any dependencies are provided, the Before method -// is overriden to ensure that all dependencies are satisfied before the command -// is executed. Requires an app.Lifecycle to be present in the context when the -// command is executed. -func NewCommand(cmdcfg *cli.Command, depfn ...DepFn) *cli.Command { - if len(depfn) == 0 { - return cmdcfg - } - - // Override the Before method to handle dependencies - originalBefore := cmdcfg.Before - cmdcfg.Before = func(ctx context.Context, cmd *cli.Command) (context.Context, error) { - if originalBefore != nil { - var err error - ctx, err = originalBefore(ctx, cmd) - if err != nil { - return ctx, err - } - } - - lifecycle := app.LifecycleFromContext(ctx) - if lifecycle == nil { - return ctx, errors.New("lifecycle not found in context, cannot run command with dependencies") - } - - deps := make([]*app.Module, 0) - for _, fn := range depfn { - if fn == nil { - continue - } - - modules, err := fn(lifecycle, cmd) - if err != nil { - return ctx, err - } - deps = append(deps, modules...) - } - - if len(deps) > 0 { - if err := lifecycle.Require(deps...); err != nil { - return ctx, err - } - } - - return ctx, nil - } - - return cmdcfg -} - -// NewRootCommand creates a new root CLI command with the specified configuration. -// Adds verbose flag and override Before and After methods to handle dependencies. -// See NewCommand for more details. -func NewRootCommand(cmdcfg *cli.Command, depfn ...DepFn) *cli.Command { - cmdcfg.Flags = append(cmdcfg.Flags, &cli.BoolFlag{ - Name: "verbose", - Aliases: []string{"v"}, - Usage: "Enable verbose output", - }) - return NewCommand(cmdcfg, depfn...) -} - -// VerboseFromCommand checks if the verbose flag is set in the command context. -func VerboseFromCommand(cmd *cli.Command) bool { - if cmd == nil { - return false - } - - return cmd.Bool("verbose") -} diff --git a/appcli_test.go b/appcli_test.go new file mode 100644 index 0000000..f57f73d --- /dev/null +++ b/appcli_test.go @@ -0,0 +1,67 @@ +package appcli_test + +import ( + "context" + "testing" + + "gitea.auvem.com/go-toolkit/app" + "gitea.auvem.com/go-toolkit/appcli" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/urfave/cli/v3" +) + +func TestNewCommandRequiresLifecycle(t *testing.T) { + cmd := appcli.NewCommand(&cli.Command{Name: "test"}, appcli.DepList()) + err := cmd.Run(context.Background(), []string{"test"}) + assert.ErrorIs(t, err, appcli.ErrLifecycleNotInContext) +} + +func TestNewCommandLoadsDeps(t *testing.T) { + var loaded bool + mod := app.NewModule("dep", app.ModuleOpts{ + Setup: func(m *app.Module) error { loaded = true; return nil }, + }) + + cmd := appcli.NewCommand(&cli.Command{ + Name: "test", + Action: func(ctx context.Context, c *cli.Command) error { + return nil + }, + }, appcli.DepList(mod)) + + lc := app.NewLifecycle() + err := cmd.Run(lc.Context(context.Background()), []string{"test"}) + require.NoError(t, err) + assert.True(t, loaded) +} + +func TestNewCommandDoesNotMutateInput(t *testing.T) { + original := &cli.Command{Name: "orig"} + wrapped := appcli.NewCommand(original, appcli.DepList()) + assert.NotSame(t, original, wrapped) + assert.Nil(t, original.Before) +} + +func TestDepListEmpty(t *testing.T) { + fn := appcli.DepList() + _, err := fn(nil, nil) + assert.ErrorIs(t, err, appcli.ErrNoDependencies) +} + +func TestVerboseFromCommand(t *testing.T) { + root := appcli.NewRootCommand(&cli.Command{Name: "root"}) + assert.False(t, appcli.VerboseFromCommand(root)) +} + +func TestRun(t *testing.T) { + lc := app.NewLifecycle() + cmd := &cli.Command{ + Name: "ok", + Action: func(ctx context.Context, c *cli.Command) error { + assert.NotNil(t, app.LifecycleFromContext(ctx)) + return nil + }, + } + require.NoError(t, appcli.Run(context.Background(), lc, cmd, []string{"ok"})) +} diff --git a/command.go b/command.go new file mode 100644 index 0000000..589ca8b --- /dev/null +++ b/command.go @@ -0,0 +1,104 @@ +package appcli + +import ( + "context" + + "gitea.auvem.com/go-toolkit/app" + "github.com/urfave/cli/v3" +) + +// NewCommand wraps cmd with dependency loading in Before. The input command is +// not mutated; a shallow copy is returned. +func NewCommand(cmdcfg *cli.Command, depfn ...DepFn) *cli.Command { + if len(depfn) == 0 { + return cmdcfg + } + + cmd := cloneCommand(cmdcfg) + originalBefore := cmdcfg.Before + cmd.Before = func(ctx context.Context, cmd *cli.Command) (context.Context, error) { + if originalBefore != nil { + var err error + ctx, err = originalBefore(ctx, cmd) + if err != nil { + return ctx, err + } + } + + lifecycle := app.LifecycleFromContext(ctx) + if lifecycle == nil { + return ctx, ErrLifecycleNotInContext + } + + deps := make([]*app.Module, 0) + for _, fn := range depfn { + if fn == nil { + continue + } + + modules, err := fn(lifecycle, cmd) + if err != nil { + return ctx, err + } + deps = append(deps, modules...) + } + + if len(deps) > 0 { + if err := lifecycle.Require(deps...); err != nil { + return ctx, err + } + } + + return ctx, nil + } + + return cmd +} + +// NewRootCommand wraps a root command, adds a global --verbose / -v flag, and +// applies dependency loading via [NewCommand]. +func NewRootCommand(cmdcfg *cli.Command, depfn ...DepFn) *cli.Command { + root := cloneCommand(cmdcfg) + if !hasFlag(root, "verbose") { + root.Flags = append(root.Flags, &cli.BoolFlag{ + Name: "verbose", + Aliases: []string{"v"}, + Usage: "Enable verbose output", + }) + } + return NewCommand(root, depfn...) +} + +// VerboseFromCommand reports whether --verbose was set on the command or an ancestor. +func VerboseFromCommand(cmd *cli.Command) bool { + if cmd == nil { + return false + } + return cmd.Bool("verbose") +} + +func cloneCommand(src *cli.Command) *cli.Command { + if src == nil { + return nil + } + dup := *src + if len(src.Flags) > 0 { + dup.Flags = append([]cli.Flag(nil), src.Flags...) + } + if len(src.Commands) > 0 { + dup.Commands = append([]*cli.Command(nil), src.Commands...) + } + if len(src.Arguments) > 0 { + dup.Arguments = append([]cli.Argument(nil), src.Arguments...) + } + return &dup +} + +func hasFlag(cmd *cli.Command, name string) bool { + for _, f := range cmd.Flags { + if bf, ok := f.(*cli.BoolFlag); ok && bf.Name == name { + return true + } + } + return false +} diff --git a/deps.go b/deps.go new file mode 100644 index 0000000..63c8540 --- /dev/null +++ b/deps.go @@ -0,0 +1,37 @@ +package appcli + +import ( + "context" + "errors" + + "gitea.auvem.com/go-toolkit/app" + "github.com/urfave/cli/v3" +) + +var ( + // ErrLifecycleNotInContext is returned when a command with dependencies runs + // without a lifecycle in context. + ErrLifecycleNotInContext = errors.New("lifecycle not found in context, cannot run command with dependencies") + + // ErrNoDependencies is returned when DepList is called with zero modules. + ErrNoDependencies = errors.New("no dependencies provided") +) + +// DepFn returns modules to load before a command runs. The *cli.Command is the +// command whose Before hook is executing (may be a parent in nested command trees). +type DepFn func(*app.Lifecycle, *cli.Command) ([]*app.Module, error) + +// DepList returns a DepFn that always supplies the given modules. +func DepList(deps ...*app.Module) DepFn { + return func(_ *app.Lifecycle, _ *cli.Command) ([]*app.Module, error) { + if len(deps) == 0 { + return nil, ErrNoDependencies + } + return deps, nil + } +} + +// Run injects lifecycle into ctx and executes cmd with args. +func Run(ctx context.Context, lc *app.Lifecycle, cmd *cli.Command, args []string) error { + return cmd.Run(lc.Context(ctx), args) +} diff --git a/doc.go b/doc.go new file mode 100644 index 0000000..32e8cb9 --- /dev/null +++ b/doc.go @@ -0,0 +1,7 @@ +// Package appcli connects [github.com/urfave/cli/v3] commands to +// [gitea.auvem.com/go-toolkit/app.Lifecycle] dependency loading. +// +// Inject the lifecycle into context in main (see [appcli.Run]), wrap commands with +// [appcli.NewCommand] or [appcli.NewRootCommand], and supply [appcli.DepFn] callbacks +// that return modules to initialize before each command runs. +package appcli diff --git a/go.mod b/go.mod index 4cf3a6d..209582e 100644 --- a/go.mod +++ b/go.mod @@ -3,6 +3,15 @@ module gitea.auvem.com/go-toolkit/appcli go 1.24.0 require ( - gitea.auvem.com/go-toolkit/app v0.0.0-20250603235859-6f9e3731acf9 + gitea.auvem.com/go-toolkit/app v0.0.0-1782781086-58e5e33e18da + github.com/stretchr/testify v1.10.0 github.com/urfave/cli/v3 v3.3.3 ) + +require ( + github.com/davecgh/go-spew v1.1.1 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect +) + +replace gitea.auvem.com/go-toolkit/app => ../app diff --git a/go.sum b/go.sum index 838a6cd..da7d8f2 100644 --- a/go.sum +++ b/go.sum @@ -1,12 +1,17 @@ -gitea.auvem.com/go-toolkit/app v0.0.0-20250603235859-6f9e3731acf9 h1:MYOI+bB4IBAqoL1tyIUFnu0S+NSq0OX88J3K/PUR7lI= -gitea.auvem.com/go-toolkit/app v0.0.0-20250603235859-6f9e3731acf9/go.mod h1:a7ENpOxndUdONE6oZ9MZAvG1ba2uq01x/LtcnDkpOj8= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e h1:fD57ERR4JtEqsWbfPhv4DMiApHyliiK5xCTNVSPiaAs= +github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= github.com/urfave/cli/v3 v3.3.3 h1:byCBaVdIXuLPIDm5CYZRVG6NvT7tv1ECqdU4YzlEa3I= github.com/urfave/cli/v3 v3.3.3/go.mod h1:FJSKtM/9AiiTOJL4fJ6TbMUkxBXn7GO9guZqoZtpYpo= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f h1:BLraFXnmrev5lT+xlilqcH8XK9/i0At2xKjWk4p6zsU= +gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=