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
90 changes: 90 additions & 0 deletions cmd/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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 {
Copy link
Collaborator

Choose a reason for hiding this comment

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

Looks like if lets executed as is (without help command) there will be no groups in the output

Copy link
Contributor Author

Choose a reason for hiding this comment

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

lets/main.go

Line 111 in 3390a5d

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

fixed with (len(os.Args) == 1) and added tests

@test "help: running 'lets help' should group commands by their group names" {
run lets help
assert_success
assert_output "$HELP_MESSAGE"
}
@test "help: running 'lets --help' should group commands by their group names" {
run lets --help
assert_success
assert_output "$HELP_MESSAGE"
}
@test "help: running 'lets' should group commands by their group names" {
run lets
assert_success
assert_output "$HELP_MESSAGE"
}

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
Expand Down
3 changes: 3 additions & 0 deletions cmd/subcommand.go
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Comment on lines +205 to +206
Copy link

Choose a reason for hiding this comment

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

issue (bug_risk): Assigning a new Annotations map may clobber existing annotations on the command.

This overwrites any existing subCmd.Annotations set elsewhere (now or in future), which can lead to unexpected behavior. Instead, only initialize when nil and then set the key, e.g.:

if subCmd.Annotations == nil {
    subCmd.Annotations = map[string]string{}
}
subCmd.Annotations["SubGroupName"] = command.GroupName

}
Comment on lines +205 to +207
Copy link

Choose a reason for hiding this comment

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

suggestion (bug_risk): Overwriting subCmd.Annotations may discard existing annotations.

If subCmd may already have annotations (now or later), this assignment will discard them. Instead, ensure subCmd.Annotations is initialized if nil, then set subCmd.Annotations["SubGroupName"] = command.GroupName so existing entries are preserved.

Suggested change
subCmd.Annotations = map[string]string{
"SubGroupName": command.GroupName,
}
if subCmd.Annotations == nil {
subCmd.Annotations = make(map[string]string)
}
subCmd.Annotations["SubGroupName"] = command.GroupName


return subCmd
}
Expand Down
9 changes: 9 additions & 0 deletions config/config/command.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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,
Expand Down
46 changes: 46 additions & 0 deletions config/config/command_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
})
}
43 changes: 42 additions & 1 deletion docs/docs/config.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ title: Config reference
- [`persist_checksum`](#persist_checksum)
- [`ref`](#ref)
- [`args`](#args)
- [`group`](#group)
- [Aliasing:](#aliasing)
- [Env aliasing](#env-aliasing)

Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand Down
69 changes: 69 additions & 0 deletions tests/command_group.bats
Original file line number Diff line number Diff line change
@@ -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 <<EOF
A CLI task runner

Usage:
lets [flags]
lets [command]

Commands:

A group
c c command

B group
a a command
b b command

Common
d d command

Internal commands:
help Help about any command
self Manage lets CLI itself

Flags:
--all show all commands (including the ones with _)
-c, --config string config file (default is lets.yaml)
-d, --debug count show debug logs (or use LETS_DEBUG=1). If used multiple times, shows more verbose logs
-E, --env stringToString set env variable for running command KEY=VALUE (default [])
--exclude stringArray run all but excluded command(s) described in cmd as map
-h, --help help for lets
--init create a new lets.yaml in the current folder
--no-depends skip 'depends' for running command
--only stringArray run only specified command(s) described in cmd as map
--upgrade upgrade lets to latest version
-v, --version version for lets

Use "lets help [command]" for more information about a command.
EOF
)


@test "help: running 'lets help' should group commands by their group names" {
run lets help
assert_success

assert_output "$HELP_MESSAGE"
}

@test "help: running 'lets --help' should group commands by their group names" {
run lets --help
assert_success

assert_output "$HELP_MESSAGE"
}

@test "help: running 'lets' should group commands by their group names" {
run lets
assert_success

assert_output "$HELP_MESSAGE"
}
21 changes: 21 additions & 0 deletions tests/command_group/lets.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
shell: bash

commands:
b:
group: B group
description: b command
cmd: echo

a:
group: B group
description: a command
cmd: echo

c:
group: A group
description: c command
cmd: echo

d:
description: d command
cmd: echo