From d02deb5d26592f9574b63c9d7fa28e6fb800ab43 Mon Sep 17 00:00:00 2001 From: Michael Rykov Date: Sat, 6 Jul 2024 13:33:17 +0800 Subject: [PATCH] Added browser-based login --- api/auth.go | 32 ++++++++++- api/client.go | 5 +- api/errors.go | 5 ++ cli/api.go | 115 ++++++++++++++++++++++++++++++++++----- cli/login.go | 78 +++++++++++++++++--------- cli/login_test.go | 45 ++++++++++++--- cli/root_test.go | 28 +++++++--- cli/yank.go | 13 +---- go.mod | 7 ++- go.sum | 10 ++-- internal/testutil/api.go | 23 +++++++- pkg/browser/browser.go | 70 ++++++++++++++++++++++++ pkg/terminal/prompt.go | 79 +++++++++++++++++++++++++++ pkg/terminal/terminal.go | 10 +++- pkg/terminal/testing.go | 9 ++- 15 files changed, 450 insertions(+), 79 deletions(-) create mode 100644 pkg/browser/browser.go create mode 100644 pkg/terminal/prompt.go diff --git a/api/auth.go b/api/auth.go index d55dbe9..55bfa6c 100644 --- a/api/auth.go +++ b/api/auth.go @@ -10,7 +10,7 @@ func (c *Client) Logout(cc context.Context) error { return req.doJSON(nil) } -// Logout deletes the CLI token on the server +// Interactive login generates the CLI token on the server from username/password func (c *Client) Login(cc context.Context, loginReq *LoginRequest) (*LoginResponse, error) { req := c.newRequest(cc, "POST", "/login", false) @@ -34,3 +34,33 @@ type LoginResponse struct { Token string `json:"token"` User AccountResponse `json:"user"` } + +// LoginCreate generates an URL used to approve a CLI login via browser authentication +func (c *Client) LoginCreate(cc context.Context) (*LoginCreateResponse, error) { + req := c.newRequest(cc, "POST", "/cli/auth", false) + resp := &LoginCreateResponse{} + err := req.doJSON(resp) + return resp, err +} + +// LoginCreateResponse represents LoginCreate JSON response +type LoginCreateResponse struct { + BrowserURL string `json:"browser_url"` + CLIURL string `json:"cli_url"` + Token string `json:"token"` +} + +// LoginGet waits for browser login and retrieves its results (token & user information) +func (c *Client) LoginGet(cc context.Context, create *LoginCreateResponse) (*LoginGetResponse, error) { + req := c.newRequest(cc, "GET", create.CLIURL, false) + req.Header.Set("Authorization", "Bearer "+create.Token) + resp := &LoginGetResponse{} + err := req.doJSON(resp) + return resp, err +} + +// LoginGetResponse represents LoginGet JSON response +type LoginGetResponse struct { + Error string `json:"error"` + LoginResponse +} diff --git a/api/client.go b/api/client.go index cadac6f..3d2e3ba 100644 --- a/api/client.go +++ b/api/client.go @@ -11,6 +11,7 @@ import ( "io/ioutil" "net/http" "net/url" + "os" "strings" ) @@ -134,7 +135,9 @@ func (r *request) doCommon() (*http.Response, error) { } resp, err := r.conduit.Do(r.Request) - if err != nil { + if os.IsTimeout(err) { + return resp, ErrTimeout + } else if err != nil { return resp, err } diff --git a/api/errors.go b/api/errors.go index befb738..1608583 100644 --- a/api/errors.go +++ b/api/errors.go @@ -11,6 +11,9 @@ var ( // ErrFuryServer is the error for 5xx server errors ErrFuryServer = errors.New("Something went wrong. Please contact support.") + // ErrTimeout is the error for 408 from server or net timeout + ErrTimeout = errors.New("Operation timed out. Try again later.") + // ErrUnauthorized is the error for 401 from server ErrUnauthorized = errors.New("Authentication failure") @@ -60,6 +63,8 @@ func StatusCodeToError(s int) error { return ErrForbidden case s == 404: return ErrNotFound + case s == 408: + return ErrTimeout case s == 409: return ErrConflict case s >= 200 && s < 300: diff --git a/cli/api.go b/cli/api.go index 92af66f..861f556 100644 --- a/cli/api.go +++ b/cli/api.go @@ -1,12 +1,17 @@ package cli import ( + "github.com/cenkalti/backoff/v4" "github.com/gemfury/cli/api" "github.com/gemfury/cli/internal/ctx" + "github.com/gemfury/cli/pkg/terminal" "github.com/manifoldco/promptui" "github.com/spf13/cobra" "context" + "errors" + "fmt" + "time" ) // Initialize new Gemfury API client with authentication @@ -44,21 +49,113 @@ func contextAuthToken(cc context.Context) (string, error) { // Hook for root command to ensure user is authenticated or prompt to login func preRunCheckAuthentication(cmd *cobra.Command, args []string) error { - if n := cmd.Name(); n == "logout" { + if n := cmd.Name(); n == "logout" || n == "login" { return nil } - _, err := ensureAuthenticated(cmd) + _, err := ensureAuthenticated(cmd, false) return err } -func ensureAuthenticated(cmd *cobra.Command) (*api.AccountResponse, error) { +func ensureAuthenticated(cmd *cobra.Command, interactive bool) (*api.AccountResponse, error) { cc := cmd.Context() + var err error + // Check whether we have login credentials from environment if token, err := contextAuthToken(cc); token != "" || err != nil { return nil, err } + // Browser or interactive login + var resp *api.LoginResponse + if interactive { + resp, err = interactiveLogin(cmd) + } else { + resp, err = browserLogin(cmd) + } + if err != nil { + return nil, err + } + + // Save credentials to .netrc for future commands + err = ctx.Auther(cc).Append(resp.User.Email, resp.Token) + if err != nil { + return nil, err + } + + return &resp.User, nil +} + +// browserLogin is a challenge/response authentication via browser +func browserLogin(cmd *cobra.Command) (*api.LoginResponse, error) { + cc := cmd.Context() + term := ctx.Terminal(cc) + + c, err := newAPIClient(cc) + if err != nil { + return nil, err + } + + // Generate authentication URLs + createResp, err := c.LoginCreate(cc) + if err != nil { + return nil, err + } else if createResp.BrowserURL == "" { + return nil, fmt.Errorf("Internal error") + } + + // Everything is ready. Confirm opening browser to login + anyKey := "Press any key to login via the browser or q to exit: " + if err := terminal.PromptAnyKeyOrQuit(term, anyKey); err != nil { + return nil, err + } + + // Attempt to open the browser to create CLI token + term.Printf("Opening %s\n", createResp.BrowserURL) + if ok := term.OpenBrowser(createResp.BrowserURL); !ok { + term.Printf("Failed to open browser. You can continue CLI login by manually opening the URL\n") + } + + // Start/end spinner while waiting for browser auth + onDone := terminal.SpinIfTerminal(term, " Waiting ...") + defer onDone() + + // LoginGet will timeout, so we retry until a time limit. + // We do constant backoff with elapsed time limit that + // is shorter than the expiry of all the JWT tokens. + constantBackoff := backoff.NewExponentialBackOff( + backoff.WithMaxElapsedTime(3*time.Minute), + backoff.WithMultiplier(1.0), + ) + + // Repeatedly hit LoginGet API until results + var resp *api.LoginGetResponse + err = backoff.Retry(func() error { + resp, err = c.LoginGet(cc, createResp) + if !errors.Is(err, api.ErrTimeout) && !errors.Is(err, api.ErrNotFound) { + err = backoff.Permanent(err) // Retry only on timeout or not-found + } + return err + }, backoff.WithContext(constantBackoff, cc)) + + if resp == nil { + return nil, err + } + + if resp.Error != "" { + err = fmt.Errorf(resp.Error) + } else if errors.Is(err, api.ErrNotFound) { + err = api.ErrTimeout + } + + return &resp.LoginResponse, err +} + +// interactiveLogin is an email/password authentication via terminal +func interactiveLogin(cmd *cobra.Command) (*api.LoginResponse, error) { + cc := cmd.Context() + + // Interactive login term := ctx.Terminal(cc) term.Println("Please enter your Gemfury credentials.") @@ -84,16 +181,6 @@ func ensureAuthenticated(cmd *cobra.Command) (*api.AccountResponse, error) { if err == api.ErrUnauthorized { cmd.SilenceErrors = true cmd.SilenceUsage = true - return nil, err - } else if err != nil { - return nil, err - } - - // Save credentials in .netrc - err = ctx.Auther(cc).Append(resp.User.Email, resp.Token) - if err != nil { - return nil, err } - - return &resp.User, nil + return resp, err } diff --git a/cli/login.go b/cli/login.go index 4a121d6..feeec2e 100644 --- a/cli/login.go +++ b/cli/login.go @@ -2,8 +2,12 @@ package cli import ( "github.com/gemfury/cli/internal/ctx" + "github.com/gemfury/cli/pkg/terminal" "github.com/manifoldco/promptui" "github.com/spf13/cobra" + + "context" + "errors" ) // Machines for Gemfury in .netrc file @@ -28,30 +32,12 @@ func NewCmdLogout() *cobra.Command { return nil } - prompt := promptui.Prompt{ - Label: "Are you sure you want to logout? [y/N]", - Default: "N", - } - - result, err := term.RunPrompt(&prompt) - if err != nil { - return err - } - - if result != "y" && result != "Y" { - return nil - } - - c, err := newAPIClient(cc) - if err != nil { + confirm := "Are you sure you want to logout? [y/N]" + if ok, err := terminal.PromptConfirm(term, confirm); !ok { return err } - if err := c.Logout(cc); err != nil { - return err - } - - if err := ctx.Auther(cc).Wipe(); err != nil { + if err := logoutCurrent(cc, false); err != nil { return err } @@ -63,36 +49,76 @@ func NewCmdLogout() *cobra.Command { return logoutCmd } +// Deactivates & deletes the saved CLI token, if present +func logoutCurrent(cc context.Context, isForLogin bool) error { + c, err := newAPIClient(cc) + if err != nil { + return err + } + + if err := c.Logout(cc); err != nil { + confirm := "Do you want to remove credentials from .netrc anyway? [y/N]" + if isForLogin { + confirm = "Do you want to ignore & continue with your login? [y/N]" + } + term := ctx.Terminal(cc) + term.Printf("Error deactivating your old CLI credentials: %s\n", err) + if ok, _ := terminal.PromptConfirm(term, confirm); !ok { + return err + } + } + + return ctx.Auther(cc).Wipe() +} + // NewCmdLogout invalidates session and wipes credentials func NewCmdLogin() *cobra.Command { + var interactiveFlag bool + loginCmd := &cobra.Command{ Use: "login", Short: "Authenticate into Gemfury account", RunE: func(cmd *cobra.Command, args []string) error { + cc := cmd.Context() + auth := ctx.Auther(cc) - user, err := ensureAuthenticated(cmd) - if err != nil { + // Logout previous CLI token, if present in .netrc + if _, token, err := auth.Auth(); err == nil && token != "" { + if err := logoutCurrent(cc, true); err != nil { + return err + } + } + + // Start browser or interactive authentication + user, err := ensureAuthenticated(cmd, interactiveFlag) + if errors.Is(err, promptui.ErrAbort) { + return nil // User-cancelled + } else if err != nil { return err } + // Verify auth if user == nil { - user, err = whoAMI(cmd.Context()) + user, err = whoAMI(cc) if err != nil { return err } } - term := ctx.Terminal(cmd.Context()) + term := ctx.Terminal(cc) if ctx.GlobalFlags(cmd.Context()).AuthToken != "" { term.Printf("API token belongs to %q\n", user.Name) } else { - term.Printf("You are logged in as %q\n", user.Name) + term.Printf("You are logged in as %q\n", user.Email) } return nil }, } + // Flags and options + loginCmd.Flags().BoolVar(&interactiveFlag, "interactive", false, "Interactive login") + return loginCmd } diff --git a/cli/login_test.go b/cli/login_test.go index 3c41871..b8c271f 100644 --- a/cli/login_test.go +++ b/cli/login_test.go @@ -5,6 +5,8 @@ import ( "github.com/gemfury/cli/internal/ctx" "github.com/gemfury/cli/internal/testutil" "github.com/gemfury/cli/pkg/terminal" + + "strings" "testing" ) @@ -13,7 +15,7 @@ import ( // /login route is already present on APIServer func TestLoginCommandSuccess(t *testing.T) { - auth := terminal.TestAuther("user", "abc123", nil) + auth := terminal.TestAuther("", "", nil) term := terminal.NewForTest() // Fire up test server @@ -24,26 +26,51 @@ func TestLoginCommandSuccess(t *testing.T) { flags := ctx.GlobalFlags(cc) flags.Endpoint = server.URL + // Add any key for the "open browser" prompt + term.InWrite([]byte("!")) + err := runCommandNoErr(cc, []string{"login"}) if err != nil { t.Error(err) } outStr := string(term.OutBytes()) - if exp := "You are logged in as \"joetest\"\n"; outStr != exp { + if exp := "You are logged in as \"u@example.com\"\n"; !strings.Contains(outStr, exp) { t.Errorf("Expected output to include %q, got %q", exp, outStr) } } -func TestLoginCommandUnauthorized(t *testing.T) { +func TestLoginCommandInteractive(t *testing.T) { + auth := terminal.TestAuther("", "", nil) + term := terminal.NewForTest() + + // Fire up test server server := testutil.APIServer(t, "GET", "/users/me", whoamiResponse, 200) - testCommandLoginPreCheck(t, []string{"login"}, server) - server.Close() + defer server.Close() + + cc := cli.TestContext(term, auth) + flags := ctx.GlobalFlags(cc) + flags.Endpoint = server.URL + + term.SetPromptResponses(map[string]string{ + "Email: ": "u@example.com", + "Password: ": "secreto", + }) + + err := runCommandNoErr(cc, []string{"login", "--interactive"}) + if err != nil { + t.Error(err) + } + + outStr := string(term.OutBytes()) + if exp := "You are logged in as \"u@example.com\"\n"; !strings.Contains(outStr, exp) { + t.Errorf("Expected output to include %q, got %q", exp, outStr) + } } -func TestLoginCommandForbidden(t *testing.T) { - server := testutil.APIServer(t, "GET", "/users/me", "{}", 403) - testCommandForbiddenResponse(t, []string{"login"}, server) +func TestLoginCommandUnauthorized(t *testing.T) { + server := testutil.APIServer(t, "GET", "/users/me", whoamiResponse, 200) + testCommandLoginPreCheck(t, []string{"login"}, server, noLoginOpt) server.Close() } @@ -90,7 +117,7 @@ func TestLogoutCommandAbort(t *testing.T) { defer server.Close() term.SetPromptResponses(map[string]string{ - "Are you sure you want to logout? [y/N]": "N", + "Are you sure you want to logout? [y/N]": "ABORT", }) cc := cli.TestContext(term, auth) diff --git a/cli/root_test.go b/cli/root_test.go index fb773a2..bd06432 100644 --- a/cli/root_test.go +++ b/cli/root_test.go @@ -69,20 +69,21 @@ func runCommandNoErr(cc context.Context, args []string) error { } // We first test with manual (prompt) login, and then test with "--api-token" flag -func testCommandLoginPreCheck(t *testing.T, args []string, server *httptest.Server) { +func testCommandLoginPreCheck(t *testing.T, args []string, server *httptest.Server, opts ...testOption) { auth := terminal.TestAuther("", "", nil) term := terminal.NewForTest() cc := cli.TestContext(term, auth) + for _, opt := range opts { + cc = opt(cc) + } + flags := ctx.GlobalFlags(cc) flags.PushEndpoint = server.URL flags.Endpoint = server.URL - // Prepare for login prompt - term.SetPromptResponses(map[string]string{ - "Email: ": "user@example.com", - "Password: ": "secreto", - }) + // Prepare for browser login prompt + term.InWrite([]byte("!")) if err := runCommand(cc, args); err != nil { t.Errorf("Command error: %s", err) @@ -109,11 +110,15 @@ func testCommandLoginPreCheck(t *testing.T, args []string, server *httptest.Serv } } -func testCommandForbiddenResponse(t *testing.T, args []string, server *httptest.Server) { +func testCommandForbiddenResponse(t *testing.T, args []string, server *httptest.Server, opts ...testOption) { auth := terminal.TestAuther("user", "abc123", nil) term := terminal.NewForTest() cc := cli.TestContext(term, auth) + for _, opt := range opts { + cc = opt(cc) + } + flags := ctx.GlobalFlags(cc) flags.PushEndpoint = server.URL flags.Endpoint = server.URL @@ -132,3 +137,12 @@ func testCommandForbiddenResponse(t *testing.T, args []string, server *httptest. t.Errorf("Output isn't showing usage: \n%s", ob) } } + +// Context altering options added to test commands +type testOption func(context.Context) context.Context + +// No login option +func noLoginOpt(cc context.Context) context.Context { + ctx.Auther(cc).Wipe() + return cc +} diff --git a/cli/yank.go b/cli/yank.go index 5b55e5a..982622c 100644 --- a/cli/yank.go +++ b/cli/yank.go @@ -3,12 +3,11 @@ package cli import ( "github.com/gemfury/cli/api" "github.com/gemfury/cli/internal/ctx" + "github.com/gemfury/cli/pkg/terminal" "github.com/hashicorp/go-multierror" - "github.com/manifoldco/promptui" "github.com/spf13/cobra" "context" - "errors" "fmt" "net/url" "strings" @@ -69,14 +68,8 @@ func NewCmdYank() *cobra.Command { if !forceFlag { termPrintVersions(term, versions) - prompt := promptui.Prompt{ - Label: "Are you sure you want to delete these files? [y/N]", - IsConfirm: true, - } - _, err := term.RunPrompt(&prompt) - if errors.Is(err, promptui.ErrAbort) { - return nil - } else if err != nil { + confirm := "Are you sure you want to delete these files? [y/N]" + if ok, err := terminal.PromptConfirm(term, confirm); !ok { return err } } diff --git a/go.mod b/go.mod index 2869632..813745d 100644 --- a/go.mod +++ b/go.mod @@ -5,7 +5,9 @@ go 1.22 require ( github.com/bgentry/go-netrc v0.0.0-20140422174119-9fd32a8b3d3d github.com/briandowns/spinner v1.23.1 + github.com/cenkalti/backoff/v4 v4.3.0 github.com/cheggaaa/pb/v3 v3.1.5 + github.com/chzyer/readline v1.5.1 github.com/hashicorp/go-multierror v1.1.1 github.com/manifoldco/promptui v0.9.0 github.com/spf13/cobra v1.8.1 @@ -16,7 +18,6 @@ require ( require ( github.com/VividCortex/ewma v1.2.0 // indirect - github.com/chzyer/readline v1.5.1 // indirect github.com/fatih/color v1.17.0 // indirect github.com/hashicorp/errwrap v1.1.0 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect @@ -24,6 +25,6 @@ require ( github.com/mattn/go-isatty v0.0.20 // indirect github.com/mattn/go-runewidth v0.0.15 // indirect github.com/rivo/uniseg v0.4.7 // indirect - golang.org/x/sys v0.21.0 // indirect - golang.org/x/term v0.21.0 // indirect + golang.org/x/sys v0.22.0 // indirect + golang.org/x/term v0.22.0 // indirect ) diff --git a/go.sum b/go.sum index b2f1b74..b7e2279 100644 --- a/go.sum +++ b/go.sum @@ -4,6 +4,8 @@ github.com/bgentry/go-netrc v0.0.0-20140422174119-9fd32a8b3d3d h1:xDfNPAt8lFiC1U github.com/bgentry/go-netrc v0.0.0-20140422174119-9fd32a8b3d3d/go.mod h1:6QX/PXZ00z/TKoufEY6K/a0k6AhaJrQKdFe6OfVXsa4= github.com/briandowns/spinner v1.23.1 h1:t5fDPmScwUjozhDj4FA46p5acZWIPXYE30qW2Ptu650= github.com/briandowns/spinner v1.23.1/go.mod h1:LaZeM4wm2Ywy6vO571mvhQNRcWfRUnXOs0RcKV0wYKM= +github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8= +github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE= github.com/cheggaaa/pb/v3 v3.1.5 h1:QuuUzeM2WsAqG2gMqtzaWithDJv0i+i6UlnwSCI4QLk= github.com/cheggaaa/pb/v3 v3.1.5/go.mod h1:CrxkeghYTXi1lQBEI7jSn+3svI3cuc19haAj6jM60XI= github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI= @@ -50,9 +52,9 @@ golang.org/x/sys v0.0.0-20181122145206-62eef0e2fa9b/go.mod h1:STP8DvDyc/dI5b8T5h golang.org/x/sys v0.0.0-20220310020820-b874c991c1a5/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.21.0 h1:rF+pYz3DAGSQAxAu1CbC7catZg4ebC4UIeIhKxBZvws= -golang.org/x/sys v0.21.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= -golang.org/x/term v0.21.0 h1:WVXCp+/EBEHOj53Rvu+7KiT/iElMrO8ACK16SMZ3jaA= -golang.org/x/term v0.21.0/go.mod h1:ooXLefLobQVslOqselCNF4SxFAaoS6KujMbsGzSDmX0= +golang.org/x/sys v0.22.0 h1:RI27ohtqKCnwULzJLqkv897zojh5/DwS/ENaMzUOaWI= +golang.org/x/sys v0.22.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/term v0.22.0 h1:BbsgPEJULsl2fV/AT3v15Mjva5yXKQDyKf+TbDz7QJk= +golang.org/x/term v0.22.0/go.mod h1:F3qCibpT5AMpCRfhfT53vVJwhLtIVHhB9XDjfFvnMI4= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/internal/testutil/api.go b/internal/testutil/api.go index 06d810c..4887ed1 100644 --- a/internal/testutil/api.go +++ b/internal/testutil/api.go @@ -95,7 +95,28 @@ func APIServerCustom(t *testing.T, custom func(*http.ServeMux)) *httptest.Server // Add custom path handlers custom(h) - // Default handler for auth + // Default handler for browser auth + h.HandleFunc("/cli/auth", func(w http.ResponseWriter, r *http.Request) { + if m := r.Method; m == "POST" { + w.Write([]byte(`{ + "browser_url": "https://gemfury.com", + "cli_url": "/cli/auth?wait=true", + "token": "xyz-123" + }`)) + } else if m == "GET" { + if a := r.Header.Get("Authorization"); a != "Bearer xyz-123" { + t.Errorf("Incorrect Authorization: %q", m) + } + w.Write([]byte(`{ + "user": { "email" : "u@example.com" }, + "token": "token-abc-123" + }`)) + } else { + t.Errorf("Incorrect method: %q", m) + } + }) + + // Default handler for interactive auth h.HandleFunc("/login", func(w http.ResponseWriter, r *http.Request) { if r.Method != "POST" { w.WriteHeader(http.StatusNotImplemented) diff --git a/pkg/browser/browser.go b/pkg/browser/browser.go new file mode 100644 index 0000000..6e92905 --- /dev/null +++ b/pkg/browser/browser.go @@ -0,0 +1,70 @@ +// Source: https://github.com/golang/go/blob/master/src/cmd/internal/browser/browser.go +// License: https://github.com/golang/go/blob/master/LICENSE + +// Copyright 2016 The Go Authors. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +// Package browser provides utilities for interacting with users' browsers. +package browser + +import ( + "os" + "os/exec" + "runtime" + "time" +) + +// Commands returns a list of possible commands to use to open a url. +func Commands() [][]string { + var cmds [][]string + if exe := os.Getenv("BROWSER"); exe != "" { + cmds = append(cmds, []string{exe}) + } + switch runtime.GOOS { + case "darwin": + cmds = append(cmds, []string{"/usr/bin/open"}) + case "windows": + cmds = append(cmds, []string{"cmd", "/c", "start"}) + default: + if os.Getenv("DISPLAY") != "" { + // xdg-open is only for use in a desktop environment. + cmds = append(cmds, []string{"xdg-open"}) + } + } + cmds = append(cmds, + []string{"chrome"}, + []string{"google-chrome"}, + []string{"chromium"}, + []string{"firefox"}, + ) + return cmds +} + +// Open tries to open url in a browser and reports whether it succeeded. +func Open(url string) bool { + for _, args := range Commands() { + cmd := exec.Command(args[0], append(args[1:], url)...) + if cmd.Start() == nil && appearsSuccessful(cmd, 3*time.Second) { + return true + } + } + return false +} + +// appearsSuccessful reports whether the command appears to have run successfully. +// If the command runs longer than the timeout, it's deemed successful. +// If the command runs within the timeout, it's deemed successful if it exited cleanly. +func appearsSuccessful(cmd *exec.Cmd, timeout time.Duration) bool { + errc := make(chan error, 1) + go func() { + errc <- cmd.Wait() + }() + + select { + case <-time.After(timeout): + return true + case err := <-errc: + return err == nil + } +} diff --git a/pkg/terminal/prompt.go b/pkg/terminal/prompt.go new file mode 100644 index 0000000..ac7f96a --- /dev/null +++ b/pkg/terminal/prompt.go @@ -0,0 +1,79 @@ +package terminal + +import ( + "github.com/briandowns/spinner" + "github.com/chzyer/readline" + "github.com/manifoldco/promptui" + + "errors" + "io" + "os" + "strings" + "time" +) + +// PromptConfirm asks a "y/N" question from Stdin +func PromptConfirm(t Terminal, label string) (bool, error) { + _, err := t.RunPrompt(&promptui.Prompt{Label: label, IsConfirm: true}) + if errors.Is(err, promptui.ErrAbort) { + return false, nil + } + return err == nil, err +} + +// PromptAnyKeyOrQuit reads either "q" or any key from Stdin +func PromptAnyKeyOrQuit(t Terminal, prompt string) error { + if ch, err := stdinRawCharPrompt(t, prompt); err != nil { + return err + } else if ch == 113 || ch == 81 { // "Q" or "q" + return promptui.ErrAbort + } + return nil +} + +// stdinRawCharPrompt reads a single character from Stdin +func stdinRawCharPrompt(t Terminal, prompt string) (byte, error) { + stdin := t.IOIn() + + // Enter raw mode, for actual STDIN + if stdin == os.Stdin { + rm := new(readline.RawMode) + if err := rm.Enter(); err != nil { + return 0, err + } + defer rm.Exit() + } + + // Display initial prompt + t.Printf(prompt) + + // Read a single byte from stdin + var b [1]byte + if n, err := stdin.Read(b[:]); err != nil { + return 0, err + } else if n == 0 { + return 0, io.ErrNoProgress + } + + // Add a newline, after success + t.Printf("\n") + + // Return charaacter + return b[0], nil +} + +// IsTerminal true if IOOut is terminal Stdout +func SpinIfTerminal(t Terminal, suffix string) func() { + ioErr := t.IOErr() // can be real os.Stderr or placeholder for testing + if osErr := os.Stderr; ioErr != osErr || !readline.IsTerminal(int(osErr.Fd())) { + return func() {} // IOErr is not a TTY terminal + } + spin := spinner.New(spinner.CharSets[11], 100*time.Millisecond, spinner.WithWriter(ioErr)) + spin.FinalMSG = "\r" + strings.Repeat(" ", 20) + "\r" // Erases previous string + spin.Suffix = suffix + spin.Start() + return func() { + spin.Stop() + t.Printf("\r") + } +} diff --git a/pkg/terminal/terminal.go b/pkg/terminal/terminal.go index ee07a4f..ea3e77d 100644 --- a/pkg/terminal/terminal.go +++ b/pkg/terminal/terminal.go @@ -1,6 +1,7 @@ package terminal import ( + "github.com/gemfury/cli/pkg/browser" "github.com/manifoldco/promptui" "fmt" @@ -13,9 +14,10 @@ type Terminal interface { RunPrompt(*promptui.Prompt) (string, error) Printf(string, ...interface{}) (int, error) Println(a ...interface{}) (n int, err error) + OpenBrowser(string) bool + IOIn() io.ReadCloser IOErr() io.Writer IOOut() io.Writer - IOIn() io.Reader } func New() Terminal { @@ -48,7 +50,7 @@ func (t term) IOOut() io.Writer { return t.ioOut } -func (t term) IOIn() io.Reader { +func (t term) IOIn() io.ReadCloser { return t.ioIn } @@ -57,3 +59,7 @@ func (t term) RunPrompt(p *promptui.Prompt) (string, error) { p.Stdin = t.ioIn return p.Run() } + +func (t term) OpenBrowser(url string) bool { + return browser.Open(url) +} diff --git a/pkg/terminal/testing.go b/pkg/terminal/testing.go index b0caa3b..b2a7941 100644 --- a/pkg/terminal/testing.go +++ b/pkg/terminal/testing.go @@ -53,13 +53,20 @@ func (tt *testTerm) StartProgress(int64, string) Progress { return noProgress{} } +// Fail to open browser progress bar +func (tt *testTerm) OpenBrowser(string) bool { + return false +} + func (tt testTerm) RunPrompt(p *promptui.Prompt) (string, error) { if l, ok := p.Label.(string); ok { if out, ok := tt.prompts[l]; ok { + if out == "ABORT" { + return "", promptui.ErrAbort + } return out, nil } } - return "", io.EOF }