-
Notifications
You must be signed in to change notification settings - Fork 32
/
shell.go
240 lines (206 loc) · 6.54 KB
/
shell.go
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
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"
)