2022-02-10 14:33:27 +00:00
package cli
import (
2022-03-22 19:17:50 +00:00
"errors"
2022-02-10 14:33:27 +00:00
"fmt"
2022-03-29 19:59:32 +00:00
"io"
2022-02-10 14:33:27 +00:00
"net/url"
2022-03-29 19:59:32 +00:00
"os"
2022-02-18 04:09:33 +00:00
"os/exec"
2022-02-10 14:33:27 +00:00
"os/user"
2022-05-20 17:42:01 +00:00
"path"
2022-02-18 04:09:33 +00:00
"runtime"
2022-02-10 14:33:27 +00:00
"strings"
"github.com/go-playground/validator/v10"
2022-02-18 04:09:33 +00:00
"github.com/pkg/browser"
2022-02-10 14:33:27 +00:00
"golang.org/x/xerrors"
2023-03-23 22:42:20 +00:00
"github.com/coder/coder/cli/clibase"
2022-03-22 19:17:50 +00:00
"github.com/coder/coder/cli/cliui"
2023-02-08 20:10:08 +00:00
"github.com/coder/coder/coderd/userpassword"
2022-02-10 14:33:27 +00:00
"github.com/coder/coder/codersdk"
)
2022-02-18 04:09:33 +00:00
const (
goosWindows = "windows"
goosDarwin = "darwin"
)
func init ( ) {
// Hide output from the browser library,
// otherwise we can get really verbose and non-actionable messages
// when in SSH or another type of headless session
// NOTE: This needs to be in `init` to prevent data races
// (multiple threads trying to set the global browser.Std* variables)
2022-03-29 19:59:32 +00:00
browser . Stderr = io . Discard
browser . Stdout = io . Discard
2022-02-18 04:09:33 +00:00
}
2023-03-23 22:42:20 +00:00
func ( r * RootCmd ) login ( ) * clibase . Cmd {
2022-11-16 23:09:49 +00:00
const firstUserTrialEnv = "CODER_FIRST_USER_TRIAL"
2022-06-15 21:02:18 +00:00
var (
email string
username string
password string
2022-11-16 23:09:49 +00:00
trial bool
2022-06-15 21:02:18 +00:00
)
2023-03-23 22:42:20 +00:00
cmd := & clibase . Cmd {
Use : "login <url>" ,
Short : "Authenticate with Coder deployment" ,
Middleware : clibase . RequireRangeArgs ( 0 , 1 ) ,
Handler : func ( inv * clibase . Invocation ) error {
2023-01-07 02:57:25 +00:00
rawURL := ""
2023-03-23 22:42:20 +00:00
if len ( inv . Args ) == 0 {
rawURL = r . clientURL . String ( )
2023-01-07 02:57:25 +00:00
} else {
2023-03-23 22:42:20 +00:00
rawURL = inv . Args [ 0 ]
2023-01-07 02:57:25 +00:00
}
fix: Run expect tests on Windows with conpty pseudo-terminal (#276)
This brings together a bunch of random, partially implemented packages for support of the new(ish) Windows [`conpty`](https://devblogs.microsoft.com/commandline/windows-command-line-introducing-the-windows-pseudo-console-conpty/) API - such that we can leverage the `expect` style of CLI tests, but in a way that works in Linux/OSX `pty`s and Windows `conpty`.
These include:
- Vendoring the `go-expect` library from Netflix w/ some tweaks to work cross-platform
- Vendoring the `pty` cross-platform implementation from [waypoint-plugin-sdk](https://github.com/hashicorp/waypoint-plugin-sdk/tree/b55c787a65ff9b7d2b32cfae80681b78f8f2275e/internal/pkg/pty)
- Vendoring the `conpty` Windows-specific implementation from [waypoint-plugin-sdk](https://github.com/hashicorp/waypoint-plugin-sdk/tree/b55c787a65ff9b7d2b32cfae80681b78f8f2275e/internal/pkg/conpty)
- Adjusting the `pty` interface to work with `go-expect` + the cross-plat version
There were several limitations with the current packages:
- `go-expect` requires the same `os.File` (TTY) for input / output, but `conhost` requires separate file handles
- `conpty` does not handle input, only output
- The cross-platform `pty` didn't expose the full set of primitives needed for `console`
Therefore, the following changes were made:
- Handling of `stdin` was added to the `conpty` interface
- We weren't using the full extent of the `go-expect` interface, so some portions were removed (ie, exec'ing a process) to simplify our implementation and make it easier to extend cross-platform
- Instead of `console` exposing just a `Tty`, it exposes an `InTty` and `OutTty`, to help encapsulate the difference on Windows (on Linux, these point to the same pipe)
Future improvements:
- The `isatty` implementation doesn't support accurate detection of `conhost` pty's without an associated process. In lieu of a more robust check, I've added a `--force-tty` flag intended for test case use - that forces the CLI to run in tty mode.
- It seems the windows implementation doesn't support setting a deadline. This is needed for the expect.Timeout API, but isn't used by us yet.
Fixes #241
2022-02-15 01:05:40 +00:00
2022-02-10 14:33:27 +00:00
if ! strings . HasPrefix ( rawURL , "http://" ) && ! strings . HasPrefix ( rawURL , "https://" ) {
scheme := "https"
if strings . HasPrefix ( rawURL , "localhost" ) {
scheme = "http"
}
rawURL = fmt . Sprintf ( "%s://%s" , scheme , rawURL )
}
serverURL , err := url . Parse ( rawURL )
if err != nil {
return xerrors . Errorf ( "parse raw url %q: %w" , rawURL , err )
}
// Default to HTTPs. Enables simple URLs like: master.cdr.dev
if serverURL . Scheme == "" {
serverURL . Scheme = "https"
}
2023-03-23 22:42:20 +00:00
client , err := r . createUnauthenticatedClient ( serverURL )
2022-09-12 21:22:05 +00:00
if err != nil {
return err
}
2022-06-29 22:49:40 +00:00
// Try to check the version of the server prior to logging in.
// It may be useful to warn the user if they are trying to login
// on a very old client.
2023-03-23 22:42:20 +00:00
err = r . checkVersions ( inv , client )
2022-06-29 22:49:40 +00:00
if err != nil {
2022-07-20 20:17:51 +00:00
// Checking versions isn't a fatal error so we print a warning
// and proceed.
2023-03-23 22:42:20 +00:00
_ , _ = fmt . Fprintln ( inv . Stderr , cliui . Styles . Warn . Render ( err . Error ( ) ) )
2022-06-29 22:49:40 +00:00
}
2023-03-23 22:42:20 +00:00
hasInitialUser , err := client . HasFirstUser ( inv . Context ( ) )
2022-02-10 14:33:27 +00:00
if err != nil {
2022-09-14 20:15:47 +00:00
return xerrors . Errorf ( "Failed to check server %q for first user, is the URL correct and is coder accessible from your browser? Error - has initial user: %w" , serverURL . String ( ) , err )
2022-02-10 14:33:27 +00:00
}
if ! hasInitialUser {
2023-03-23 22:42:20 +00:00
_ , _ = fmt . Fprintf ( inv . Stdout , Caret + "Your Coder deployment hasn't been set up!\n" )
2022-02-10 14:33:27 +00:00
2022-06-15 21:02:18 +00:00
if username == "" {
2023-03-23 22:42:20 +00:00
if ! isTTY ( inv ) {
2022-06-15 21:02:18 +00:00
return xerrors . New ( "the initial user cannot be created in non-interactive mode. use the API" )
}
2023-03-23 22:42:20 +00:00
_ , err := cliui . Prompt ( inv , cliui . PromptOptions {
2022-06-15 21:02:18 +00:00
Text : "Would you like to create the first user?" ,
2022-07-12 17:36:07 +00:00
Default : cliui . ConfirmYes ,
2022-06-15 21:02:18 +00:00
IsConfirm : true ,
} )
if errors . Is ( err , cliui . Canceled ) {
return nil
}
if err != nil {
2022-02-10 14:33:27 +00:00
return err
2022-06-15 21:02:18 +00:00
}
currentUser , err := user . Current ( )
if err != nil {
return xerrors . Errorf ( "get current user: %w" , err )
}
2023-03-23 22:42:20 +00:00
username , err = cliui . Prompt ( inv , cliui . PromptOptions {
2022-06-15 21:02:18 +00:00
Text : "What " + cliui . Styles . Field . Render ( "username" ) + " would you like?" ,
Default : currentUser . Username ,
} )
if errors . Is ( err , cliui . Canceled ) {
return nil
}
if err != nil {
return xerrors . Errorf ( "pick username prompt: %w" , err )
}
2022-02-10 14:33:27 +00:00
}
2022-06-15 21:02:18 +00:00
if email == "" {
2023-03-23 22:42:20 +00:00
email , err = cliui . Prompt ( inv , cliui . PromptOptions {
2022-06-15 21:02:18 +00:00
Text : "What's your " + cliui . Styles . Field . Render ( "email" ) + "?" ,
Validate : func ( s string ) error {
err := validator . New ( ) . Var ( s , "email" )
if err != nil {
return xerrors . New ( "That's not a valid email address!" )
}
return err
} ,
} )
if err != nil {
return xerrors . Errorf ( "specify email prompt: %w" , err )
}
2022-02-10 14:33:27 +00:00
}
2022-06-15 21:02:18 +00:00
if password == "" {
2022-07-01 16:49:39 +00:00
var matching bool
for ! matching {
2023-03-23 22:42:20 +00:00
password , err = cliui . Prompt ( inv , cliui . PromptOptions {
2023-02-08 20:10:08 +00:00
Text : "Enter a " + cliui . Styles . Field . Render ( "password" ) + ":" ,
Secret : true ,
Validate : func ( s string ) error {
return userpassword . Validate ( s )
} ,
2022-07-01 16:49:39 +00:00
} )
if err != nil {
return xerrors . Errorf ( "specify password prompt: %w" , err )
}
2023-03-23 22:42:20 +00:00
confirm , err := cliui . Prompt ( inv , cliui . PromptOptions {
2023-02-08 20:10:08 +00:00
Text : "Confirm " + cliui . Styles . Field . Render ( "password" ) + ":" ,
Secret : true ,
Validate : cliui . ValidateNotEmpty ,
2022-07-01 16:49:39 +00:00
} )
if err != nil {
return xerrors . Errorf ( "confirm password prompt: %w" , err )
}
matching = confirm == password
if ! matching {
2023-03-23 22:42:20 +00:00
_ , _ = fmt . Fprintln ( inv . Stdout , cliui . Styles . Error . Render ( "Passwords do not match" ) )
2022-07-01 16:49:39 +00:00
}
2022-06-15 21:02:18 +00:00
}
2022-05-06 12:47:38 +00:00
}
2022-02-10 14:33:27 +00:00
2023-03-23 22:42:20 +00:00
if ! inv . ParsedFlags ( ) . Changed ( "first-user-trial" ) && os . Getenv ( firstUserTrialEnv ) == "" {
v , _ := cliui . Prompt ( inv , cliui . PromptOptions {
2022-11-16 23:09:49 +00:00
Text : "Start a 30-day trial of Enterprise?" ,
IsConfirm : true ,
Default : "yes" ,
} )
trial = v == "yes" || v == "y"
}
2023-03-23 22:42:20 +00:00
_ , err = client . CreateFirstUser ( inv . Context ( ) , codersdk . CreateFirstUserRequest {
2022-11-16 23:09:49 +00:00
Email : email ,
Username : username ,
Password : password ,
Trial : trial ,
2022-02-10 14:33:27 +00:00
} )
if err != nil {
return xerrors . Errorf ( "create initial user: %w" , err )
}
2023-03-23 22:42:20 +00:00
resp , err := client . LoginWithPassword ( inv . Context ( ) , codersdk . LoginWithPasswordRequest {
2022-02-10 14:33:27 +00:00
Email : email ,
Password : password ,
} )
if err != nil {
return xerrors . Errorf ( "login with password: %w" , err )
}
2022-02-18 04:09:33 +00:00
sessionToken := resp . SessionToken
2023-03-23 22:42:20 +00:00
config := r . createConfig ( )
2022-02-18 04:09:33 +00:00
err = config . Session ( ) . Write ( sessionToken )
2022-02-10 14:33:27 +00:00
if err != nil {
return xerrors . Errorf ( "write session token: %w" , err )
}
err = config . URL ( ) . Write ( serverURL . String ( ) )
if err != nil {
return xerrors . Errorf ( "write server url: %w" , err )
}
2023-03-23 22:42:20 +00:00
_ , _ = fmt . Fprintf ( inv . Stdout ,
2022-03-22 19:17:50 +00:00
cliui . Styles . Paragraph . Render ( fmt . Sprintf ( "Welcome to Coder, %s! You're authenticated." , cliui . Styles . Keyword . Render ( username ) ) ) + "\n" )
2023-03-23 22:42:20 +00:00
_ , _ = fmt . Fprintf ( inv . Stdout ,
2022-05-19 12:49:40 +00:00
cliui . Styles . Paragraph . Render ( "Get started by creating a template: " + cliui . Styles . Code . Render ( "coder templates init" ) ) + "\n" )
2022-02-10 14:33:27 +00:00
return nil
}
2023-03-23 22:42:20 +00:00
sessionToken , _ := inv . ParsedFlags ( ) . GetString ( varToken )
2022-05-16 18:07:35 +00:00
if sessionToken == "" {
authURL := * serverURL
2022-05-20 17:42:01 +00:00
// Don't use filepath.Join, we don't want to use the os separator
// for a url.
authURL . Path = path . Join ( serverURL . Path , "/cli-auth" )
2023-03-23 22:42:20 +00:00
if err := openURL ( inv , authURL . String ( ) ) ; err != nil {
_ , _ = fmt . Fprintf ( inv . Stdout , "Open the following in your browser:\n\n\t%s\n\n" , authURL . String ( ) )
2022-05-16 18:07:35 +00:00
} else {
2023-03-23 22:42:20 +00:00
_ , _ = fmt . Fprintf ( inv . Stdout , "Your browser has been opened to visit:\n\n\t%s\n\n" , authURL . String ( ) )
2022-05-16 18:07:35 +00:00
}
2022-02-18 04:09:33 +00:00
2023-03-23 22:42:20 +00:00
sessionToken , err = cliui . Prompt ( inv , cliui . PromptOptions {
2022-05-16 18:07:35 +00:00
Text : "Paste your token here:" ,
Secret : true ,
Validate : func ( token string ) error {
2022-11-09 13:31:24 +00:00
client . SetSessionToken ( token )
2023-03-23 22:42:20 +00:00
_ , err := client . User ( inv . Context ( ) , codersdk . Me )
2022-05-16 18:07:35 +00:00
if err != nil {
return xerrors . New ( "That's not a valid token!" )
}
return err
} ,
} )
if err != nil {
return xerrors . Errorf ( "paste token prompt: %w" , err )
}
2022-02-18 04:09:33 +00:00
}
// Login to get user data - verify it is OK before persisting
2022-11-09 13:31:24 +00:00
client . SetSessionToken ( sessionToken )
2023-03-23 22:42:20 +00:00
resp , err := client . User ( inv . Context ( ) , codersdk . Me )
2022-02-18 04:09:33 +00:00
if err != nil {
return xerrors . Errorf ( "get user: %w" , err )
}
2023-03-23 22:42:20 +00:00
config := r . createConfig ( )
2022-02-18 04:09:33 +00:00
err = config . Session ( ) . Write ( sessionToken )
if err != nil {
return xerrors . Errorf ( "write session token: %w" , err )
}
err = config . URL ( ) . Write ( serverURL . String ( ) )
if err != nil {
return xerrors . Errorf ( "write server url: %w" , err )
}
2023-03-23 22:42:20 +00:00
_ , _ = fmt . Fprintf ( inv . Stdout , Caret + "Welcome to Coder, %s! You're authenticated.\n" , cliui . Styles . Keyword . Render ( resp . Username ) )
2022-02-10 14:33:27 +00:00
return nil
} ,
}
2023-03-23 22:42:20 +00:00
cmd . Options = clibase . OptionSet {
{
Flag : "first-user-email" ,
Env : "CODER_FIRST_USER_EMAIL" ,
Description : "Specifies an email address to use if creating the first user for the deployment." ,
Value : clibase . StringOf ( & email ) ,
} ,
{
Flag : "first-user-username" ,
Env : "CODER_FIRST_USER_USERNAME" ,
Description : "Specifies a username to use if creating the first user for the deployment." ,
Value : clibase . StringOf ( & username ) ,
} ,
{
Flag : "first-user-password" ,
Env : "CODER_FIRST_USER_PASSWORD" ,
Description : "Specifies a password to use if creating the first user for the deployment." ,
Value : clibase . StringOf ( & password ) ,
} ,
{
Flag : "first-user-trial" ,
Env : firstUserTrialEnv ,
Description : "Specifies whether a trial license should be provisioned for the Coder deployment or not." ,
Value : clibase . BoolOf ( & trial ) ,
} ,
}
2022-06-15 21:02:18 +00:00
return cmd
2022-02-10 14:33:27 +00:00
}
2022-02-18 04:09:33 +00:00
// isWSL determines if coder-cli is running within Windows Subsystem for Linux
func isWSL ( ) ( bool , error ) {
if runtime . GOOS == goosDarwin || runtime . GOOS == goosWindows {
return false , nil
}
2022-03-29 19:59:32 +00:00
data , err := os . ReadFile ( "/proc/version" )
2022-02-18 04:09:33 +00:00
if err != nil {
return false , xerrors . Errorf ( "read /proc/version: %w" , err )
}
return strings . Contains ( strings . ToLower ( string ( data ) ) , "microsoft" ) , nil
}
// openURL opens the provided URL via user's default browser
2023-03-23 22:42:20 +00:00
func openURL ( inv * clibase . Invocation , urlToOpen string ) error {
noOpen , err := inv . ParsedFlags ( ) . GetBool ( varNoOpen )
2022-02-21 17:10:05 +00:00
if err != nil {
panic ( err )
}
if noOpen {
return xerrors . New ( "opening is blocked" )
}
2022-02-18 04:09:33 +00:00
wsl , err := isWSL ( )
if err != nil {
return xerrors . Errorf ( "test running Windows Subsystem for Linux: %w" , err )
}
if wsl {
2022-02-21 17:10:05 +00:00
// #nosec
return exec . Command ( "cmd.exe" , "/c" , "start" , strings . ReplaceAll ( urlToOpen , "&" , "^&" ) ) . Start ( )
2022-02-18 04:09:33 +00:00
}
2022-10-25 00:46:24 +00:00
browserEnv := os . Getenv ( "BROWSER" )
if browserEnv != "" {
browserSh := fmt . Sprintf ( "%s '%s'" , browserEnv , urlToOpen )
2023-03-23 22:42:20 +00:00
cmd := exec . CommandContext ( inv . Context ( ) , "sh" , "-c" , browserSh )
2022-10-25 00:46:24 +00:00
out , err := cmd . CombinedOutput ( )
if err != nil {
return xerrors . Errorf ( "failed to run %v (out: %q): %w" , cmd . Args , out , err )
}
return nil
}
2022-02-18 04:09:33 +00:00
return browser . OpenURL ( urlToOpen )
}