Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions docs/docs/changelog.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ title: Changelog

## [Unreleased](https://github.com/lets-cli/lets/releases/tag/v0.0.X)

* `[Fixed]` Make root and `self` help paths delegate through Cobra help handling, and allow `--version` without requiring config.
* `[Refactoring]` Use Go 1.26 `errors.AsType` for type-safe error unwrapping.

## [0.0.61](https://github.com/lets-cli/lets/releases/tag/v0.0.61)
Expand Down
27 changes: 5 additions & 22 deletions internal/cli/cli.go
Original file line number Diff line number Diff line change
Expand Up @@ -66,15 +66,6 @@ func Main(version string, buildDate string) int {
return 1
}

if rootFlags.version {
if err := cmd.PrintVersionMessage(rootCmd); err != nil {
log.Errorf("print version error: %s", err)
return 1
}

return 0
}

debugLevel := env.SetDebugLevel(rootFlags.debug)

if debugLevel > 0 {
Expand Down Expand Up @@ -112,17 +103,6 @@ func Main(version string, buildDate string) int {
return 0
}

showUsage := rootFlags.help || (command.Name() == "help" && len(args) == 0) || (len(os.Args) == 1)

if showUsage {
if err := cmd.PrintRootHelpMessage(rootCmd); err != nil {
log.Errorf("print help error: %s", err)
return 1
}

return 0
}

updateCh, cancelUpdateCheck := maybeStartUpdateCheck(ctx, version, command, appSettings)
defer cancelUpdateCheck()

Expand Down Expand Up @@ -178,9 +158,12 @@ func getExitCode(err error, defaultCode int) int {
return defaultCode
}

// do not fail on config error if it is help (-h, --help), --init, completion, or lets self.
// do not fail on config error if it is help (-h, --help), --version, --init, completion, or lets self.
func failOnConfigError(root *cobra.Command, current *cobra.Command, rootFlags *flags) bool {
return (root.Flags().NFlag() == 0 && !allowsMissingConfig(current)) && !rootFlags.help && !rootFlags.init
return (root.Flags().NFlag() == 0 && !allowsMissingConfig(current)) &&
!rootFlags.help &&
!rootFlags.version &&
!rootFlags.init
}

func allowsMissingConfig(current *cobra.Command) bool {
Expand Down
9 changes: 9 additions & 0 deletions internal/cli/cli_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,15 @@ func TestAllowsMissingConfig(t *testing.T) {
})
}

func TestFailOnConfigError(t *testing.T) {
root := cmdpkg.CreateRootCommand("v0.0.0-test", "")
current := &cobra.Command{Use: "run"}

if failOnConfigError(root, current, &flags{version: true}) {
t.Fatal("expected --version to allow missing config")
}
}

func TestShouldCheckForUpdate(t *testing.T) {
defaultSettings := settings.Default()

Expand Down
45 changes: 28 additions & 17 deletions internal/cmd/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -61,33 +61,55 @@ func validateCommandArgs(cmd *cobra.Command, args []string) error {
}
}

// newRootCmd represents the base command when called without any subcommands.
func newRootCmd(version string) *cobra.Command {
// newRootCmd creates root cobra command that represents the base command
// when called without any subcommands.
func newRootCmd(version, buildDate string) *cobra.Command {
cmd := &cobra.Command{
Use: "lets",
Short: "A CLI task runner",
Args: validateCommandArgs,
RunE: func(cmd *cobra.Command, args []string) error {
return PrintHelpMessage(cmd)
return cmd.Help()
},
Version: buildVersion(version, buildDate),
Annotations: map[string]string{"buildDate": buildDate},
TraverseChildren: true,
FParseErrWhitelist: cobra.FParseErrWhitelist{UnknownFlags: true},
Version: version,
// handle errors manually
SilenceErrors: true,
// print help message manyally
SilenceUsage: true,
}

cmd.SetHelpFunc(func(c *cobra.Command, _ []string) {
var err error
if c == c.Root() {
err = PrintRootHelpMessage(c)
} else {
err = PrintHelpMessage(c)
}

if err != nil {
c.Println(err)
}
})
Comment on lines +92 to +95

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

suggestion (bug_risk): Avoid printing help errors to stdout; prefer stderr to keep output channels separated.

In the custom help func, errors from PrintRootHelpMessage / PrintHelpMessage are printed via c.Println(err), so they go to stdout and mix with normal help output, which can break tooling that parses stdout. Since SetHelpFunc can’t return errors, consider using c.PrintErrln(err) so error output goes to stderr instead.

Suggested change
if err != nil {
c.Println(err)
}
})
if err != nil {
c.PrintErrln(err)
}
})

cmd.AddGroup(&cobra.Group{ID: "main", Title: "Commands:"}, &cobra.Group{ID: "internal", Title: "Internal commands:"})
cmd.SetHelpCommandGroupID("internal")

return cmd
}

func buildVersion(version string, buildDate string) string {
if buildDate != "" {
version += fmt.Sprintf(" (%s)", buildDate)
}

return version
}

