Skip to content

Commit

Permalink
feat: add shell mode (#2433)
Browse files Browse the repository at this point in the history
  • Loading branch information
Codelax authored Sep 14, 2022
1 parent d10397a commit 439341d
Show file tree
Hide file tree
Showing 11 changed files with 405 additions and 0 deletions.
9 changes: 9 additions & 0 deletions cmd/scw/testdata/test-all-usage-shell-usage.golden
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
🎲🎲🎲 EXIT CODE: 0 🎲🎲🎲
🟥🟥🟥 STDERR️️ 🟥🟥🟥️
Start shell mode

USAGE:
scw shell

FLAGS:
-h, --help help for shell
1 change: 1 addition & 0 deletions cmd/scw/testdata/test-main-usage-usage.golden
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ AVAILABLE COMMANDS:
rdb Database RDB API
redis Managed Database for Redis™ API
registry Container registry API
shell Start shell mode
version Display cli version
vpc VPC API
vpc-gw VPC Public Gateway API
Expand Down
6 changes: 6 additions & 0 deletions docs/commands/shell.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
<!-- DO NOT EDIT: this file is automatically generated using scw-doc-gen -->
# Documentation for `scw shell`
Start shell mode



4 changes: 4 additions & 0 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ go 1.17

require (
github.com/alecthomas/assert v0.0.0-20170929043011-405dbfeb8e38
github.com/c-bata/go-prompt v0.2.5
github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e
github.com/containerd/console v1.0.3
github.com/dnaeon/go-vcr v1.2.0
Expand Down Expand Up @@ -35,7 +36,10 @@ require (
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/etdub/goparsetime v0.0.0-20160315173935-ea17b0ac3318 // indirect
github.com/inconshreveable/mousetrap v1.0.0 // indirect
github.com/mattn/go-runewidth v0.0.9 // indirect
github.com/mattn/go-tty v0.0.3 // indirect
github.com/pkg/errors v0.8.1 // indirect
github.com/pkg/term v1.1.0 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/sergi/go-diff v1.2.0 // indirect
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab // indirect
Expand Down
19 changes: 19 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ github.com/alecthomas/colour v0.1.0 h1:nOE9rJm6dsZ66RGWYSFrXw461ZIt9A6+nHgL7FRrD
github.com/alecthomas/colour v0.1.0/go.mod h1:QO9JBoKquHd+jz9nshCh40fOfO+JzsoXy8qTHF68zU0=
github.com/alecthomas/repr v0.0.0-20210801044451-80ca428c5142 h1:8Uy0oSf5co/NZXje7U1z8Mpep++QJOldL2hs/sBQf48=
github.com/alecthomas/repr v0.0.0-20210801044451-80ca428c5142/go.mod h1:2kn6fqh/zIyPLmm3ugklbEi5hg5wS435eygvNfaDQL8=
github.com/c-bata/go-prompt v0.2.5 h1:3zg6PecEywxNn0xiqcXHD96fkbxghD+gdB2tbsYfl+Y=
github.com/c-bata/go-prompt v0.2.5/go.mod h1:vFnjEGDIIA/Lib7giyE4E9c50Lvl8j0S+7FVlAwDAVw=
github.com/certifi/gocertifi v0.0.0-20210507211836-431795d63e8d h1:S2NE3iHSwP0XV47EEXL8mWmRdEfGscSJ+7EgePNgt0s=
github.com/certifi/gocertifi v0.0.0-20210507211836-431795d63e8d/go.mod h1:sGbDF6GwGcLpkNXPUTkMRoywsNa/ol15pxFe6ERfguA=
github.com/chzyer/logex v1.2.0 h1:+eqR0HfOetur4tgnC8ftU5imRnhi4te+BadWS95c5AM=
Expand Down Expand Up @@ -45,16 +47,27 @@ github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE=
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
github.com/kubernetes-client/go-base v0.0.0-20190205182333-3d0e39759d98 h1:ZMIkOkl/Bg5H4EJI7zbjVXAo4rV0QJOGz2U5A0xUmZU=
github.com/kubernetes-client/go-base v0.0.0-20190205182333-3d0e39759d98/go.mod h1:HPlr4uJEfrxar3JUY9cmXs3oooPjTLO6nEaEAIt5LI8=
github.com/mattn/go-colorable v0.1.4/go.mod h1:U0ppj6V5qS13XJ6of8GYAs25YV2eR4EVcfRqFIhoBtE=
github.com/mattn/go-colorable v0.1.7/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc=
github.com/mattn/go-colorable v0.1.9/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc=
github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
github.com/mattn/go-isatty v0.0.8/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s=
github.com/mattn/go-isatty v0.0.10/go.mod h1:qgIWMr58cqv1PHHyhnkY9lrL7etaEgOFcMEpPG5Rm84=
github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU=
github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94=
github.com/mattn/go-isatty v0.0.16 h1:bq3VjFmv/sOjHtdEhmkEV4x1AJtvUvOJ2PFAZ5+peKQ=
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
github.com/mattn/go-runewidth v0.0.6/go.mod h1:H031xJmbD/WCDINGzjvQ9THkh0rPKHF+m2gUSrubnMI=
github.com/mattn/go-runewidth v0.0.9 h1:Lm995f3rfxdpd6TSmuVCHVb/QhupuXlYr8sCI/QdE+0=
github.com/mattn/go-runewidth v0.0.9/go.mod h1:H031xJmbD/WCDINGzjvQ9THkh0rPKHF+m2gUSrubnMI=
github.com/mattn/go-tty v0.0.3 h1:5OfyWorkyO7xP52Mq7tB36ajHDG5OHrmBGIS/DtakQI=
github.com/mattn/go-tty v0.0.3/go.mod h1:ihxohKRERHTVzN+aSVRwACLCeqIoZAWpoICkkvrWyR0=
github.com/modocache/gover v0.0.0-20171022184752-b58185e213c5/go.mod h1:caMODM3PzxT8aQXRPkAt8xlV/e7d7w8GM5g0fa5F0D8=
github.com/pkg/errors v0.8.1 h1:iURUrRGxPUNPdy5/HRSm+Yj6okJ6UtLINN0Q9M4+h3I=
github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pkg/term v1.1.0 h1:xIAAdCMh3QIAy+5FrE8Ad8XoDhEU4ufwbaSozViP9kk=
github.com/pkg/term v1.1.0/go.mod h1:E25nymQcrSllhX42Ok8MRm1+hyBdHY0dCeiKZ9jpNGw=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
Expand All @@ -72,8 +85,14 @@ github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81P
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.0 h1:pSgiaMZlXftHpm5L7V1+rVB+AZJydKsMxsQBIJw4PKk=
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sys v0.0.0-20190222072716-a9d3bda3a223/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20191008105621-543471e840be/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20191120155948-bd437916bb0e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200223170610-d5e6a3e2c0ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200909081042-eff7692f9009/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200918174421-af09f7315aff/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
Expand Down
6 changes: 6 additions & 0 deletions internal/core/bootstrap.go
Original file line number Diff line number Diff line change
Expand Up @@ -194,6 +194,12 @@ func Bootstrap(config *BootstrapConfig) (exitCode int, result interface{}, err e

rootCmd := builder.build()

// ShellMode
if len(config.Args) >= 2 && config.Args[1] == "shell" {
RunShell(ctx, printer, meta, rootCmd, config.Args)
return 0, meta.result, nil
}

// These flag are already handle at the beginning of this function but we keep this
// declaration in order for them to be shown in the cobra usage documentation.
rootCmd.PersistentFlags().StringVarP(&profileFlag, "profile", "p", "", "The config profile to use")
Expand Down
8 changes: 8 additions & 0 deletions internal/core/command.go
Original file line number Diff line number Diff line change
Expand Up @@ -182,6 +182,14 @@ func (c *Commands) MustFind(path ...string) *Command {
panic(fmt.Errorf("command %v not found", strings.Join(path, " ")))
}

func (c *Commands) Remove(namespace, verb string) {
for i := range c.commands {
if c.commands[i].Namespace == namespace && c.commands[i].Verb == verb {
c.commands = append(c.commands[:i], c.commands[i+1:]...)
}
}
}

func (c *Commands) Add(cmd *Command) {
c.commands = append(c.commands, cmd)
c.commandIndex[cmd.getPath()] = cmd
Expand Down
274 changes: 274 additions & 0 deletions internal/core/shell.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,274 @@
package core

import (
"context"
"fmt"
"os"
"sort"
"strconv"
"strings"

"github.com/c-bata/go-prompt"
"github.com/scaleway/scaleway-cli/v2/internal/interactive"
"github.com/spf13/cobra"
)

type Completer struct {
ctx context.Context
}

type ShellSuggestion struct {
Text string
Arg *ArgSpec
Cmd *Command
}

// lastArg returns last element of string or empty string
func lastArg(args []string) string {
l := len(args)
if l >= 2 {
return args[l-1]
}
if l == 1 {
return args[0]
}
return ""
}

// firstArg returns first element of list or empty string
func firstArg(args []string) string {
l := len(args)
if l >= 1 {
return args[0]
}
return ""
}

// trimLastArg returns all arguments but the last one
// return a nil slice if there is no previous arguments
func trimLastArg(args []string) []string {
l := len(args)
if l > 1 {
return args[:l-1]
}
return []string(nil)
}

// argIsOption returns if an argument is an option
func argIsOption(arg string) bool {
return strings.Contains(arg, "=") || strings.Contains(arg, ".")
}

// removeOptions removes options from a list of argument
// ex: scw instance create name=myserver
// will be changed to: scw instance server create
func removeOptions(args []string) []string {
filteredArgs := []string(nil)
for _, arg := range args {
if !argIsOption(arg) {
filteredArgs = append(filteredArgs, arg)
}
}
return filteredArgs
}

// optionToArgSpecName convert option to arg spec name
// from additional-volumes.0=hello to additional-volumes.{index}
// also with multiple indexes pools.0.kubelet-args. to pools.{index}.kubelet-args.{key}
func optionToArgSpecName(option string) string {
optionName := strings.Split(option, "=")[0]

if strings.Contains(optionName, ".") {
// If option is formatted like "additional-volumes.0"
// it should be converted to "additional-volumes.{index}
elems := strings.Split(optionName, ".")
for i := range elems {
_, err := strconv.Atoi(elems[i])
if err == nil {
elems[i] = "{index}"
}
}
if elems[len(elems)-1] == "" {
elems[len(elems)-1] = "{key}"
}
optionName = strings.Join(elems, ".")
}
return optionName
}

// getCommand return command object from args and suggest
func getCommand(meta *meta, args []string, suggest string) *Command {
rawCommand := removeOptions(args)
suggestIsOption := argIsOption(suggest)

if !suggestIsOption {
rawCommand = append(rawCommand, suggest)
}

command, foundCommand := meta.Commands.find(rawCommand...)
if foundCommand {
return command
}
return nil
}

// getSuggestDescription will return suggest description
// it will return command description if it is a command
// or option description if suggest is an option of a command
func getSuggestDescription(meta *meta, args []string, suggest string) string {
isOption := argIsOption(suggest)

command := getCommand(meta, args, suggest)
if command == nil {
return "command not found"
}

if isOption {
option := command.ArgSpecs.GetByName(optionToArgSpecName(suggest))
if option != nil {
return option.Short
}
} else {
return command.Short
}

return ""
}

// sortOptions sorts options, putting required first then order alphabetically
func sortOptions(meta *meta, args []string, toSuggest string, suggestions []string) []string {
command := getCommand(meta, args, toSuggest)
if command == nil {
return suggestions
}

argSpecs := []ShellSuggestion(nil)
for _, suggest := range suggestions {
argSpec := command.ArgSpecs.GetByName(optionToArgSpecName(suggest))
argSpecs = append(argSpecs, ShellSuggestion{
Text: suggest,
Arg: argSpec,
})
}

sort.Slice(argSpecs, func(i, j int) bool {
if argSpecs[i].Arg.Required != argSpecs[j].Arg.Required {
return argSpecs[i].Arg.Required
}
return argSpecs[i].Text < argSpecs[j].Text
})

suggests := []string(nil)
for _, argSpec := range argSpecs {
suggests = append(suggests, argSpec.Text)
}

return suggests
}

// Complete returns the list of suggestion based on prompt content
func (c *Completer) Complete(d prompt.Document) []prompt.Suggest {
argsBeforeCursor := strings.Split(d.TextBeforeCursor(), " ")
argsAfterCursor := strings.Split(d.TextAfterCursor(), " ")
currentArg := lastArg(argsBeforeCursor) + firstArg(argsAfterCursor)

// args contains all arguments before the one with the cursor
args := trimLastArg(argsBeforeCursor)

acr := AutoComplete(c.ctx, append([]string{"scw"}, args...), currentArg, argsAfterCursor)

suggestions := []prompt.Suggest(nil)

meta := extractMeta(c.ctx)
rawSuggestions := []string(acr.Suggestions)

// if first suggestion is an option, all suggestions should be options
// we sort them
if len(rawSuggestions) > 0 && argIsOption(rawSuggestions[0]) {
rawSuggestions = sortOptions(meta, args, rawSuggestions[0], rawSuggestions)
}

for _, suggest := range rawSuggestions {
suggestions = append(suggestions, prompt.Suggest{
Text: suggest,
Description: getSuggestDescription(meta, args, suggest),
})
}

return prompt.FilterHasPrefix(suggestions, currentArg, true)
}

func NewShellCompleter(ctx context.Context) *Completer {
return &Completer{
ctx: ctx,
}
}

// shellExecutor returns the function that will execute command entered in shell
func shellExecutor(rootCmd *cobra.Command, printer *Printer, meta *meta) func(s string) {
return func(s string) {
args := strings.Fields(s)
rootCmd.SetArgs(args)

err := rootCmd.Execute()
if err != nil {
if _, ok := err.(*interactive.InterruptError); ok {
return
}

printErr := printer.Print(err, nil)
if printErr != nil {
_, _ = fmt.Fprintln(os.Stderr, err)
}

return
}

printErr := printer.Print(meta.result, meta.command.getHumanMarshalerOpt())
if printErr != nil {
_, _ = fmt.Fprintln(os.Stderr, printErr)
}
}
}

// Return the shell subcommand
func getShellCommand(rootCmd *cobra.Command) *cobra.Command {
subcommands := rootCmd.Commands()
for _, command := range subcommands {
if command.Name() == "shell" {
return command
}
}
return nil
}

// RunShell will run an interactive shell that runs cobra commands
func RunShell(ctx context.Context, printer *Printer, meta *meta, rootCmd *cobra.Command, args []string) {
completer := NewShellCompleter(ctx)

shellCobraCommand := getShellCommand(rootCmd)
shellCobraCommand.InitDefaultHelpFlag()
_ = shellCobraCommand.ParseFlags(args)
if isHelp, _ := shellCobraCommand.Flags().GetBool("help"); isHelp {
shellCobraCommand.HelpFunc()(shellCobraCommand, args)
return
}

// remove shell command so it cannot be called from shell
rootCmd.RemoveCommand(shellCobraCommand)
meta.Commands.Remove("shell", "")

executor := shellExecutor(rootCmd, printer, meta)
p := prompt.New(
executor,
completer.Complete,
prompt.OptionPrefix(">>>"),
prompt.OptionSuggestionBGColor(prompt.Purple),
prompt.OptionSelectedSuggestionBGColor(prompt.Fuchsia),
prompt.OptionSelectedSuggestionTextColor(prompt.White),
prompt.OptionDescriptionBGColor(prompt.Purple),
prompt.OptionSelectedDescriptionBGColor(prompt.Fuchsia),
prompt.OptionSelectedDescriptionTextColor(prompt.White),
)
p.Run()
}
Loading

0 comments on commit 439341d

Please sign in to comment.