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,209 @@
1
+ package login
2
+
3
+ import (
4
+ "context"
5
+ "encoding/json"
6
+ "fmt"
7
+ "io"
8
+ "io/ioutil"
9
+ "net/http"
10
+ "net/url"
11
+ "os"
12
+
13
+ "github.com/briandowns/spinner"
14
+
15
+ "github.com/hookdeck/hookdeck-cli/pkg/ansi"
16
+ "github.com/hookdeck/hookdeck-cli/pkg/config"
17
+ "github.com/hookdeck/hookdeck-cli/pkg/hookdeck"
18
+ "github.com/hookdeck/hookdeck-cli/pkg/open"
19
+ "github.com/hookdeck/hookdeck-cli/pkg/validators"
20
+ )
21
+
22
+ var openBrowser = open.Browser
23
+ var canOpenBrowser = open.CanOpenBrowser
24
+
25
+ const hookdeckCLIAuthPath = "/cli-auth"
26
+
27
+ // Links provides the URLs for the CLI to continue the login flow
28
+ type Links struct {
29
+ BrowserURL string `json:"browser_url"`
30
+ PollURL string `json:"poll_url"`
31
+ }
32
+
33
+ // Login function is used to obtain credentials via hookdeck dashboard.
34
+ func Login(config *config.Config, input io.Reader) error {
35
+ var s *spinner.Spinner
36
+
37
+ if config.Profile.APIKey != "" {
38
+ s = ansi.StartNewSpinner("Verifying CLI Key...", os.Stdout)
39
+ response, err := ValidateKey(config.APIBaseURL, config.Profile.APIKey)
40
+ if err != nil {
41
+ return err
42
+ }
43
+
44
+ config.Profile.ClientID = response.ClientID
45
+ config.Profile.DisplayName = response.UserName
46
+ config.Profile.TeamName = response.TeamName
47
+ config.Profile.TeamMode = response.TeamMode
48
+ config.Profile.TeamID = response.TeamID
49
+
50
+ profileErr := config.Profile.CreateProfile()
51
+ if profileErr != nil {
52
+ return profileErr
53
+ }
54
+
55
+ message := SuccessMessage(response.UserName, response.TeamName, config.Profile.TeamMode == "console")
56
+ ansi.StopSpinner(s, message, os.Stdout)
57
+
58
+ return nil
59
+ }
60
+
61
+ links, err := getLinks(config.APIBaseURL, config.Profile.DeviceName)
62
+ if err != nil {
63
+ return err
64
+ }
65
+
66
+ if isSSH() || !canOpenBrowser() {
67
+ fmt.Printf("To authenticate with Hookdeck, please go to: %s\n", links.BrowserURL)
68
+
69
+ s = ansi.StartNewSpinner("Waiting for confirmation...", os.Stdout)
70
+ } else {
71
+ fmt.Printf("Press Enter to open the browser (^C to quit)")
72
+ fmt.Fscanln(input)
73
+
74
+ s = ansi.StartNewSpinner("Waiting for confirmation...", os.Stdout)
75
+
76
+ err = openBrowser(links.BrowserURL)
77
+ if err != nil {
78
+ msg := fmt.Sprintf("Failed to open browser, please go to %s manually.", links.BrowserURL)
79
+ ansi.StopSpinner(s, msg, os.Stdout)
80
+ s = ansi.StartNewSpinner("Waiting for confirmation...", os.Stdout)
81
+ }
82
+ }
83
+
84
+ // Call poll function
85
+ response, err := PollForKey(links.PollURL, 0, 0)
86
+ if err != nil {
87
+ return err
88
+ }
89
+
90
+ validateErr := validators.APIKey(response.APIKey)
91
+ if validateErr != nil {
92
+ return validateErr
93
+ }
94
+
95
+ config.Profile.APIKey = response.APIKey
96
+ config.Profile.ClientID = response.ClientID
97
+ config.Profile.DisplayName = response.UserName
98
+ config.Profile.TeamName = response.TeamName
99
+ config.Profile.TeamMode = response.TeamMode
100
+ config.Profile.TeamID = response.TeamID
101
+
102
+ profileErr := config.Profile.CreateProfile()
103
+ if profileErr != nil {
104
+ return profileErr
105
+ }
106
+
107
+ message := SuccessMessage(response.UserName, response.TeamName, response.TeamMode == "console")
108
+ ansi.StopSpinner(s, message, os.Stdout)
109
+
110
+ return nil
111
+ }
112
+
113
+ func GuestLogin(config *config.Config) (string, error) {
114
+ parsedBaseURL, err := url.Parse(config.APIBaseURL)
115
+ if err != nil {
116
+ return "", err
117
+ }
118
+
119
+ client := &hookdeck.Client{
120
+ BaseURL: parsedBaseURL,
121
+ }
122
+
123
+ fmt.Println("🚩 Not connected with any account. Creating a guest account...")
124
+
125
+ guest_user, err := client.CreateGuestUser(hookdeck.CreateGuestUserInput{
126
+ DeviceName: config.Profile.DeviceName,
127
+ })
128
+ if err != nil {
129
+ return "", err
130
+ }
131
+
132
+ // Call poll function
133
+ response, err := PollForKey(guest_user.PollURL, 0, 0)
134
+ if err != nil {
135
+ return "", err
136
+ }
137
+
138
+ validateErr := validators.APIKey(response.APIKey)
139
+ if validateErr != nil {
140
+ return "", validateErr
141
+ }
142
+
143
+ config.Profile.APIKey = response.APIKey
144
+ config.Profile.ClientID = response.ClientID
145
+ config.Profile.DisplayName = response.UserName
146
+ config.Profile.TeamName = response.TeamName
147
+ config.Profile.TeamMode = response.TeamMode
148
+ config.Profile.TeamID = response.TeamID
149
+
150
+ profileErr := config.Profile.CreateProfile()
151
+ if profileErr != nil {
152
+ return "", profileErr
153
+ }
154
+
155
+ return guest_user.Url, nil
156
+ }
157
+
158
+ func getLinks(baseURL string, deviceName string) (*Links, error) {
159
+ parsedBaseURL, err := url.Parse(baseURL)
160
+ if err != nil {
161
+ return nil, err
162
+ }
163
+
164
+ client := &hookdeck.Client{
165
+ BaseURL: parsedBaseURL,
166
+ }
167
+
168
+ data := struct {
169
+ DeviceName string `json:"device_name"`
170
+ }{}
171
+ data.DeviceName = deviceName
172
+ json_data, err := json.Marshal(data)
173
+ if err != nil {
174
+ return nil, err
175
+ }
176
+
177
+ res, err := client.Post(context.TODO(), hookdeckCLIAuthPath, json_data, nil)
178
+ if err != nil {
179
+ return nil, err
180
+ }
181
+
182
+ defer res.Body.Close()
183
+
184
+ bodyBytes, err := ioutil.ReadAll(res.Body)
185
+ if err != nil {
186
+ return nil, err
187
+ }
188
+
189
+ if res.StatusCode != http.StatusOK {
190
+ return nil, fmt.Errorf("unexpected http status code: %d %s", res.StatusCode, string(bodyBytes))
191
+ }
192
+
193
+ var links Links
194
+
195
+ err = json.Unmarshal(bodyBytes, &links)
196
+ if err != nil {
197
+ return nil, err
198
+ }
199
+
200
+ return &links, nil
201
+ }
202
+
203
+ func isSSH() bool {
204
+ if os.Getenv("SSH_TTY") != "" || os.Getenv("SSH_CONNECTION") != "" || os.Getenv("SSH_CLIENT") != "" {
205
+ return true
206
+ }
207
+
208
+ return false
209
+ }
@@ -0,0 +1,180 @@
1
+ package login
2
+
3
+ import (
4
+ "bufio"
5
+ "context"
6
+ "encoding/json"
7
+ "errors"
8
+ "fmt"
9
+ "io"
10
+ "net/url"
11
+ "os"
12
+ "os/signal"
13
+ "strings"
14
+ "syscall"
15
+
16
+ "golang.org/x/term"
17
+
18
+ "github.com/hookdeck/hookdeck-cli/pkg/ansi"
19
+ "github.com/hookdeck/hookdeck-cli/pkg/config"
20
+ "github.com/hookdeck/hookdeck-cli/pkg/hookdeck"
21
+ "github.com/hookdeck/hookdeck-cli/pkg/validators"
22
+ )
23
+
24
+ // InteractiveLogin lets the user set configuration on the command line
25
+ func InteractiveLogin(config *config.Config) error {
26
+ apiKey, err := getConfigureAPIKey(os.Stdin)
27
+ if err != nil {
28
+ return err
29
+ }
30
+
31
+ config.Profile.DeviceName = getConfigureDeviceName(os.Stdin)
32
+
33
+ s := ansi.StartNewSpinner("Waiting for confirmation...", os.Stdout)
34
+
35
+ // Call poll function
36
+ response, err := PollForKey(config.APIBaseURL+"/cli-auth/poll?key="+apiKey, 0, 0)
37
+ if err != nil {
38
+ return err
39
+ }
40
+
41
+ parsedBaseURL, err := url.Parse(config.APIBaseURL)
42
+ if err != nil {
43
+ return err
44
+ }
45
+
46
+ client := &hookdeck.Client{
47
+ BaseURL: parsedBaseURL,
48
+ APIKey: response.APIKey,
49
+ }
50
+
51
+ data := struct {
52
+ DeviceName string `json:"device_name"`
53
+ }{}
54
+ data.DeviceName = config.Profile.DeviceName
55
+ json_data, err := json.Marshal(data)
56
+ if err != nil {
57
+ return err
58
+ }
59
+
60
+ _, err = client.Put(context.TODO(), "/cli/"+response.ClientID, json_data, nil)
61
+ if err != nil {
62
+ return err
63
+ }
64
+
65
+ config.Profile.APIKey = response.APIKey
66
+ config.Profile.ClientID = response.ClientID
67
+ config.Profile.DisplayName = response.UserName
68
+ config.Profile.TeamName = response.TeamName
69
+ config.Profile.TeamMode = response.TeamMode
70
+ config.Profile.TeamID = response.TeamID
71
+
72
+ profileErr := config.Profile.CreateProfile()
73
+ if profileErr != nil {
74
+ ansi.StopSpinner(s, "", os.Stdout)
75
+ return profileErr
76
+ }
77
+
78
+ message := SuccessMessage(response.UserName, response.TeamName, response.TeamMode == "console")
79
+
80
+ ansi.StopSpinner(s, message, os.Stdout)
81
+
82
+ return nil
83
+ }
84
+
85
+ func getConfigureAPIKey(input io.Reader) (string, error) {
86
+ fmt.Print("Enter your CLI API key: ")
87
+
88
+ apiKey, err := securePrompt(input)
89
+ if err != nil {
90
+ return "", err
91
+ }
92
+
93
+ apiKey = strings.TrimSpace(apiKey)
94
+ if apiKey == "" {
95
+ return "", errors.New("CLI API key is required, please provide your CLI API key")
96
+ }
97
+
98
+ err = validators.APIKey(apiKey)
99
+ if err != nil {
100
+ return "", err
101
+ }
102
+
103
+ fmt.Printf("Your API key is: %s\n", redactAPIKey(apiKey))
104
+
105
+ return apiKey, nil
106
+ }
107
+
108
+ func getConfigureDeviceName(input io.Reader) string {
109
+ hostName, _ := os.Hostname()
110
+ reader := bufio.NewReader(input)
111
+
112
+ color := ansi.Color(os.Stdout)
113
+ fmt.Printf("How would you like to identify this device in the Hookdeck Dashboard? [default: %s] ", color.Bold(color.Cyan(hostName)))
114
+
115
+ deviceName, _ := reader.ReadString('\n')
116
+ if strings.TrimSpace(deviceName) == "" {
117
+ deviceName = hostName
118
+ }
119
+
120
+ return deviceName
121
+ }
122
+
123
+ // redactAPIKey returns a redacted version of API keys. The first 8 and last 4
124
+ // characters are not redacted, everything else is replaced by "*" characters.
125
+ //
126
+ // It panics if the provided string has less than 12 characters.
127
+ func redactAPIKey(apiKey string) string {
128
+ var b strings.Builder
129
+
130
+ b.WriteString(apiKey[0:8]) // #nosec G104 (gosec bug: https://github.com/securego/gosec/issues/267)
131
+ b.WriteString(strings.Repeat("*", len(apiKey)-12)) // #nosec G104 (gosec bug: https://github.com/securego/gosec/issues/267)
132
+ b.WriteString(apiKey[len(apiKey)-4:]) // #nosec G104 (gosec bug: https://github.com/securego/gosec/issues/267)
133
+
134
+ return b.String()
135
+ }
136
+
137
+ func securePrompt(input io.Reader) (string, error) {
138
+ if input == os.Stdin {
139
+ // terminal.ReadPassword does not reset terminal state on ctrl-c interrupts,
140
+ // this results in the terminal input staying hidden after program exit.
141
+ // We need to manually catch the interrupt and restore terminal state before exiting.
142
+ signalChan, err := protectTerminalState()
143
+ if err != nil {
144
+ return "", err
145
+ }
146
+
147
+ buf, err := term.ReadPassword(int(syscall.Stdin)) //nolint:unconvert
148
+ if err != nil {
149
+ return "", err
150
+ }
151
+
152
+ signal.Stop(signalChan)
153
+
154
+ fmt.Print("\n")
155
+
156
+ return string(buf), nil
157
+ }
158
+
159
+ reader := bufio.NewReader(input)
160
+
161
+ return reader.ReadString('\n')
162
+ }
163
+
164
+ func protectTerminalState() (chan os.Signal, error) {
165
+ originalTerminalState, err := term.GetState(int(syscall.Stdin)) //nolint:unconvert
166
+ if err != nil {
167
+ return nil, err
168
+ }
169
+
170
+ signalChan := make(chan os.Signal)
171
+ signal.Notify(signalChan, os.Interrupt)
172
+
173
+ go func() {
174
+ <-signalChan
175
+ term.Restore(int(syscall.Stdin), originalTerminalState) //nolint:unconvert
176
+ os.Exit(1)
177
+ }()
178
+
179
+ return signalChan, nil
180
+ }
@@ -0,0 +1,24 @@
1
+ package login
2
+
3
+ import (
4
+ "fmt"
5
+ "os"
6
+
7
+ "github.com/hookdeck/hookdeck-cli/pkg/ansi"
8
+ )
9
+
10
+ // SuccessMessage returns the display message for a successfully authenticated user
11
+ func SuccessMessage(displayName string, teamName string, isConsole bool) string {
12
+ color := ansi.Color(os.Stdout)
13
+
14
+ if isConsole == true {
15
+ return fmt.Sprintf(
16
+ "Done! The Hookdeck CLI is configured with your console Sandbox",
17
+ )
18
+ }
19
+ return fmt.Sprintf(
20
+ "Done! The Hookdeck CLI is configured for %s in workspace %s\n",
21
+ color.Bold(displayName),
22
+ color.Bold(teamName),
23
+ )
24
+ }
@@ -0,0 +1,78 @@
1
+ package login
2
+
3
+ import (
4
+ "context"
5
+ "encoding/json"
6
+ "errors"
7
+ "io/ioutil"
8
+ "net/url"
9
+ "time"
10
+
11
+ "github.com/hookdeck/hookdeck-cli/pkg/hookdeck"
12
+ )
13
+
14
+ const maxAttemptsDefault = 2 * 60
15
+ const intervalDefault = 1 * time.Second
16
+
17
+ // PollAPIKeyResponse returns the data of the polling client login
18
+ type PollAPIKeyResponse struct {
19
+ Claimed bool `json:"claimed"`
20
+ UserID string `json:"user_id"`
21
+ UserName string `json:"user_name"`
22
+ TeamID string `json:"team_id"`
23
+ TeamName string `json:"team_name"`
24
+ TeamMode string `json:"team_mode"`
25
+ APIKey string `json:"key"`
26
+ ClientID string `json:"client_id"`
27
+ }
28
+
29
+ // PollForKey polls Hookdeck at the specified interval until either the API key is available or we've reached the max attempts.
30
+ func PollForKey(pollURL string, interval time.Duration, maxAttempts int) (*PollAPIKeyResponse, error) {
31
+ if maxAttempts == 0 {
32
+ maxAttempts = maxAttemptsDefault
33
+ }
34
+
35
+ if interval == 0 {
36
+ interval = intervalDefault
37
+ }
38
+
39
+ parsedURL, err := url.Parse(pollURL)
40
+ if err != nil {
41
+ return nil, err
42
+ }
43
+
44
+ baseURL := &url.URL{Scheme: parsedURL.Scheme, Host: parsedURL.Host}
45
+
46
+ client := &hookdeck.Client{
47
+ BaseURL: baseURL,
48
+ }
49
+
50
+ var count = 0
51
+ for count < maxAttempts {
52
+ res, err := client.Get(context.TODO(), parsedURL.Path, parsedURL.Query().Encode(), nil)
53
+ if err != nil {
54
+ return nil, err
55
+ }
56
+
57
+ var response PollAPIKeyResponse
58
+
59
+ defer res.Body.Close()
60
+ body, err := ioutil.ReadAll(res.Body)
61
+ if err != nil {
62
+ return nil, err
63
+ }
64
+ err = json.Unmarshal(body, &response)
65
+ if err != nil {
66
+ return nil, err
67
+ }
68
+
69
+ if response.Claimed {
70
+ return &response, nil
71
+ }
72
+
73
+ count++
74
+ time.Sleep(interval)
75
+ }
76
+
77
+ return nil, errors.New("exceeded max attempts")
78
+ }
@@ -0,0 +1,52 @@
1
+ package login
2
+
3
+ import (
4
+ "context"
5
+ "encoding/json"
6
+ "io/ioutil"
7
+ "net/url"
8
+
9
+ "github.com/hookdeck/hookdeck-cli/pkg/hookdeck"
10
+ )
11
+
12
+ // ValidateAPIKeyResponse returns the user and team associated with a key
13
+ type ValidateAPIKeyResponse struct {
14
+ UserID string `json:"user_id"`
15
+ UserName string `json:"user_name"`
16
+ TeamID string `json:"team_id"`
17
+ TeamName string `json:"team_name"`
18
+ TeamMode string `json:"team_mode"`
19
+ ClientID string `json:"client_id"`
20
+ }
21
+
22
+ func ValidateKey(baseURL string, key string) (*ValidateAPIKeyResponse, error) {
23
+
24
+ parsedBaseURL, err := url.Parse(baseURL)
25
+ if err != nil {
26
+ return nil, err
27
+ }
28
+
29
+ client := &hookdeck.Client{
30
+ BaseURL: parsedBaseURL,
31
+ APIKey: key,
32
+ }
33
+
34
+ res, err := client.Get(context.Background(), "/cli-auth/validate", "", nil)
35
+ if err != nil {
36
+ return nil, err
37
+ }
38
+
39
+ var response ValidateAPIKeyResponse
40
+
41
+ defer res.Body.Close()
42
+ body, err := ioutil.ReadAll(res.Body)
43
+ if err != nil {
44
+ return nil, err
45
+ }
46
+ err = json.Unmarshal(body, &response)
47
+ if err != nil {
48
+ return nil, err
49
+ }
50
+
51
+ return &response, nil
52
+ }
@@ -0,0 +1,48 @@
1
+ package logout
2
+
3
+ import (
4
+ "fmt"
5
+
6
+ "github.com/hookdeck/hookdeck-cli/pkg/config"
7
+ )
8
+
9
+ // Logout function is used to clear the credentials set for the current Profile
10
+ func Logout(config *config.Config) error {
11
+ key, _ := config.Profile.GetAPIKey()
12
+
13
+ if key == "" {
14
+ fmt.Println("You are already logged out.")
15
+ return nil
16
+ }
17
+
18
+ fmt.Println("Logging out...")
19
+
20
+ profileName := config.Profile.ProfileName
21
+
22
+ err := config.RemoveProfile(profileName)
23
+ if err != nil {
24
+ return err
25
+ }
26
+
27
+ if profileName == "default" {
28
+ fmt.Println("Credentials have been cleared for the default project.")
29
+ } else {
30
+ fmt.Printf("Credentials have been cleared for %s.\n", profileName)
31
+ }
32
+
33
+ return nil
34
+ }
35
+
36
+ // All function is used to clear the credentials on all profiles
37
+ func All(cfg *config.Config) error {
38
+ fmt.Println("Logging out...")
39
+
40
+ err := cfg.RemoveAllProfiles()
41
+ if err != nil {
42
+ return err
43
+ }
44
+
45
+ fmt.Println("Credentials have been cleared for all projects.")
46
+
47
+ return nil
48
+ }
@@ -0,0 +1,50 @@
1
+ package open
2
+
3
+ import (
4
+ "fmt"
5
+ "os/exec"
6
+ "runtime"
7
+ )
8
+
9
+ var execCommand = exec.Command
10
+
11
+ // Browser takes a url and opens it using the default browser on the operating system
12
+ func Browser(url string) error {
13
+ var err error
14
+
15
+ switch runtime.GOOS {
16
+ case "linux":
17
+ err = execCommand("xdg-open", url).Start()
18
+ case "windows":
19
+ err = execCommand("rundll32", "url.dll,FileProtocolHandler", url).Start()
20
+ case "darwin":
21
+ err = execCommand("open", url).Start()
22
+ default:
23
+ err = fmt.Errorf("unsupported platform")
24
+ }
25
+
26
+ if err != nil {
27
+ return err
28
+ }
29
+
30
+ return nil
31
+ }
32
+
33
+ // CanOpenBrowser determines if no browser is set in linux
34
+ func CanOpenBrowser() bool {
35
+ if runtime.GOOS == "windows" || runtime.GOOS == "darwin" {
36
+ return true
37
+ }
38
+
39
+ output, err := execCommand("xdg-settings", "get", "default-web-browser").Output()
40
+
41
+ if err != nil {
42
+ return false
43
+ }
44
+
45
+ if string(output) == "" {
46
+ return false
47
+ }
48
+
49
+ return true
50
+ }