Skip to content

Commit 439341d

Browse files
authored
feat: add shell mode (#2433)
1 parent d10397a commit 439341d

File tree

11 files changed

+405
-0
lines changed

11 files changed

+405
-0
lines changed
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
🎲🎲🎲 EXIT CODE: 0 🎲🎲🎲
2+
🟥🟥🟥 STDERR️️ 🟥🟥🟥️
3+
Start shell mode
4+
5+
USAGE:
6+
scw shell
7+
8+
FLAGS:
9+
-h, --help help for shell

cmd/scw/testdata/test-main-usage-usage.golden

+1
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ AVAILABLE COMMANDS:
2626
rdb Database RDB API
2727
redis Managed Database for Redis™ API
2828
registry Container registry API
29+
shell Start shell mode
2930
version Display cli version
3031
vpc VPC API
3132
vpc-gw VPC Public Gateway API

docs/commands/shell.md

+6
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
<!-- DO NOT EDIT: this file is automatically generated using scw-doc-gen -->
2+
# Documentation for `scw shell`
3+
Start shell mode
4+
5+
6+

go.mod

+4
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ go 1.17
44

55
require (
66
github.com/alecthomas/assert v0.0.0-20170929043011-405dbfeb8e38
7+
github.com/c-bata/go-prompt v0.2.5
78
github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e
89
github.com/containerd/console v1.0.3
910
github.com/dnaeon/go-vcr v1.2.0
@@ -35,7 +36,10 @@ require (
3536
github.com/davecgh/go-spew v1.1.1 // indirect
3637
github.com/etdub/goparsetime v0.0.0-20160315173935-ea17b0ac3318 // indirect
3738
github.com/inconshreveable/mousetrap v1.0.0 // indirect
39+
github.com/mattn/go-runewidth v0.0.9 // indirect
40+
github.com/mattn/go-tty v0.0.3 // indirect
3841
github.com/pkg/errors v0.8.1 // indirect
42+
github.com/pkg/term v1.1.0 // indirect
3943
github.com/pmezard/go-difflib v1.0.0 // indirect
4044
github.com/sergi/go-diff v1.2.0 // indirect
4145
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab // indirect

go.sum

+19
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@ github.com/alecthomas/colour v0.1.0 h1:nOE9rJm6dsZ66RGWYSFrXw461ZIt9A6+nHgL7FRrD
44
github.com/alecthomas/colour v0.1.0/go.mod h1:QO9JBoKquHd+jz9nshCh40fOfO+JzsoXy8qTHF68zU0=
55
github.com/alecthomas/repr v0.0.0-20210801044451-80ca428c5142 h1:8Uy0oSf5co/NZXje7U1z8Mpep++QJOldL2hs/sBQf48=
66
github.com/alecthomas/repr v0.0.0-20210801044451-80ca428c5142/go.mod h1:2kn6fqh/zIyPLmm3ugklbEi5hg5wS435eygvNfaDQL8=
7+
github.com/c-bata/go-prompt v0.2.5 h1:3zg6PecEywxNn0xiqcXHD96fkbxghD+gdB2tbsYfl+Y=
8+
github.com/c-bata/go-prompt v0.2.5/go.mod h1:vFnjEGDIIA/Lib7giyE4E9c50Lvl8j0S+7FVlAwDAVw=
79
github.com/certifi/gocertifi v0.0.0-20210507211836-431795d63e8d h1:S2NE3iHSwP0XV47EEXL8mWmRdEfGscSJ+7EgePNgt0s=
810
github.com/certifi/gocertifi v0.0.0-20210507211836-431795d63e8d/go.mod h1:sGbDF6GwGcLpkNXPUTkMRoywsNa/ol15pxFe6ERfguA=
911
github.com/chzyer/logex v1.2.0 h1:+eqR0HfOetur4tgnC8ftU5imRnhi4te+BadWS95c5AM=
@@ -45,16 +47,27 @@ github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE=
4547
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
4648
github.com/kubernetes-client/go-base v0.0.0-20190205182333-3d0e39759d98 h1:ZMIkOkl/Bg5H4EJI7zbjVXAo4rV0QJOGz2U5A0xUmZU=
4749
github.com/kubernetes-client/go-base v0.0.0-20190205182333-3d0e39759d98/go.mod h1:HPlr4uJEfrxar3JUY9cmXs3oooPjTLO6nEaEAIt5LI8=
50+
github.com/mattn/go-colorable v0.1.4/go.mod h1:U0ppj6V5qS13XJ6of8GYAs25YV2eR4EVcfRqFIhoBtE=
51+
github.com/mattn/go-colorable v0.1.7/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc=
4852
github.com/mattn/go-colorable v0.1.9/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc=
4953
github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
5054
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
55+
github.com/mattn/go-isatty v0.0.8/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s=
56+
github.com/mattn/go-isatty v0.0.10/go.mod h1:qgIWMr58cqv1PHHyhnkY9lrL7etaEgOFcMEpPG5Rm84=
5157
github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU=
5258
github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94=
5359
github.com/mattn/go-isatty v0.0.16 h1:bq3VjFmv/sOjHtdEhmkEV4x1AJtvUvOJ2PFAZ5+peKQ=
5460
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
61+
github.com/mattn/go-runewidth v0.0.6/go.mod h1:H031xJmbD/WCDINGzjvQ9THkh0rPKHF+m2gUSrubnMI=
62+
github.com/mattn/go-runewidth v0.0.9 h1:Lm995f3rfxdpd6TSmuVCHVb/QhupuXlYr8sCI/QdE+0=
63+
github.com/mattn/go-runewidth v0.0.9/go.mod h1:H031xJmbD/WCDINGzjvQ9THkh0rPKHF+m2gUSrubnMI=
64+
github.com/mattn/go-tty v0.0.3 h1:5OfyWorkyO7xP52Mq7tB36ajHDG5OHrmBGIS/DtakQI=
65+
github.com/mattn/go-tty v0.0.3/go.mod h1:ihxohKRERHTVzN+aSVRwACLCeqIoZAWpoICkkvrWyR0=
5566
github.com/modocache/gover v0.0.0-20171022184752-b58185e213c5/go.mod h1:caMODM3PzxT8aQXRPkAt8xlV/e7d7w8GM5g0fa5F0D8=
5667
github.com/pkg/errors v0.8.1 h1:iURUrRGxPUNPdy5/HRSm+Yj6okJ6UtLINN0Q9M4+h3I=
5768
github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
69+
github.com/pkg/term v1.1.0 h1:xIAAdCMh3QIAy+5FrE8Ad8XoDhEU4ufwbaSozViP9kk=
70+
github.com/pkg/term v1.1.0/go.mod h1:E25nymQcrSllhX42Ok8MRm1+hyBdHY0dCeiKZ9jpNGw=
5871
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
5972
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
6073
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
@@ -72,8 +85,14 @@ github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81P
7285
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
7386
github.com/stretchr/testify v1.8.0 h1:pSgiaMZlXftHpm5L7V1+rVB+AZJydKsMxsQBIJw4PKk=
7487
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
88+
golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
89+
golang.org/x/sys v0.0.0-20190222072716-a9d3bda3a223/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
90+
golang.org/x/sys v0.0.0-20191008105621-543471e840be/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
91+
golang.org/x/sys v0.0.0-20191120155948-bd437916bb0e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
7592
golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
7693
golang.org/x/sys v0.0.0-20200223170610-d5e6a3e2c0ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
94+
golang.org/x/sys v0.0.0-20200909081042-eff7692f9009/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
95+
golang.org/x/sys v0.0.0-20200918174421-af09f7315aff/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
7796
golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
7897
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
7998
golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=

internal/core/bootstrap.go

+6
Original file line numberDiff line numberDiff line change
@@ -194,6 +194,12 @@ func Bootstrap(config *BootstrapConfig) (exitCode int, result interface{}, err e
194194

195195
rootCmd := builder.build()
196196

197+
// ShellMode
198+
if len(config.Args) >= 2 && config.Args[1] == "shell" {
199+
RunShell(ctx, printer, meta, rootCmd, config.Args)
200+
return 0, meta.result, nil
201+
}
202+
197203
// These flag are already handle at the beginning of this function but we keep this
198204
// declaration in order for them to be shown in the cobra usage documentation.
199205
rootCmd.PersistentFlags().StringVarP(&profileFlag, "profile", "p", "", "The config profile to use")

internal/core/command.go

+8
Original file line numberDiff line numberDiff line change
@@ -182,6 +182,14 @@ func (c *Commands) MustFind(path ...string) *Command {
182182
panic(fmt.Errorf("command %v not found", strings.Join(path, " ")))
183183
}
184184

185+
func (c *Commands) Remove(namespace, verb string) {
186+
for i := range c.commands {
187+
if c.commands[i].Namespace == namespace && c.commands[i].Verb == verb {
188+
c.commands = append(c.commands[:i], c.commands[i+1:]...)
189+
}
190+
}
191+
}
192+
185193
func (c *Commands) Add(cmd *Command) {
186194
c.commands = append(c.commands, cmd)
187195
c.commandIndex[cmd.getPath()] = cmd

internal/core/shell.go

+274
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,274 @@
1+
package core
2+
3+
import (
4+
"context"
5+
"fmt"
6+
"os"
7+
"sort"
8+
"strconv"
9+
"strings"
10+
11+
"github.com/c-bata/go-prompt"
12+
"github.com/scaleway/scaleway-cli/v2/internal/interactive"
13+
"github.com/spf13/cobra"
14+
)
15+
16+
type Completer struct {
17+
ctx context.Context
18+
}
19+
20+
type ShellSuggestion struct {
21+
Text string
22+
Arg *ArgSpec
23+
Cmd *Command
24+
}
25+
26+
// lastArg returns last element of string or empty string
27+
func lastArg(args []string) string {
28+
l := len(args)
29+
if l >= 2 {
30+
return args[l-1]
31+
}
32+
if l == 1 {
33+
return args[0]
34+
}
35+
return ""
36+
}
37+
38+
// firstArg returns first element of list or empty string
39+
func firstArg(args []string) string {
40+
l := len(args)
41+
if l >= 1 {
42+
return args[0]
43+
}
44+
return ""
45+
}
46+
47+
// trimLastArg returns all arguments but the last one
48+
// return a nil slice if there is no previous arguments
49+
func trimLastArg(args []string) []string {
50+
l := len(args)
51+
if l > 1 {
52+
return args[:l-1]
53+
}
54+
return []string(nil)
55+
}
56+
57+
// argIsOption returns if an argument is an option
58+
func argIsOption(arg string) bool {
59+
return strings.Contains(arg, "=") || strings.Contains(arg, ".")
60+
}
61+
62+
// removeOptions removes options from a list of argument
63+
// ex: scw instance create name=myserver
64+
// will be changed to: scw instance server create
65+
func removeOptions(args []string) []string {
66+
filteredArgs := []string(nil)
67+
for _, arg := range args {
68+
if !argIsOption(arg) {
69+
filteredArgs = append(filteredArgs, arg)
70+
}
71+
}
72+
return filteredArgs
73+
}
74+
75+
// optionToArgSpecName convert option to arg spec name
76+
// from additional-volumes.0=hello to additional-volumes.{index}
77+
// also with multiple indexes pools.0.kubelet-args. to pools.{index}.kubelet-args.{key}
78+
func optionToArgSpecName(option string) string {
79+
optionName := strings.Split(option, "=")[0]
80+
81+
if strings.Contains(optionName, ".") {
82+
// If option is formatted like "additional-volumes.0"
83+
// it should be converted to "additional-volumes.{index}
84+
elems := strings.Split(optionName, ".")
85+
for i := range elems {
86+
_, err := strconv.Atoi(elems[i])
87+
if err == nil {
88+
elems[i] = "{index}"
89+
}
90+
}
91+
if elems[len(elems)-1] == "" {
92+
elems[len(elems)-1] = "{key}"
93+
}
94+
optionName = strings.Join(elems, ".")
95+
}
96+
return optionName
97+
}
98+
99+
// getCommand return command object from args and suggest
100+
func getCommand(meta *meta, args []string, suggest string) *Command {
101+
rawCommand := removeOptions(args)
102+
suggestIsOption := argIsOption(suggest)
103+
104+
if !suggestIsOption {
105+
rawCommand = append(rawCommand, suggest)
106+
}
107+
108+
command, foundCommand := meta.Commands.find(rawCommand...)
109+
if foundCommand {
110+
return command
111+
}
112+
return nil
113+
}
114+
115+
// getSuggestDescription will return suggest description
116+
// it will return command description if it is a command
117+
// or option description if suggest is an option of a command
118+
func getSuggestDescription(meta *meta, args []string, suggest string) string {
119+
isOption := argIsOption(suggest)
120+
121+
command := getCommand(meta, args, suggest)
122+
if command == nil {
123+
return "command not found"
124+
}
125+
126+
if isOption {
127+
option := command.ArgSpecs.GetByName(optionToArgSpecName(suggest))
128+
if option != nil {
129+
return option.Short
130+
}
131+
} else {
132+
return command.Short
133+
}
134+
135+
return ""
136+
}
137+
138+
// sortOptions sorts options, putting required first then order alphabetically
139+
func sortOptions(meta *meta, args []string, toSuggest string, suggestions []string) []string {
140+
command := getCommand(meta, args, toSuggest)
141+
if command == nil {
142+
return suggestions
143+
}
144+
145+
argSpecs := []ShellSuggestion(nil)
146+
for _, suggest := range suggestions {
147+
argSpec := command.ArgSpecs.GetByName(optionToArgSpecName(suggest))
148+
argSpecs = append(argSpecs, ShellSuggestion{
149+
Text: suggest,
150+
Arg: argSpec,
151+
})
152+
}
153+
154+
sort.Slice(argSpecs, func(i, j int) bool {
155+
if argSpecs[i].Arg.Required != argSpecs[j].Arg.Required {
156+
return argSpecs[i].Arg.Required
157+
}
158+
return argSpecs[i].Text < argSpecs[j].Text
159+
})
160+
161+
suggests := []string(nil)
162+
for _, argSpec := range argSpecs {
163+
suggests = append(suggests, argSpec.Text)
164+
}
165+
166+
return suggests
167+
}
168+
169+
// Complete returns the list of suggestion based on prompt content
170+
func (c *Completer) Complete(d prompt.Document) []prompt.Suggest {
171+
argsBeforeCursor := strings.Split(d.TextBeforeCursor(), " ")
172+
argsAfterCursor := strings.Split(d.TextAfterCursor(), " ")
173+
currentArg := lastArg(argsBeforeCursor) + firstArg(argsAfterCursor)
174+
175+
// args contains all arguments before the one with the cursor
176+
args := trimLastArg(argsBeforeCursor)
177+
178+
acr := AutoComplete(c.ctx, append([]string{"scw"}, args...), currentArg, argsAfterCursor)
179+
180+
suggestions := []prompt.Suggest(nil)
181+
182+
meta := extractMeta(c.ctx)
183+
rawSuggestions := []string(acr.Suggestions)
184+
185+
// if first suggestion is an option, all suggestions should be options
186+
// we sort them
187+
if len(rawSuggestions) > 0 && argIsOption(rawSuggestions[0]) {
188+
rawSuggestions = sortOptions(meta, args, rawSuggestions[0], rawSuggestions)
189+
}
190+
191+
for _, suggest := range rawSuggestions {
192+
suggestions = append(suggestions, prompt.Suggest{
193+
Text: suggest,
194+
Description: getSuggestDescription(meta, args, suggest),
195+
})
196+
}
197+
198+
return prompt.FilterHasPrefix(suggestions, currentArg, true)
199+
}
200+
201+
func NewShellCompleter(ctx context.Context) *Completer {
202+
return &Completer{
203+
ctx: ctx,
204+
}
205+
}
206+
207+
// shellExecutor returns the function that will execute command entered in shell
208+
func shellExecutor(rootCmd *cobra.Command, printer *Printer, meta *meta) func(s string) {
209+
return func(s string) {
210+
args := strings.Fields(s)
211+
rootCmd.SetArgs(args)
212+
213+
err := rootCmd.Execute()
214+
if err != nil {
215+
if _, ok := err.(*interactive.InterruptError); ok {
216+
return
217+
}
218+
219+
printErr := printer.Print(err, nil)
220+
if printErr != nil {
221+
_, _ = fmt.Fprintln(os.Stderr, err)
222+
}
223+
224+
return
225+
}
226+
227+
printErr := printer.Print(meta.result, meta.command.getHumanMarshalerOpt())
228+
if printErr != nil {
229+
_, _ = fmt.Fprintln(os.Stderr, printErr)
230+
}
231+
}
232+
}
233+
234+
// Return the shell subcommand
235+
func getShellCommand(rootCmd *cobra.Command) *cobra.Command {
236+
subcommands := rootCmd.Commands()
237+
for _, command := range subcommands {
238+
if command.Name() == "shell" {
239+
return command
240+
}
241+
}
242+
return nil
243+
}
244+
245+
// RunShell will run an interactive shell that runs cobra commands
246+
func RunShell(ctx context.Context, printer *Printer, meta *meta, rootCmd *cobra.Command, args []string) {
247+
completer := NewShellCompleter(ctx)
248+
249+
shellCobraCommand := getShellCommand(rootCmd)
250+
shellCobraCommand.InitDefaultHelpFlag()
251+
_ = shellCobraCommand.ParseFlags(args)
252+
if isHelp, _ := shellCobraCommand.Flags().GetBool("help"); isHelp {
253+
shellCobraCommand.HelpFunc()(shellCobraCommand, args)
254+
return
255+
}
256+
257+
// remove shell command so it cannot be called from shell
258+
rootCmd.RemoveCommand(shellCobraCommand)
259+
meta.Commands.Remove("shell", "")
260+
261+
executor := shellExecutor(rootCmd, printer, meta)
262+
p := prompt.New(
263+
executor,
264+
completer.Complete,
265+
prompt.OptionPrefix(">>>"),
266+
prompt.OptionSuggestionBGColor(prompt.Purple),
267+
prompt.OptionSelectedSuggestionBGColor(prompt.Fuchsia),
268+
prompt.OptionSelectedSuggestionTextColor(prompt.White),
269+
prompt.OptionDescriptionBGColor(prompt.Purple),
270+
prompt.OptionSelectedDescriptionBGColor(prompt.Fuchsia),
271+
prompt.OptionSelectedDescriptionTextColor(prompt.White),
272+
)
273+
p.Run()
274+
}

0 commit comments

Comments
 (0)