fix: copy-on-wrap commands and typed errors
Clone commands before wrapping Before hooks, export ErrLifecycleNotInContext and ErrNoDependencies, add Run helper, split command.go and deps.go. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,3 @@
|
||||
coverage.out
|
||||
coverage.html
|
||||
*.test
|
||||
@@ -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.
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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"}))
|
||||
}
|
||||
+104
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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=
|
||||
|
||||
Reference in New Issue
Block a user