Skip to content

Commit 8e152b6

Browse files
committed
refactor(cli): add Execute, unexport error types, modernize idioms
Add cli.Execute() that owns all error dispatch (sentinels, flag errors, default). Silence Cobra's built-in error/usage printing so Execute has full control. Unexport FlagError and remove HandleError/NewFlagError — flag wrapping is now internal to Init via SetFlagErrorFunc. Modernize across packages: interface{} → any, os.IsNotExist → errors.Is(fs.ErrNotExist), sort.Slice → slices.SortFunc. Update migration guide and README to document cli.Execute.
1 parent 3eb7605 commit 8e152b6

22 files changed

Lines changed: 200 additions & 156 deletions

MIGRATION.md

Lines changed: 35 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -95,15 +95,23 @@ HTTP middleware is explicit — use `middleware.DefaultHTTP(logger)` for the sta
9595

9696
## CLI bootstrap
9797

98-
New `cli.Init()` enhances your root command with standard features:
98+
`cli.Init()` enhances your root command with standard features and `cli.Execute()` runs it with proper error handling:
9999

100100
```go
101101
// Before
102102
rootCmd := &cobra.Command{Use: "frontier", Short: "identity management"}
103103
mgr := commander.New(rootCmd, commander.WithTopics(topics))
104104
mgr.Init()
105105
rootCmd.AddCommand(serverCmd, configCmd)
106-
rootCmd.Execute()
106+
107+
cmd, err := rootCmd.ExecuteC()
108+
if err != nil {
109+
if commander.IsCommandErr(err) {
110+
fmt.Println(cmd.UsageString())
111+
}
112+
fmt.Println(err)
113+
os.Exit(1)
114+
}
107115

108116
// After
109117
import "github.com/raystack/salt/cli"
@@ -117,7 +125,7 @@ cli.Init(rootCmd,
117125
cli.Topics(topics...),
118126
)
119127

120-
rootCmd.Execute()
128+
cli.Execute(rootCmd)
121129
```
122130

