hookdeck-cli 0.6.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (61) hide show
  1. package/.github/workflows/publish-npm.yml +19 -0
  2. package/.github/workflows/release.yml +85 -0
  3. package/.goreleaser/linux.yml +68 -0
  4. package/.goreleaser/mac.yml +69 -0
  5. package/.goreleaser/windows.yml +52 -0
  6. package/.tool-versions +1 -0
  7. package/Dockerfile +5 -0
  8. package/LICENSE +202 -0
  9. package/README.md +223 -0
  10. package/docs/cli-demo.gif +0 -0
  11. package/go.mod +58 -0
  12. package/go.sum +444 -0
  13. package/main.go +22 -0
  14. package/package.json +30 -0
  15. package/pkg/ansi/ansi.go +208 -0
  16. package/pkg/ansi/init_windows.go +23 -0
  17. package/pkg/cmd/completion.go +128 -0
  18. package/pkg/cmd/listen.go +114 -0
  19. package/pkg/cmd/login.go +37 -0
  20. package/pkg/cmd/logout.go +35 -0
  21. package/pkg/cmd/root.go +112 -0
  22. package/pkg/cmd/version.go +25 -0
  23. package/pkg/cmd/whoami.go +50 -0
  24. package/pkg/config/config.go +326 -0
  25. package/pkg/config/config_test.go +20 -0
  26. package/pkg/config/profile.go +296 -0
  27. package/pkg/config/profile_test.go +109 -0
  28. package/pkg/hookdeck/client.go +210 -0
  29. package/pkg/hookdeck/client_test.go +203 -0
  30. package/pkg/hookdeck/connections.go +61 -0
  31. package/pkg/hookdeck/destinations.go +14 -0
  32. package/pkg/hookdeck/guest.go +37 -0
  33. package/pkg/hookdeck/session.go +37 -0
  34. package/pkg/hookdeck/sources.go +73 -0
  35. package/pkg/hookdeck/telemetry.go +84 -0
  36. package/pkg/hookdeck/telemetry_test.go +35 -0
  37. package/pkg/hookdeck/verbosetransport.go +82 -0
  38. package/pkg/hookdeck/verbosetransport_test.go +47 -0
  39. package/pkg/listen/connection.go +91 -0
  40. package/pkg/listen/listen.go +140 -0
  41. package/pkg/listen/source.go +84 -0
  42. package/pkg/login/client_login.go +209 -0
  43. package/pkg/login/interactive_login.go +180 -0
  44. package/pkg/login/login_message.go +24 -0
  45. package/pkg/login/poll.go +78 -0
  46. package/pkg/login/validate.go +52 -0
  47. package/pkg/logout/logout.go +48 -0
  48. package/pkg/open/open.go +50 -0
  49. package/pkg/proxy/proxy.go +433 -0
  50. package/pkg/useragent/uname_unix.go +25 -0
  51. package/pkg/useragent/uname_unix_test.go +24 -0
  52. package/pkg/useragent/uname_windows.go +9 -0
  53. package/pkg/useragent/useragent.go +74 -0
  54. package/pkg/validators/cmds.go +71 -0
  55. package/pkg/validators/validate.go +144 -0
  56. package/pkg/version/version.go +58 -0
  57. package/pkg/version/version_test.go +15 -0
  58. package/pkg/websocket/attempt_messages.go +47 -0
  59. package/pkg/websocket/client.go +525 -0
  60. package/pkg/websocket/connection_messages.go +11 -0
  61. package/pkg/websocket/messages.go +64 -0
