diff --git a/cmd/root.go b/cmd/root.go index 4b79f3b..3226173 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -3,8 +3,10 @@ package cmd import ( "fmt" "strings" + "sort" "github.com/spf13/cobra" + "github.com/lets-cli/lets/set" ) // newRootCmd represents the base command when called without any subcommands. @@ -58,6 +60,94 @@ func PrintHelpMessage(cmd *cobra.Command) error { return err } +func buildGroupCommandHelp(cmd *cobra.Command, group *cobra.Group) string { + help := "" + cmds := []*cobra.Command{} + + // select commands that belong to the specified group + for _, c := range cmd.Commands() { + if c.GroupID == group.ID && (c.IsAvailableCommand() || c.Name() == "help") { + cmds = append(cmds, c) + } + } + + sort.Slice(cmds, func(i, j int) bool { + return cmds[i].Name() < cmds[j].Name() + }) + + // Create a list of subgroups + subGroupNameSet := set.NewSet[string]() + + for _, c := range cmds { + if subgroup, ok := c.Annotations["SubGroupName"]; ok && subgroup != "" { + subGroupNameSet.Add(subgroup) + } + } + + subGroupNameList := subGroupNameSet.ToList() + sort.Strings(subGroupNameList) + + // generate output + help += fmt.Sprintf("%s\n", group.Title) + + for _, subgroupName := range subGroupNameList { + intend := "" + if len(subGroupNameList) > 1 { + help += fmt.Sprintf("\n %s\n", subgroupName) + intend = " " + } + for _, c := range cmds { + if subgroup, ok := c.Annotations["SubGroupName"]; ok && subgroup == subgroupName { + help += fmt.Sprintf("%s %-*s %s\n", intend, cmd.NamePadding(), c.Name(), c.Short) + } + } + } + + for _, c := range cmds { + if _, ok := c.Annotations["SubGroupName"]; !ok { + help += fmt.Sprintf(" %-*s %s\n", cmd.NamePadding(), c.Name(), c.Short) + } + } + + help += "\n" + + return help +} + + +func PrintRootHelpMessage(cmd *cobra.Command) error { + help := "" + help = fmt.Sprintf("%s\n\n%s", cmd.Short, help) + + // General + help += "Usage:\n" + if cmd.Runnable() { + help += fmt.Sprintf(" %s\n", cmd.UseLine()) + } + if cmd.HasAvailableSubCommands() { + help += fmt.Sprintf(" %s [command]\n", cmd.CommandPath()) + } + help += "\n" + + // Commands + for _, group := range cmd.Groups() { + help += buildGroupCommandHelp(cmd, group) + } + + // Flags + if cmd.HasAvailableLocalFlags() { + help += "Flags:\n" + help += cmd.LocalFlags().FlagUsagesWrapped(120) + help += "\n" + } + + // Usage + help += fmt.Sprintf(`Use "%s help [command]" for more information about a command.`, cmd.CommandPath()) + + _, err := fmt.Fprint(cmd.OutOrStdout(), help) + return err +} + func PrintVersionMessage(cmd *cobra.Command) error { _, err := fmt.Fprintf(cmd.OutOrStdout(), "lets version %s\n", cmd.Version) return err diff --git a/cmd/subcommand.go b/cmd/subcommand.go index df2f87e..759c03f 100644 --- a/cmd/subcommand.go +++ b/cmd/subcommand.go @@ -202,6 +202,9 @@ func newSubcommand(command *config.Command, conf *config.Config, showAll bool, o c.Println(err) } }) + subCmd.Annotations = map[string]string{ + "SubGroupName": command.GroupName, + } return subCmd } diff --git a/config/config/command.go b/config/config/command.go index 148ac0b..68af75b 100644 --- a/config/config/command.go +++ b/config/config/command.go @@ -14,6 +14,7 @@ import ( type Command struct { Name string + GroupName string // Represents a list of commands (scripts) Cmds Cmds // script to run after cmd finished (cleanup, etc) @@ -57,6 +58,7 @@ func (c *Command) UnmarshalYAML(unmarshal func(interface{}) error) error { } var cmd struct { + GroupName string `yaml:"group"` Cmd Cmds Description string Shell string @@ -75,6 +77,12 @@ func (c *Command) UnmarshalYAML(unmarshal func(interface{}) error) error { return err } + if cmd.GroupName != "" { + c.GroupName = cmd.GroupName + } else { + c.GroupName = "Common" + } + c.Cmds = cmd.Cmd c.Description = cmd.Description c.Env = cmd.Env @@ -141,6 +149,7 @@ func (c *Command) GetEnv(cfg Config) (map[string]string, error) { func (c *Command) Clone() *Command { cmd := &Command{ Name: c.Name, + GroupName: c.GroupName, Cmds: c.Cmds.Clone(), After: c.After, Shell: c.Shell, diff --git a/config/config/command_test.go b/config/config/command_test.go new file mode 100644 index 0000000..f02865e --- /dev/null +++ b/config/config/command_test.go @@ -0,0 +1,46 @@ +package config + +import ( + "bytes" + "testing" + + "github.com/lithammer/dedent" + "gopkg.in/yaml.v3" +) + +func CommandFixture(t *testing.T, text string) *Command { + buf := bytes.NewBufferString(text) + c := &Command{} + if err := yaml.NewDecoder(buf).Decode(&c); err != nil { + t.Fatalf("command fixture decode error: %s", err) + } + + return c +} + +func TestParseCommand(t *testing.T) { + t.Run("default group", func(t *testing.T) { + text := dedent.Dedent(` + cmd: [echo, Hello] + `) + command := CommandFixture(t, text) + exp := "Common" + + if command.GroupName != exp { + t.Errorf("wrong output. \nexpect %s \ngot: %s", exp, command.GroupName) + } + }) + + t.Run("provided custom group", func(t *testing.T) { + text := dedent.Dedent(` + group: Group Name + cmd: [echo, Hello] + `) + command := CommandFixture(t, text) + exp := "Group Name" + + if command.GroupName != exp { + t.Errorf("wrong output. \nexpect %s \ngot: %s", exp, command.GroupName) + } + }) +} diff --git a/docs/docs/config.md b/docs/docs/config.md index 517b940..1411343 100644 --- a/docs/docs/config.md +++ b/docs/docs/config.md @@ -31,6 +31,7 @@ title: Config reference - [`persist_checksum`](#persist_checksum) - [`ref`](#ref) - [`args`](#args) + - [`group`](#group) - [Aliasing:](#aliasing) - [Env aliasing](#env-aliasing) @@ -193,7 +194,7 @@ Bar #### Conditional init If you need to make sure that code in `init` is called once with some condition, -you can for example create a file at the end of `init` script and check if this +you can for example create a file at the end of `init` script and check if this file exists at the beginning of `init` script. Example: @@ -871,6 +872,46 @@ commands: `args` is used only with [ref](#ref) and allows to set additional positional args to referenced command. See [ref](#ref) example. +### `group` + +`key: group` + +`type: string` + +Commands can be organized into groups for better readability in the help output. To assign a command to a group, use the `group` key: + +```yaml +commands: + build: + group: Build & Deploy + description: Build the project + cmd: npm run build + + deploy: + group: Build & Deploy + description: Deploy the project + cmd: npm run deploy + + test: + group: Testing + description: Run tests + cmd: npm test +``` + +When you run `lets help`, commands will be listed under their respective groups, making it easier to find related commands. + +``` +Commands: + + Build & Deploy + build Build the project + deploy Deploy the project + + Testing + test Run tests +``` + + ## Aliasing: Lets supports YAML aliasing in various places in the config diff --git a/main.go b/main.go index d8db930..f93c60d 100644 --- a/main.go +++ b/main.go @@ -108,10 +108,10 @@ func main() { os.Exit(0) } - showUsage := rootFlags.help || (command.Name() == "help" && len(args) == 0) + showUsage := rootFlags.help || (command.Name() == "help" && len(args) == 0) || (len(os.Args) == 1) if showUsage { - if err := cmd.PrintHelpMessage(rootCmd); err != nil { + if err := cmd.PrintRootHelpMessage(rootCmd); err != nil { log.Errorf("lets: print help error: %s", err) os.Exit(1) } diff --git a/tests/command_group.bats b/tests/command_group.bats new file mode 100644 index 0000000..3bde50a --- /dev/null +++ b/tests/command_group.bats @@ -0,0 +1,69 @@ +load test_helpers + +setup() { + load "${BATS_UTILS_PATH}/bats-support/load.bash" + load "${BATS_UTILS_PATH}/bats-assert/load.bash" + cd ./tests/command_group +} + +HELP_MESSAGE=$(cat <