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:
2026-06-29 18:04:07 -07:00
parent 60ecc69620
commit f64f7fa04e
9 changed files with 282 additions and 100 deletions
+3
View File
@@ -0,0 +1,3 @@
coverage.out
coverage.html
*.test
+47 -1
View File
@@ -1,3 +1,49 @@
# appcli # 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.
-96
View File
@@ -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")
}
+67
View File
@@ -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
View File
@@ -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
}
+37
View File
@@ -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)
}
+7
View File
@@ -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
+10 -1
View File
@@ -3,6 +3,15 @@ module gitea.auvem.com/go-toolkit/appcli
go 1.24.0 go 1.24.0
require ( 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 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
+7 -2
View File
@@ -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 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 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 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 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 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= 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 h1:byCBaVdIXuLPIDm5CYZRVG6NvT7tv1ECqdU4YzlEa3I=
github.com/urfave/cli/v3 v3.3.3/go.mod h1:FJSKtM/9AiiTOJL4fJ6TbMUkxBXn7GO9guZqoZtpYpo= 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 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=