core/commandline/shell.go
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"
)