From fe7136ad6097945697e9d26b3b867acc83488f11 Mon Sep 17 00:00:00 2001 From: "m.kindritskiy" Date: Sat, 6 Jun 2026 22:00:59 +0300 Subject: [PATCH 1/2] Fix help and version handling --- internal/cli/cli.go | 18 +++-------- internal/cli/cli_test.go | 9 ++++++ internal/cmd/root.go | 41 ++++++++++++++++------- internal/cmd/root_test.go | 68 ++++++++++++++++++++++++++------------- internal/cmd/self.go | 2 +- 5 files changed, 89 insertions(+), 49 deletions(-) diff --git a/internal/cli/cli.go b/internal/cli/cli.go index 72677e6..7c08ec8 100644 --- a/internal/cli/cli.go +++ b/internal/cli/cli.go @@ -112,17 +112,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() @@ -178,9 +167,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 { diff --git a/internal/cli/cli_test.go b/internal/cli/cli_test.go index 25baebf..55199ff 100644 --- a/internal/cli/cli_test.go +++ b/internal/cli/cli_test.go @@ -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() diff --git a/internal/cmd/root.go b/internal/cmd/root.go index 338a42e..99a4f0c 100644 --- a/internal/cmd/root.go +++ b/internal/cmd/root.go @@ -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) + } + }) 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 { + msg := "lets version " + version + if buildDate != "" { + msg += fmt.Sprintf(" (%s)", buildDate) + } + return msg +} + // 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) @@ -259,12 +281,7 @@ func PrintRootHelpMessage(cmd *cobra.Command) error { } 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) + _, err := fmt.Fprintln(cmd.OutOrStdout(), cmd.Version) return err } diff --git a/internal/cmd/root_test.go b/internal/cmd/root_test.go index 821d3a2..6adc823 100644 --- a/internal/cmd/root_test.go +++ b/internal/cmd/root_test.go @@ -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) { @@ -150,44 +168,48 @@ func TestRootCmdWithConfig(t *testing.T) { }) } -func TestPrintVersionMessage(t *testing.T) { +func TestRootCommandVersion(t *testing.T) { t.Run("should include build date in parentheses when non-empty", func(t *testing.T) { - buf := new(bytes.Buffer) root := CreateRootCommand("v0.0.0-test", "2024-01-15T10:30:00Z") - root.SetOut(buf) - - err := PrintVersionMessage(root) - if 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)" + if root.Version != expected { + t.Errorf("expected: %s, got %s", expected, root.Version) } }) t.Run("should omit parentheses when build date is empty", func(t *testing.T) { - buf := new(bytes.Buffer) root := CreateRootCommand("v0.0.0-test", "") - root.SetOut(buf) - err := PrintVersionMessage(root) - if err != nil { + expected := "lets version v0.0.0-test" + if root.Version != expected { + t.Errorf("expected: %s, got %s", expected, root.Version) + } + }) +} + +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) } - out := buf.String() - if strings.Contains(out, "(") { - t.Errorf("expected no parentheses when build date is empty, got %q", out) + if !called { + t.Fatal("expected self command to delegate to help func") } }) -} -func TestSelfCmd(t *testing.T) { t.Run("should open documentation in browser", func(t *testing.T) { bufOut := new(bytes.Buffer) called := false diff --git a/internal/cmd/self.go b/internal/cmd/self.go index a71cd5c..97b9863 100644 --- a/internal/cmd/self.go +++ b/internal/cmd/self.go @@ -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() }, } From e7462b648d22da84f4bfc5a127da7d6b02bafbc7 Mon Sep 17 00:00:00 2001 From: "m.kindritskiy" Date: Sat, 6 Jun 2026 22:04:15 +0300 Subject: [PATCH 2/2] Add changelog entry for help/version fix --- docs/docs/changelog.md | 1 + internal/cli/cli.go | 9 --------- internal/cmd/root.go | 12 +++-------- internal/cmd/root_test.go | 42 +++++++++++++++++++++++++++++++-------- 4 files changed, 38 insertions(+), 26 deletions(-) diff --git a/docs/docs/changelog.md b/docs/docs/changelog.md index 223e60f..e6880d9 100644 --- a/docs/docs/changelog.md +++ b/docs/docs/changelog.md @@ -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) diff --git a/internal/cli/cli.go b/internal/cli/cli.go index 7c08ec8..4f54cbb 100644 --- a/internal/cli/cli.go +++ b/internal/cli/cli.go @@ -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 { diff --git a/internal/cmd/root.go b/internal/cmd/root.go index 99a4f0c..30d87c1 100644 --- a/internal/cmd/root.go +++ b/internal/cmd/root.go @@ -100,11 +100,11 @@ func newRootCmd(version, buildDate string) *cobra.Command { } func buildVersion(version string, buildDate string) string { - msg := "lets version " + version if buildDate != "" { - msg += fmt.Sprintf(" (%s)", buildDate) + version += fmt.Sprintf(" (%s)", buildDate) } - return msg + + return version } // CreateRootCommand used to run only root command without config. @@ -279,9 +279,3 @@ func PrintRootHelpMessage(cmd *cobra.Command) error { return err } - -func PrintVersionMessage(cmd *cobra.Command) error { - _, err := fmt.Fprintln(cmd.OutOrStdout(), cmd.Version) - - return err -} diff --git a/internal/cmd/root_test.go b/internal/cmd/root_test.go index 6adc823..c3ac84f 100644 --- a/internal/cmd/root_test.go +++ b/internal/cmd/root_test.go @@ -169,21 +169,47 @@ func TestRootCmdWithConfig(t *testing.T) { } func TestRootCommandVersion(t *testing.T) { - t.Run("should include build date in parentheses when non-empty", func(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") - expected := "lets version v0.0.0-test (2024-01-15T10:30:00Z)" - if root.Version != expected { - t.Errorf("expected: %s, got %s", expected, root.Version) + if root.Version != "v0.0.0-test (2024-01-15T10:30:00Z)" { + t.Errorf("expected raw version, got %s", root.Version) } }) - t.Run("should omit parentheses when build date is empty", func(t *testing.T) { + 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"}) + + if err := root.Execute(); err != nil { + t.Fatalf("unexpected error: %v", err) + } + + 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("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"}) + + if err := root.Execute(); err != nil { + t.Fatalf("unexpected error: %v", err) + } - expected := "lets version v0.0.0-test" - if root.Version != expected { - t.Errorf("expected: %s, got %s", expected, root.Version) + expected := "lets version v0.0.0-test\n" + if buf.String() != expected { + t.Errorf("expected %q, got %q", expected, buf.String()) } }) }