123131
Config command helper replaces boilerplate:
@@ -159,7 +167,7 @@ func newListCmd() *cobra.Command {
159167

160168
## Error handling
161169

162-
`commander.IsCommandErr` (string matching) is replaced by typed errors and a helper:
170+
`commander.IsCommandErr` (string matching) and manual error handling are replaced by `cli.Execute`:
163171

164172
```go
165173
// Before
@@ -172,24 +180,38 @@ if err := rootCmd.Execute(); err != nil {
172180
}
173181

174182
// After
175-
if err := rootCmd.Execute(); err != nil {
176-
cli.HandleError(err) // handles ErrSilent, ErrCancel, FlagError, and default
177-
}
183+
cli.Execute(rootCmd) // handles all errors with proper exit codes
178184
```
179185

180-
In commands, return typed errors:
186+
`cli.Execute` uses `ExecuteC` internally and handles all error types:
187+
188+
| Error | Behavior |
189+
|-------|----------|
190+
| `cli.ErrSilent` | Exit 1, no output (command already printed the error) |
191+
| `cli.ErrCancel` | Exit 0, no output (user cancelled) |
192+
| Flag errors | Prints error + failing command's usage, exit 1 |
193+
| Other errors | Prints "Error: \<message\>", exit 1 |
194+
195+
In commands, return sentinel errors to control exit behavior:
181196

182197
```go
183-
// Command already printed the error — exit silently
198+
// Command already printed a rich error — exit 1, no extra output
199+
out.Error("connection failed: timeout")
184200
return cli.ErrSilent
185201

186-
// User cancelled (ctrl-c) — exit 0
202+
// User cancelled (ctrl-c, declined prompt) — exit 0
187203
return cli.ErrCancel
188-
189-
// Bad input — print error + usage
190-
return cli.NewFlagError(fmt.Errorf("--port must be positive"))
191204
```
192205

206+
The following exports are removed — their functionality is now internal to `cli.Execute`:
207+
208+
| Removed | Replacement |
209+
|---------|-------------|
210+
| `cli.HandleError(err)` | `cli.Execute(rootCmd)` handles errors automatically |
211+
| `cli.NewFlagError(err)` | `cli.Init` wraps flag errors automatically via `SetFlagErrorFunc` |
212+
| `cli.FlagError` (type) | Unexported; flag errors are handled internally by `Execute` |
213+
| `commander.IsCommandErr(err)` | Removed; `Execute` detects and handles flag/command errors |
214+
193215
## Printer
194216

195217
Package-level functions replaced by `Output` type:

README.md

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66

77
The standard way to build raystack services and CLIs.
88

9-
Salt provides `app.Run()` for services and `cli.Init()` for command-line tools, along with the building blocks they use: configuration, middleware, terminal output, and more.
9+
Salt provides `app.Run()` for services and `cli.Init()` / `cli.Execute()` for command-line tools, along with the building blocks they use: configuration, middleware, terminal output, and more.
1010

1111
## Quick start
1212

@@ -57,11 +57,11 @@ func main() {
5757
cli.Version("0.1.0", "raystack/frontier"),
5858
)
5959

60-
rootCmd.Execute()
60+
cli.Execute(rootCmd)
6161
}
6262
```
6363

64-
Help, shell completion, and reference docs added automatically. Commands access shared output via `cli.Output(cmd)`.
64+
`Init` adds help, shell completion, reference docs, and silences Cobra's default error output. `Execute` runs the command and handles all errors with proper exit codes. Commands access shared output via `cli.Output(cmd)`.
6565

6666
## Installation
6767

@@ -78,7 +78,7 @@ Requires Go 1.24+.
7878
| Package | Description |
7979
|---------|-------------|
8080
| [`app`](app/) | Service lifecycle — config, logger, telemetry, server, graceful shutdown |
81-
| [`cli`](cli/) | CLI lifecycle — root command, help, completion, version check |
81+
| [`cli`](cli/) | CLI lifecycle — init, execute, error handling, help, completion, version check |
8282

8383
### Server & Middleware
8484

app/option.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ type Option func(*App) error
1717
// WithConfig loads configuration into the target struct.
1818
// The target must be a pointer to a struct. Config is loaded eagerly
1919
// so that subsequent options can reference fields from it.
20-
func WithConfig(target interface{}, loaderOpts ...config.Option) Option {
20+
func WithConfig(target any, loaderOpts ...config.Option) Option {
2121
return func(_ *App) error {
2222
loader := config.NewLoader(loaderOpts...)
2323
return loader.Load(target)

cli/cli.go

Lines changed: 41 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,11 +10,12 @@
1010
// cli.Topics(authTopic, envTopic),
1111
// )
1212
//
13-
// rootCmd.Execute()
13+
// cli.Execute(rootCmd)
1414
package cli
1515

1616
import (
1717
"context"
18+
"errors"
1819
"fmt"
1920
"os"
2021

@@ -46,6 +47,11 @@ func Init(rootCmd *cobra.Command, opts ...Option) {
4647
// Set error prefix for consistent error messages.
4748
rootCmd.SetErrPrefix(rootCmd.Name() + ":")
4849

50+
// Silence cobra's default error and usage printing.
51+
// Errors are handled by Execute; usage is shown only for flag errors.
52+
rootCmd.SilenceErrors = true
53+
rootCmd.SilenceUsage = true
54+
4955
// Inject shared output and prompter into command context.
5056
existing := rootCmd.PersistentPreRun
5157
rootCmd.PersistentPreRun = func(cmd *cobra.Command, args []string) {
@@ -70,12 +76,46 @@ func Init(rootCmd *cobra.Command, opts ...Option) {
7076
mgr := commander.New(rootCmd, managerOpts...)
7177
mgr.Init()
7278

79+
// Wrap flag parsing errors so Execute can show contextual usage.
80+
// Must be set after mgr.Init() which also configures a flag error func.
81+
rootCmd.SetFlagErrorFunc(func(cmd *cobra.Command, err error) error {
82+
return &flagError{err: err}
83+
})
84+
7385
// Add version command if configured.
7486
if cfg.version != "" {
7587
rootCmd.AddCommand(versionCmd(rootCmd.Name(), cfg.version, cfg.repo))
7688
}
7789
}
7890

91+
// Execute runs the root command and handles errors with appropriate
92+
// exit codes and output. It uses ExecuteC to obtain the failing command
93+
// so flag errors can show contextual usage.
94+
//
95+
// This function never returns on error — it calls os.Exit.
96+
func Execute(rootCmd *cobra.Command) {
97+
cmd, err := rootCmd.ExecuteC()
98+
if err == nil {
99+
return
100+
}
101+
102+
var flagErr *flagError
103+
switch {
104+
case errors.Is(err, ErrCancel):
105+
os.Exit(0)
106+
case errors.Is(err, ErrSilent):
107+
os.Exit(1)
108+
case errors.As(err, &flagErr):
109+
fmt.Fprintln(os.Stderr, err)
110+
fmt.Fprintln(os.Stderr)
111+
fmt.Fprintln(os.Stderr, cmd.UsageString())
112+
os.Exit(1)
113+
default:
114+
fmt.Fprintf(os.Stderr, "Error: %s\n", err)
115+
os.Exit(1)
116+
}
117+
}
118+
79119
func versionCmd(name, ver, repo string) *cobra.Command {
80120
return &cobra.Command{
81121
Use: "version",

cli/cli_test.go

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,33 @@ func TestInit(t *testing.T) {
7373
err := root.Execute()
7474
require.NoError(t, err)
7575
})
76+
77+
t.Run("silences cobra error and usage output", func(t *testing.T) {
78+
root := newTestRoot()
79+
cli.Init(root)
80+
81+
assert.True(t, root.SilenceErrors, "SilenceErrors should be true")
82+
assert.True(t, root.SilenceUsage, "SilenceUsage should be true")
83+
})
84+
85+
t.Run("wraps flag errors for Execute", func(t *testing.T) {
86+
root := newTestRoot()
87+
root.Flags().Int("port", 8080, "server port")
88+
root.RunE = func(cmd *cobra.Command, args []string) error { return nil }
89+
cli.Init(root)
90+
91+
// Unknown flag returns an error (wrapped internally as flagError).
92+
root.SetArgs([]string{"--unknown"})
93+
err := root.Execute()
94+
require.Error(t, err)
95+
assert.Contains(t, err.Error(), "unknown flag")
96+
97+
// Invalid flag value also returns an error.
98+
root.SetArgs([]string{"--port", "abc"})
99+
err = root.Execute()
100+
require.Error(t, err)
101+
assert.Contains(t, err.Error(), "invalid argument")
102+
})
76103
}
77104

78105
func TestOutput(t *testing.T) {

cli/commander/codex.go

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
11
package commander
22

33
import (
4+
"errors"
45
"fmt"
6+
"io/fs"
57
"os"
68
"path/filepath"
79

@@ -67,7 +69,7 @@ func (m *Manager) generateMarkdownTree(rootOutputPath string, cmd *cobra.Command
6769

6870
// ensureDir ensures that the given directory exists, creating it if necessary.
6971
func ensureDir(path string) error {
70-
if _, err := os.Stat(path); os.IsNotExist(err) {
72+
if _, err := os.Stat(path); errors.Is(err, fs.ErrNotExist) {
7173
if err := os.MkdirAll(path, os.ModePerm); err != nil {
7274
return err
7375
}

cli/commander/completion.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,7 @@ func (m *Manager) addCompletionCommand() {
4343

4444
// generateCompletionSummary creates the long description for the `completion` command.
4545
func (m *Manager) generateCompletionSummary(exec string) string {
46-
var execs []interface{}
46+
var execs []any
4747
for i := 0; i < 12; i++ {
4848
execs = append(execs, exec)
4949
}

cli/commander/layout.go

Lines changed: 0 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,6 @@ package commander
22

33
import (
44
"bytes"
5-
"errors"
65
"fmt"
76
"regexp"
87
"strings"
@@ -12,7 +11,6 @@ import (
1211
"golang.org/x/text/language"
1312

1413
"github.com/spf13/cobra"
15-
"github.com/spf13/pflag"
1614
)
1715

1816
// Section Titles for Help Output
@@ -39,7 +37,6 @@ func (m *Manager) setCustomHelp() {
3937
displayHelp(cmd, args)
4038
})
4139
m.RootCmd.SetUsageFunc(generateUsage)
42-
m.RootCmd.SetFlagErrorFunc(handleFlagError)
4340
}
4441

4542
// generateUsage customizes the usage function for a command.
@@ -64,14 +61,6 @@ func generateUsage(cmd *cobra.Command) error {
6461
return nil
6562
}
6663

67-
// handleFlagError processes flag-related errors, including the special case of help flags.
68-
func handleFlagError(cmd *cobra.Command, err error) error {
69-
if errors.Is(err, pflag.ErrHelp) {
70-
return err
71-
}
72-
return err
73-
}
74-
7564
// displayHelp generates a custom help message for a Cobra command.
7665
func displayHelp(cmd *cobra.Command, args []string) {
7766
if isRootCommand(cmd.Parent()) && len(args) >= 2 && args[1] != "--help" && args[1] != "-h" {

cli/config_cmd.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ import (
1717
// Usage:
1818
//
1919
// rootCmd.AddCommand(cli.ConfigCommand("frontier", &Config{}))
20-
func ConfigCommand(appName string, defaultCfg interface{}) *cobra.Command {
20+
func ConfigCommand(appName string, defaultCfg any) *cobra.Command {
2121
cmd := &cobra.Command{
2222
Use: "config <command>",
2323
Short: "Manage client configuration",
@@ -30,7 +30,7 @@ func ConfigCommand(appName string, defaultCfg interface{}) *cobra.Command {
3030
return cmd
3131
}
3232

33-
func configInitCmd(appName string, defaultCfg interface{}) *cobra.Command {
33+
func configInitCmd(appName string, defaultCfg any) *cobra.Command {
3434
return &cobra.Command{
3535
Use: "init",
3636
Short: "Initialize a new configuration file",

cli/error.go

Lines changed: 9 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -1,50 +1,20 @@
11
package cli
22

3-
import (
4-
"errors"
5-
"fmt"
6-
"os"
7-
)
3+
import "errors"
84

95
// ErrSilent indicates the command already printed its error.
10-
// The error handler should exit 1 without printing anything.
6+
// Execute will exit 1 without printing anything.
117
var ErrSilent = errors.New("silent error")
128

139
// ErrCancel indicates the user cancelled the operation (e.g. ctrl-c).
14-
// The error handler should exit 0.
10+
// Execute will exit 0 without printing anything.
1511
var ErrCancel = errors.New("cancelled")
1612

17-
// FlagError wraps an error caused by bad flags or arguments.
18-
// The error handler should print the error and show usage.
19-
type FlagError struct {
20-
Err error
13+
// flagError wraps an error caused by bad flags or arguments.
14+
// Execute prints the error and shows the failing command's usage.
15+
type flagError struct {
16+
err error
2117
}
2218

23-
func (e *FlagError) Error() string { return e.Err.Error() }
24-
func (e *FlagError) Unwrap() error { return e.Err }
25-
26-
// NewFlagError creates a FlagError.
27-
func NewFlagError(err error) *FlagError {
28-
return &FlagError{Err: err}
29-
}
30-
31-
// HandleError handles a command error by type.
32-
// FlagError prints the error (usage is shown by cobra).
33-
// SilentError exits without printing.
34-
// CancelError exits with code 0.
35-
// Other errors print "Error: <message>".
36-
func HandleError(err error) {
37-
if err == nil {
38-
return
39-
}
40-
41-
switch {
42-
case errors.Is(err, ErrCancel):
43-
os.Exit(0)
44-
case errors.Is(err, ErrSilent):
45-
os.Exit(1)
46-
default:
47-
fmt.Fprintf(os.Stderr, "Error: %s\n", err)
48-
os.Exit(1)
49-
}
50-
}
19+
func (e *flagError) Error() string { return e.err.Error() }
20+
func (e *flagError) Unwrap() error { return e.err }

0 commit comments

Comments
 (0)