@@ -0,0 +1,433 @@
1
+ package proxy
2
+
3
+ import (
4
+ "context"
5
+ "crypto/tls"
6
+ "encoding/json"
7
+ "errors"
8
+ "fmt"
9
+ "io/ioutil"
10
+ "math"
11
+ "net/http"
12
+ "net/url"
13
+ "os"
14
+ "os/signal"
15
+ "strconv"
16
+ "strings"
17
+ "syscall"
18
+ "time"
19
+
20
+ log "github.com/sirupsen/logrus"
21
+
22
+ "github.com/hookdeck/hookdeck-cli/pkg/ansi"
23
+ "github.com/hookdeck/hookdeck-cli/pkg/config"
24
+ "github.com/hookdeck/hookdeck-cli/pkg/hookdeck"
25
+ "github.com/hookdeck/hookdeck-cli/pkg/websocket"
26
+ )
27
+
28
+ const timeLayout = "2006-01-02 15:04:05"
29
+
30
+ //
31
+ // Public types
32
+ //
33
+
34
+ // Config provides the configuration of a Proxy
35
+ type Config struct {
36
+ // DeviceName is the name of the device sent to Hookdeck to help identify the device
37
+ DeviceName string
38
+ Profile config.Profile
39
+ // Key is the API key used to authenticate with Hookdeck
40
+ Key string
41
+ URL *url.URL
42
+ APIBaseURL string
43
+ DashboardBaseURL string
44
+ ConsoleBaseURL string
45
+ WSBaseURL string
46
+ // Indicates whether to print full JSON objects to stdout
47
+ PrintJSON bool
48
+ Log *log.Logger
49
+ // Force use of unencrypted ws:// protocol instead of wss://
50
+ NoWSS bool
51
+ Insecure bool
52
+ }
53
+
54
+ // A Proxy opens a websocket connection with Hookdeck, listens for incoming
55
+ // webhook events, forwards them to the local endpoint and sends the response
56
+ // back to Hookdeck.
57
+ type Proxy struct {
58
+ cfg *Config
59
+ source hookdeck.Source
60
+ connections []hookdeck.Connection
61
+ connections_paths map[string]string
62
+ webSocketClient *websocket.Client
63
+ connectionTimer *time.Timer
64
+ }
65
+
66
+ func withSIGTERMCancel(ctx context.Context, onCancel func()) context.Context {
67
+ // Create a context that will be canceled when Ctrl+C is pressed
68
+ ctx, cancel := context.WithCancel(ctx)
69
+
70
+ interruptCh := make(chan os.Signal, 1)
71
+ signal.Notify(interruptCh, os.Interrupt, syscall.SIGTERM)
72
+
73
+ go func() {
74
+ <-interruptCh
75
+ onCancel()
76
+ cancel()
77
+ }()
78
+ return ctx
79
+ }
80
+
81
+ // Run manages the connection to Hookdeck.
82
+ // The connection is established in phases:
83
+ // - Create a new CLI session
84
+ // - Create a new websocket connection
85
+ func (p *Proxy) Run(parentCtx context.Context) error {
86
+ const maxConnectAttempts = 3
87
+ nAttempts := 0
88
+
89
+ // Track whether or not we have connected successfully.
90
+ // Once we have connected we no longer limit the number
91
+ // of connection attempts that will be made and will retry
92
+ // until the connection is successful or the user terminates
93
+ // the program.
94
+ hasConnectedOnce := false
95
+ canConnect := func() bool {
96
+ if hasConnectedOnce {
97
+ return true
98
+ } else {
99
+ return nAttempts < maxConnectAttempts
100
+ }
101
+ }
102
+
103
+ signalCtx := withSIGTERMCancel(parentCtx, func() {
104
+ log.WithFields(log.Fields{
105
+ "prefix": "proxy.Proxy.Run",
106
+ }).Debug("Ctrl+C received, cleaning up...")
107
+ })
108
+
109
+ s := ansi.StartNewSpinner("Getting ready...", p.cfg.Log.Out)
110
+
111
+ session, err := p.createSession(signalCtx)
112
+ if err != nil {
113
+ ansi.StopSpinner(s, "", p.cfg.Log.Out)
114
+ p.cfg.Log.Fatalf("Error while authenticating with Hookdeck: %v", err)
115
+ }
116
+
117
+ if session.Id == "" {
118
+ ansi.StopSpinner(s, "", p.cfg.Log.Out)
119
+ p.cfg.Log.Fatalf("Error while starting a new session")
120
+ }
121
+
122
+ // Main loop to keep attempting to connect to Hookdeck once
123
+ // we have created a session.
124
+ for canConnect() {
125
+ p.webSocketClient = websocket.NewClient(
126
+ p.cfg.WSBaseURL,
127
+ session.Id,
128
+ p.cfg.Key,
129
+ &websocket.Config{
130
+ Log: p.cfg.Log,
131
+ NoWSS: p.cfg.NoWSS,
132
+ EventHandler: websocket.EventHandlerFunc(p.processAttempt),
133
+ },
134
+ )
135
+
136
+ // Monitor the websocket for connection and update the spinner appropriately.
137
+ go func() {
138
+ <-p.webSocketClient.Connected()
139
+ msg := "Ready! (^C to quit)"
140
+ if hasConnectedOnce {
141
+ msg = "Reconnected!"
142
+ }
143
+ ansi.StopSpinner(s, msg, p.cfg.Log.Out)
144
+ hasConnectedOnce = true
145
+ }()
146
+
147
+ // Run the websocket in the background
148
+ go p.webSocketClient.Run(signalCtx)
149
+ nAttempts++
150
+
151
+ // Block until ctrl+c or the websocket connection is interrupted
152
+ select {
153
+ case <-signalCtx.Done():
154
+ ansi.StopSpinner(s, "", p.cfg.Log.Out)
155
+ return nil
156
+ case <-p.webSocketClient.NotifyExpired:
157
+ if canConnect() {
158
+ ansi.StopSpinner(s, "", p.cfg.Log.Out)
159
+ s = ansi.StartNewSpinner("Connection lost, reconnecting...", p.cfg.Log.Out)
160
+ } else {
161
+ p.cfg.Log.Fatalf("Session expired. Terminating after %d failed attempts to reauthorize", nAttempts)
162
+ }
163
+ }
164
+
165
+ // Determine if we should backoff the connection retries.
166
+ attemptsOverMax := math.Max(0, float64(nAttempts-maxConnectAttempts))
167
+ if canConnect() && attemptsOverMax > 0 {
168
+ // Determine the time to wait to reconnect, maximum of 10 second intervals
169
+ sleepDurationMS := int(math.Round(math.Min(100, math.Pow(attemptsOverMax, 2)) * 100))
170
+ log.WithField(
171
+ "prefix", "proxy.Proxy.Run",
172
+ ).Debugf(
173
+ "Connect backoff (%dms)", sleepDurationMS,
174
+ )
175
+
176
+ // Reset the timer to the next duration
177
+ p.connectionTimer.Stop()
178
+ p.connectionTimer.Reset(time.Duration(sleepDurationMS) * time.Millisecond)
179
+
180
+ // Block until the timer completes or we get interrupted by the user
181
+ select {
182
+ case <-p.connectionTimer.C:
183
+ case <-signalCtx.Done():
184
+ p.connectionTimer.Stop()
185
+ return nil
186
+ }
187
+ }
188
+ }
189
+
190
+ if p.webSocketClient != nil {
191
+ p.webSocketClient.Stop()
192
+ }
193
+
194
+ log.WithFields(log.Fields{
195
+ "prefix": "proxy.Proxy.Run",
196
+ }).Debug("Bye!")
197
+
198
+ return nil
199
+ }
200
+
201
+ func (p *Proxy) createSession(ctx context.Context) (hookdeck.Session, error) {
202
+ var session hookdeck.Session
203
+
204
+ parsedBaseURL, err := url.Parse(p.cfg.APIBaseURL)
205
+ if err != nil {
206
+ return session, err
207
+ }
208
+
209
+ client := &hookdeck.Client{
210
+ BaseURL: parsedBaseURL,
211
+ APIKey: p.cfg.Key,
212
+ }
213
+
214
+ var connection_ids []string
215
+ for _, connection := range p.connections {
216
+ connection_ids = append(connection_ids, connection.Id)
217
+ }
218
+
219
+ for i := 0; i <= 5; i++ {
220
+ session, err = client.CreateSession(hookdeck.CreateSessionInput{SourceId: p.source.Id,
221
+ ConnectionIds: connection_ids})
222
+
223
+ if err == nil {
224
+ return session, nil
225
+ }
226
+
227
+ select {
228
+ case <-ctx.Done():
229
+ return session, errors.New("canceled by context")
230
+ case <-time.After(1 * time.Second):
231
+ }
232
+ }
233
+
234
+ return session, err
235
+ }
236
+
237
+ func (p *Proxy) processAttempt(msg websocket.IncomingMessage) {
238
+ if msg.Attempt == nil {
239
+ p.cfg.Log.Debug("WebSocket specified for Webhooks received non-webhook event")
240
+ return
241
+ }
242
+
243
+ webhookEvent := msg.Attempt
244
+
245
+ p.cfg.Log.WithFields(log.Fields{
246
+ "prefix": "proxy.Proxy.processAttempt",
247
+ }).Debugf("Processing webhook event")
248
+
249
+ if p.cfg.PrintJSON {
250
+ fmt.Println(webhookEvent.Body.Request.DataString)
251
+ } else {
252
+ url := p.cfg.URL.Scheme + "://" + p.cfg.URL.Host + p.cfg.URL.Path + webhookEvent.Body.Path
253
+ tr := &http.Transport{
254
+ TLSClientConfig: &tls.Config{InsecureSkipVerify: p.cfg.Insecure},
255
+ }
256
+
257
+ timeout := webhookEvent.Body.Request.Timeout
258
+ if timeout == 0 {
259
+ timeout = 1000 * 30
260
+ }
261
+
262
+ client := &http.Client{
263
+ Timeout: time.Duration(timeout) * time.Millisecond,
264
+ Transport: tr,
265
+ }
266
+
267
+ req, err := http.NewRequest(webhookEvent.Body.Request.Method, url, nil)
268
+ if err != nil {
269
+ fmt.Printf("Error: %s\n", err)
270
+ return
271
+ }
272
+ x := make(map[string]json.RawMessage)
273
+ err = json.Unmarshal(webhookEvent.Body.Request.Headers, &x)
274
+ if err != nil {
275
+ fmt.Printf("Error: %s\n", err)
276
+ return
277
+ }
278
+
279
+ for key, value := range x {
280
+ unquoted_value, _ := strconv.Unquote(string(value))
281
+ req.Header.Set(key, unquoted_value)
282
+ }
283
+
284
+ req.Body = ioutil.NopCloser(strings.NewReader(webhookEvent.Body.Request.DataString))
285
+ req.ContentLength = int64(len(webhookEvent.Body.Request.DataString))
286
+
287
+ res, err := client.Do(req)
288
+
289
+ if err != nil {
290
+ color := ansi.Color(os.Stdout)
291
+ localTime := time.Now().Format(timeLayout)
292
+
293
+ errStr := fmt.Sprintf("%s [%s] Failed to %s: %v",
294
+ color.Faint(localTime),
295
+ color.Red("ERROR"),
296
+ webhookEvent.Body.Request.Method,
297
+ err,
298
+ )
299
+
300
+ fmt.Println(errStr)
301
+ p.webSocketClient.SendMessage(&websocket.OutgoingMessage{
302
+ ErrorAttemptResponse: &websocket.ErrorAttemptResponse{
303
+ Event: "attempt_response",
304
+ Body: websocket.ErrorAttemptBody{
305
+ AttemptId: webhookEvent.Body.AttemptId,
306
+ Error: true,
307
+ },
308
+ }})
309
+ } else {
310
+ p.processEndpointResponse(webhookEvent, res)
311
+ }
312
+ }
313
+ }
314
+
315
+ func (p *Proxy) processEndpointResponse(webhookEvent *websocket.Attempt, resp *http.Response) {
316
+ localTime := time.Now().Format(timeLayout)
317
+ color := ansi.Color(os.Stdout)
318
+ var url = p.cfg.DashboardBaseURL + "/cli/events/" + webhookEvent.Body.EventID
319
+ if p.cfg.Profile.GetTeamMode() == "console" {
320
+ url = p.cfg.ConsoleBaseURL + "/?event_id=" + webhookEvent.Body.EventID
321
+ }
322
+ outputStr := fmt.Sprintf("%s [%d] %s %s | %s",
323
+ color.Faint(localTime),
324
+ ansi.ColorizeStatus(resp.StatusCode),
325
+ resp.Request.Method,
326
+ resp.Request.URL,
327
+ url,
328
+ )
329
+ fmt.Println(outputStr)
330
+
331
+ buf, err := ioutil.ReadAll(resp.Body)
332
+ if err != nil {
333
+ errStr := fmt.Sprintf("%s [%s] Failed to read response from endpoint, error = %v\n",
334
+ color.Faint(localTime),
335
+ color.Red("ERROR"),
336
+ err,
337
+ )
338
+ log.Errorf(errStr)
339
+
340
+ return
341
+ }
342
+
343
+ // body := truncate(string(buf), 5000, true)
344
+
345
+ if p.webSocketClient != nil {
346
+ p.webSocketClient.SendMessage(&websocket.OutgoingMessage{
347
+ AttemptResponse: &websocket.AttemptResponse{
348
+ Event: "attempt_response",
349
+ Body: websocket.AttemptResponseBody{
350
+ AttemptId: webhookEvent.Body.AttemptId,
351
+ CLIPath: webhookEvent.Body.Path,
352
+ Status: resp.StatusCode,
353
+ Data: string(buf),
354
+ },
355
+ }})
356
+ }
357
+ }
358
+
359
+ //
360
+ // Public functions
361
+ //
362
+
363
+ // New creates a new Proxy
364
+ func New(cfg *Config, source hookdeck.Source, connections []hookdeck.Connection) *Proxy {
365
+ if cfg.Log == nil {
366
+ cfg.Log = &log.Logger{Out: ioutil.Discard}
367
+ }
368
+
369
+ connections_paths := make(map[string]string)
370
+
371
+ for _, connection := range connections {
372
+ connections_paths[connection.Id] = connection.Destination.CliPath
373
+ }
374
+
375
+ p := &Proxy{
376
+ cfg: cfg,
377
+ connections: connections,
378
+ connections_paths: connections_paths,
379
+ source: source,
380
+ connectionTimer: time.NewTimer(0), // Defaults to no delay
381
+ }
382
+
383
+ return p
384
+ }
385
+
386
+ //
387
+ // Private constants
388
+ //
389
+
390
+ const (
391
+ maxBodySize = 5000
392
+ maxNumHeaders = 20
393
+ maxHeaderKeySize = 50
394
+ maxHeaderValueSize = 200
395
+ )
396
+
397
+ //
398
+ // Private functions
399
+ //
400
+
401
+ // truncate will truncate str to be less than or equal to maxByteLength bytes.
402
+ // It will respect UTF8 and truncate the string at a code point boundary.
403
+ // If ellipsis is true, we'll append "..." to the truncated string if the string
404
+ // was in fact truncated, and if there's enough room. Note that the
405
+ // full string returned will always be <= maxByteLength bytes long, even with ellipsis.
406
+ func truncate(str string, maxByteLength int, ellipsis bool) string {
407
+ if len(str) <= maxByteLength {
408
+ return str
409
+ }
410
+
411
+ bytes := []byte(str)
412
+
413
+ if ellipsis && maxByteLength > 3 {
414
+ maxByteLength -= 3
415
+ } else {
416
+ ellipsis = false
417
+ }
418
+
419
+ for maxByteLength > 0 && maxByteLength < len(bytes) && isUTF8ContinuationByte(bytes[maxByteLength]) {
420
+ maxByteLength--
421
+ }
422
+
423
+ result := string(bytes[0:maxByteLength])
424
+ if ellipsis {
425
+ result += "..."
426
+ }
427
+
428
+ return result
429
+ }
430
+
431
+ func isUTF8ContinuationByte(b byte) bool {
432
+ return (b & 0xC0) == 0x80
433
+ }
@@ -0,0 +1,25 @@
1
+ // +build !windows
2
+
3
+ package useragent
4
+
5
+ import (
6
+ "bytes"
7
+ "fmt"
8
+
9
+ "golang.org/x/sys/unix"
10
+ )
11
+
12
+ func trimNulls(input []byte) []byte {
13
+ return bytes.Trim(input, "\x00")
14
+ }
15
+
16
+ func getUname() string {
17
+ u := new(unix.Utsname)
18
+
19
+ err := unix.Uname(u)
20
+ if err != nil {
21
+ panic(err)
22
+ }
23
+
24
+ return fmt.Sprintf("%s %s %s %s %s", trimNulls(u.Sysname[:]), trimNulls(u.Nodename[:]), trimNulls(u.Release[:]), trimNulls(u.Version[:]), trimNulls(u.Machine[:]))
25
+ }
@@ -0,0 +1,24 @@
1
+ // +build !windows
2
+
3
+ package useragent
4
+
5
+ import (
6
+ "fmt"
7
+ "testing"
8
+
9
+ "github.com/stretchr/testify/require"
10
+ )
11
+
12
+ func TestUnameNotEmpty(t *testing.T) {
13
+ u := getUname()
14
+ t.Log(fmt.Sprintf("%x", u)) // For NULL trim paranoia
15
+ require.NotEmpty(t, u)
16
+ }
17
+
18
+ func TestTrimNulls(t *testing.T) {
19
+ input := [256]byte{0xff}
20
+ t.Log(input)
21
+ output := trimNulls(input[:])
22
+ t.Log(output)
23
+ require.NotEqual(t, len(input), len(output))
24
+ }
@@ -0,0 +1,9 @@
1
+ // +build windows
2
+
3
+ package useragent
4
+
5
+ func getUname() string {
6
+ // TODO: if there is appetite for it in the community
7
+ // add support for Windows GetSystemInfo
8
+ return ""
9
+ }
@@ -0,0 +1,74 @@
1
+ package useragent
2
+
3
+ import (
4
+ "encoding/json"
5
+ "runtime"
6
+
7
+ "github.com/hookdeck/hookdeck-cli/pkg/version"
8
+ )
9
+
10
+ //
11
+ // Public functions
12
+ //
13
+
14
+ // GetEncodedHookdeckUserAgent returns the string to be used as the value for
15
+ // the `X-Hookdeck-Client-User-Agent` HTTP header.
16
+ func GetEncodedHookdeckUserAgent() string {
17
+ return encodedHookdeckUserAgent
18
+ }
19
+
20
+ // GetEncodedUserAgent returns the string to be used as the value for
21
+ // the `User-Agent` HTTP header.
22
+ func GetEncodedUserAgent() string {
23
+ return encodedUserAgent
24
+ }
25
+
26
+ //
27
+ // Private types
28
+ //
29
+
30
+ // hookdeckClientUserAgent contains information about the current runtime which
31
+ // is serialized and sent in the `X-Hookdeck-Client-User-Agent` as additional
32
+ // debugging information.
33
+ type hookdeckClientUserAgent struct {
34
+ Name string `json:"name"`
35
+ OS string `json:"os"`
36
+ Publisher string `json:"publisher"`
37
+ Uname string `json:"uname"`
38
+ Version string `json:"version"`
39
+ }
40
+
41
+ //
42
+ // Private variables
43
+ //
44
+
45
+ var encodedHookdeckUserAgent string
46
+ var encodedUserAgent string
47
+
48
+ //
49
+ // Private functions
50
+ //
51
+
52
+ func init() {
53
+ initUserAgent()
54
+ }
55
+
56
+ func initUserAgent() {
57
+ encodedUserAgent = "Hookdeck/v1 hookdeck-cli/" + version.Version
58
+
59
+ hookdeckUserAgent := &hookdeckClientUserAgent{
60
+ Name: "hookdeck-cli",
61
+ Version: version.Version,
62
+ Publisher: "hookdeck",
63
+ OS: runtime.GOOS,
64
+ Uname: getUname(),
65
+ }
66
+ marshaled, err := json.Marshal(hookdeckUserAgent)
67
+ // Encoding this struct should never be a problem, so we're okay to panic
68
+ // in case it is for some reason.
69
+ if err != nil {
70
+ panic(err)
71
+ }
72
+
73
+ encodedHookdeckUserAgent = string(marshaled)
74
+ }
@@ -0,0 +1,71 @@
1
+ package validators
2
+
3
+ import (
4
+ "errors"
5
+ "fmt"
6
+
7
+ "github.com/spf13/cobra"
8
+ )
9
+
10
+ // NoArgs is a validator for commands to print an error when an argument is provided
11
+ func NoArgs(cmd *cobra.Command, args []string) error {
12
+ errorMessage := fmt.Sprintf(
13
+ "`%s` does not take any positional arguments. See `%s --help` for supported flags and usage",
14
+ cmd.CommandPath(),
15
+ cmd.CommandPath(),
16
+ )
17
+
18
+ if len(args) > 0 {
19
+ return errors.New(errorMessage)
20
+ }
21
+
22
+ return nil
23
+ }
24
+
25
+ // ExactArgs is a validator for commands to print an error when the number provided
26
+ // is different than the arguments passed in
27
+ func ExactArgs(num int) cobra.PositionalArgs {
28
+ return func(cmd *cobra.Command, args []string) error {
29
+ argument := "positional argument"
30
+ if num != 1 {
31
+ argument = "positional arguments"
32
+ }
33
+
34
+ errorMessage := fmt.Sprintf(
35
+ "`%s` requires exactly %d %s. See `%s --help` for supported flags and usage",
36
+ cmd.CommandPath(),
37
+ num,
38
+ argument,
39
+ cmd.CommandPath(),
40
+ )
41
+
42
+ if len(args) != num {
43
+ return errors.New(errorMessage)
44
+ }
45
+ return nil
46
+ }
47
+ }
48
+
49
+ // MaximumNArgs is a validator for commands to print an error when the provided
50
+ // args are greater than the maximum amount
51
+ func MaximumNArgs(num int) cobra.PositionalArgs {
52
+ return func(cmd *cobra.Command, args []string) error {
53
+ argument := "positional argument"
54
+ if num > 1 {
55
+ argument = "positional arguments"
56
+ }
57
+
58
+ errorMessage := fmt.Sprintf(
59
+ "`%s` accepts at maximum %d %s. See `%s --help` for supported flags and usage",
60
+ cmd.CommandPath(),
61
+ num,
62
+ argument,
63
+ cmd.CommandPath(),
64
+ )
65
+
66
+ if len(args) > num {
67
+ return errors.New(errorMessage)
68
+ }
69
+ return nil
70
+ }
71
+ }