synapsecns/sanguine

View on GitHub
core/commandline/shell.go

Summary

Maintainability
A
1 hr
Test Coverage
package commandline

import (
    "context"
    "fmt"
    "k8s.io/apimachinery/pkg/util/sets"
    "os"
    "os/signal"
    "os/user"
    "strings"
    "syscall"

    "github.com/c-bata/go-prompt"
    "github.com/c-bata/go-prompt/completer"
    "github.com/pkg/errors"
    "github.com/urfave/cli/v2"
)

const shellCommandName = "shell"

// GenerateShellCommand generates the shell command with a list of commands that the shell should take.
// TODO: this needs a more comprehensive test suite.
// TODO: support ctrl+c.
func GenerateShellCommand(shellCommands []*cli.Command) *cli.Command {
    // explicitly exclude shell if included
    capturedCommands := pruneShellCommands(shellCommands)

    // make sure tty is open, this will not be the case in distroless containers
    _, err := syscall.Open("/dev/tty", syscall.O_RDONLY, 0)
    shellAvailable := err == nil

    return &cli.Command{
        Name:  shellCommandName,
        Usage: "start an interactive shell.",
        Flags: []cli.Flag{
            &LogLevel,
        },
        Action: func(c *cli.Context) (err error) {
            SetLogLevel(c)

            console := cli.NewApp()
            console.Commands = capturedCommands
            console.Action = func(c *cli.Context) error {
                fmt.Printf("Command not found. Type 'help' for a list of commands or \"%s\", \"%s\" or \"%s\" to exit.\n", quitCommand, exitCommand, quitCommandShort)
                return nil
            }

            if c.Args().Len() == 0 {
                err := console.RunContext(c.Context, strings.Fields("cmd help"))
                if err != nil {
                    return errors.Wrap(err, "could not show help")
                }
            }

            sigs := make(chan os.Signal)
            ctx, cancel := context.WithCancel(c.Context)
            go func() {
                for sig := range sigs {
                    if sig == syscall.SIGINT || sig == syscall.SIGTERM {
                        cancel()
                        fmt.Printf("\n(type \"%s\", \"%s\" or \"%s\" to exit)\n\n >", quitCommand, exitCommand, quitCommandShort)
                    }
                }
            }()
            signal.Notify(sigs, syscall.SIGINT, syscall.SIGTERM)
            defer func() {
                signal.Stop(sigs)
                close(sigs)
            }()

            if !shellAvailable {
                fmt.Println("Shell is not available in this environment because /dev/tty is not available. This is expected in containerized images")
                return nil
            }

            interactive := newInteractiveClient(ctx, capturedCommands, console)
            for {
                p := prompt.New(
                    interactive.executor,
                    interactive.completor,
                    prompt.OptionCompletionWordSeparator(completer.FilePathCompletionSeparator),
                    prompt.OptionMaxSuggestion(3),
                    prompt.OptionLivePrefix(livePrefix),
                )
                p.Run()
            }
        },
    }
}

// pruneShellCommands gets a list of commands including the shell command.
func pruneShellCommands(commands []*cli.Command) (prunedCommands []*cli.Command) {
    // initialize shell commands
    nameSet := sets.NewString()
    for _, command := range commands {
        if command.Name != shellCommandName {
            prunedCommands = append(prunedCommands, command)
        }
        if !nameSet.Has(command.Name) {
            fmt.Printf("Command %s already exists, skipping\n", command.Name)
        }

        nameSet.Insert(command.Name)
    }

    return prunedCommands
}

// ShellCmd is used to launch an interactive shell. This is useful for repeatedly interacting with the cli or testing.

// livePrefix generates a prefix with the current user directory.
// this is useful for file relative commands like creating a new geth account.
func livePrefix() (prefix string, useLivePrefix bool) {
    pwd, err := os.Getwd()
    useLivePrefix = true
    if err != nil {
        prefix += " $ "
        return
    }

    prefix += " " + pwd + " $ "

    u, err := user.Current()
    if err != nil {
        return
    }

    prefix = strings.ReplaceAll(prefix, u.HomeDir, "~")
    return
}

// interactiveClient object.
type interactiveClient struct {
    // cli app
    app *cli.App
    // ctx is the parent context
    //nolint: containedctx
    ctx context.Context
    // shellCommands are all shell commands supported by the interactive client
    shellCommands []*cli.Command
}

// newInteractiveClient creates a new interactive client.
func newInteractiveClient(ctx context.Context, shellCommands []*cli.Command, app *cli.App) *interactiveClient {
    return &interactiveClient{
        app:           app,
        shellCommands: shellCommands,
        ctx:           ctx,
    }
}

// completor handles autocompletion for the interactive client.
func (i *interactiveClient) completor(in prompt.Document) []prompt.Suggest {
    // commandPrompts are prompts for commands (no flags)
    commandPrompts := []prompt.Suggest{
        {
            Text:        "help",
            Description: "Shows a list of commands or help for one command",
        },
    }

    // flagPrompts promp flags for each command
    var flagPrompts []prompt.Suggest

    for _, command := range i.shellCommands {
        commandPrompts = append(commandPrompts, prompt.Suggest{
            Text:        command.Name,
            Description: command.Usage,
        })

        for _, flag := range command.Flags {
            docFlag, ok := flag.(cli.DocGenerationFlag)
            if ok {
                flagPrompts = append(flagPrompts, prompt.Suggest{
                    Text:        fmt.Sprintf("%s --%s ", command.Name, longestFlag(flag)),
                    Description: docFlag.GetUsage(),
                })
            }
        }
    }

    // get the prompts for the current text. if the user has entered part of a command this will be
    // command prompts. if the user has entered a whole command flags will be suggested
    prompts := prompt.FilterHasPrefix(commandPrompts, in.CurrentLineBeforeCursor(), true)
    if len(prompts) == 0 {
        prompts = prompt.FilterHasPrefix(flagPrompts, in.CurrentLineBeforeCursor(), true)

        // right now, autocomplete for the flags will not take into account the first word so
        // autocompleting `start --config` will return `start start --config`. Here, since we've
        // already done the matching we cut off hte command prefix
        for i, currentPrompt := range prompts {
            splitText := strings.Split(currentPrompt.Text, " ")
            currentPrompt.Text = strings.Join(splitText[1:], " ")
            prompts[i] = currentPrompt
        }
    }

    return prompts
}

// longestFlag gets the longest flag from all flag names
// this is used to avoid short (non-descriptive) flags from coming up in the autocomplete.
func longestFlag(flag cli.Flag) (flagName string) {
    maxLength := 0
    for _, name := range flag.Names() {
        flagLength := len(name)
        if flagLength > maxLength {
            maxLength = flagLength
            flagName = name
        }
    }
    return flagName
}

// executor handles executing interactive commands.
func (i *interactiveClient) executor(line string) {
    if line == "" {
        return
    }

    if line == quitCommand || line == quitCommandShort || line == exitCommand {
        os.Exit(0)
    }

    if line == "shell" {
        fmt.Println("cannot start a shell from within a shell!")
        return
    }

    err := i.app.RunContext(i.ctx, strings.Fields("cmd "+line))
    if err != nil {
        fmt.Println("error: ", err)
    }
}

const (
    quitCommand      = "quit"
    quitCommandShort = "q"
    exitCommand      = "exit"
)