// CreateRootCommand used to run only root command without config.
func CreateRootCommand(version string, buildDate string) *cobra.Command {
rootCmd := newRootCmd(version)
rootCmd.Annotations = map[string]string{"buildDate": buildDate}
rootCmd := newRootCmd(version, buildDate)

initRootFlags(rootCmd)

Expand Down Expand Up @@ -257,14 +279,3 @@ func PrintRootHelpMessage(cmd *cobra.Command) error {

return err
}

func PrintVersionMessage(cmd *cobra.Command) error {
msg := "lets version " + cmd.Version
if buildDate := cmd.Annotations["buildDate"]; buildDate != "" {
msg += fmt.Sprintf(" (%s)", buildDate)
}

_, err := fmt.Fprintln(cmd.OutOrStdout(), msg)

return err
}
80 changes: 64 additions & 16 deletions internal/cmd/root_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,24 @@ func TestRootCmd(t *testing.T) {
)
}
})

t.Run("should use help func when run without args", func(t *testing.T) {
rootCmd := CreateRootCommand("v0.0.0-test", "")
rootCmd.SetArgs([]string{})

called := false
rootCmd.SetHelpFunc(func(c *cobra.Command, args []string) {
called = true
})

if err := rootCmd.Execute(); err != nil {
t.Fatalf("unexpected error: %v", err)
}

if !called {
t.Fatal("expected root command to delegate to help func")
}
})
}

func TestRootCmdWithConfig(t *testing.T) {
Expand Down Expand Up @@ -150,44 +168,74 @@ func TestRootCmdWithConfig(t *testing.T) {
})
}

func TestPrintVersionMessage(t *testing.T) {
t.Run("should include build date in parentheses when non-empty", func(t *testing.T) {
func TestRootCommandVersion(t *testing.T) {
t.Run("should keep raw version on command", func(t *testing.T) {
root := CreateRootCommand("v0.0.0-test", "2024-01-15T10:30:00Z")

if root.Version != "v0.0.0-test (2024-01-15T10:30:00Z)" {
t.Errorf("expected raw version, got %s", root.Version)
}
})

t.Run("print version with build date", func(t *testing.T) {
buf := new(bytes.Buffer)
root := CreateRootCommand("v0.0.0-test", "2024-01-15T10:30:00Z")
root.SetOut(buf)
root.SetErr(buf)
root.InitDefaultVersionFlag()
root.SetArgs([]string{"--version"})

err := PrintVersionMessage(root)
if err != nil {
if err := root.Execute(); err != nil {
t.Fatalf("unexpected error: %v", err)
}

out := buf.String()
if !strings.Contains(out, "v0.0.0-test") {
t.Errorf("expected version in output, got %q", out)
}
if !strings.Contains(out, "(2024-01-15T10:30:00Z)") {
t.Errorf("expected build date in parentheses, got %q", out)
expected := "lets version v0.0.0-test (2024-01-15T10:30:00Z)\n"
if buf.String() != expected {
t.Errorf("expected %q, got %q", expected, buf.String())
}
})

t.Run("should omit parentheses when build date is empty", func(t *testing.T) {
t.Run("omit build date from version output when empty", func(t *testing.T) {
buf := new(bytes.Buffer)
root := CreateRootCommand("v0.0.0-test", "")
root.SetOut(buf)
root.SetErr(buf)
root.InitDefaultVersionFlag()
root.SetArgs([]string{"--version"})

err := PrintVersionMessage(root)
if err != nil {
if err := root.Execute(); err != nil {
t.Fatalf("unexpected error: %v", err)
}

out := buf.String()
if strings.Contains(out, "(") {
t.Errorf("expected no parentheses when build date is empty, got %q", out)
expected := "lets version v0.0.0-test\n"
if buf.String() != expected {
t.Errorf("expected %q, got %q", expected, buf.String())
}
})
}

func TestSelfCmd(t *testing.T) {
t.Run("should use help func when run without args", func(t *testing.T) {
rootCmd := CreateRootCommand("v0.0.0-test", "")
rootCmd.SetArgs([]string{"self"})
initSelfCmd(rootCmd, "v0.0.0-test", func(string) error { return nil })

called := false
rootCmd.SetHelpFunc(func(c *cobra.Command, args []string) {
if c.Name() == "self" {
called = true
}
})

if err := rootCmd.Execute(); err != nil {
t.Fatalf("unexpected error: %v", err)
}

if !called {
t.Fatal("expected self command to delegate to help func")
}
})

t.Run("should open documentation in browser", func(t *testing.T) {
bufOut := new(bytes.Buffer)
called := false
Expand Down
2 changes: 1 addition & 1 deletion internal/cmd/self.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ func initSelfCmd(rootCmd *cobra.Command, version string, openURL func(string) er
GroupID: "internal",
Args: validateCommandArgs,
RunE: func(cmd *cobra.Command, args []string) error {
return PrintHelpMessage(cmd)
return cmd.Help()
},
}

Expand Down
Loading