-
Notifications
You must be signed in to change notification settings - Fork 582
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
* feat: Add "coder" CLI * Add CLI test for login * Add "bin/coder" target to Makefile * Update promptui to fix race * Fix error scope * Don't run CLI tests on Windows * Fix requested changes
- Loading branch information
Showing
24 changed files
with
921 additions
and
7 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,38 @@ | ||
package clitest | ||
|
||
import ( | ||
"bufio" | ||
"io" | ||
"testing" | ||
|
||
"github.com/spf13/cobra" | ||
|
||
"github.com/coder/coder/cli" | ||
"github.com/coder/coder/cli/config" | ||
) | ||
|
||
func New(t *testing.T, args ...string) (*cobra.Command, config.Root) { | ||
cmd := cli.Root() | ||
dir := t.TempDir() | ||
root := config.Root(dir) | ||
cmd.SetArgs(append([]string{"--global-config", dir}, args...)) | ||
return cmd, root | ||
} | ||
|
||
func StdoutLogs(t *testing.T) io.Writer { | ||
reader, writer := io.Pipe() | ||
scanner := bufio.NewScanner(reader) | ||
t.Cleanup(func() { | ||
_ = reader.Close() | ||
_ = writer.Close() | ||
}) | ||
go func() { | ||
for scanner.Scan() { | ||
if scanner.Err() != nil { | ||
return | ||
} | ||
t.Log(scanner.Text()) | ||
} | ||
}() | ||
return writer | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,71 @@ | ||
package config | ||
|
||
import ( | ||
"io/ioutil" | ||
"os" | ||
"path/filepath" | ||
) | ||
|
||
// Root represents the configuration directory. | ||
type Root string | ||
|
||
func (r Root) Session() File { | ||
return File(filepath.Join(string(r), "session")) | ||
} | ||
|
||
func (r Root) URL() File { | ||
return File(filepath.Join(string(r), "url")) | ||
} | ||
|
||
func (r Root) Organization() File { | ||
return File(filepath.Join(string(r), "organization")) | ||
} | ||
|
||
// File provides convenience methods for interacting with *os.File. | ||
type File string | ||
|
||
// Delete deletes the file. | ||
func (f File) Delete() error { | ||
return os.Remove(string(f)) | ||
} | ||
|
||
// Write writes the string to the file. | ||
func (f File) Write(s string) error { | ||
return write(string(f), 0600, []byte(s)) | ||
} | ||
|
||
// Read reads the file to a string. | ||
func (f File) Read() (string, error) { | ||
byt, err := read(string(f)) | ||
return string(byt), err | ||
} | ||
|
||
// open opens a file in the configuration directory, | ||
// creating all intermediate directories. | ||
func open(path string, flag int, mode os.FileMode) (*os.File, error) { | ||
err := os.MkdirAll(filepath.Dir(path), 0750) | ||
if err != nil { | ||
return nil, err | ||
} | ||
|
||
return os.OpenFile(path, flag, mode) | ||
} | ||
|
||
func write(path string, mode os.FileMode, dat []byte) error { | ||
fi, err := open(path, os.O_WRONLY|os.O_TRUNC|os.O_CREATE, mode) | ||
if err != nil { | ||
return err | ||
} | ||
defer fi.Close() | ||
_, err = fi.Write(dat) | ||
return err | ||
} | ||
|
||
func read(path string) ([]byte, error) { | ||
fi, err := open(path, os.O_RDONLY, 0) | ||
if err != nil { | ||
return nil, err | ||
} | ||
defer fi.Close() | ||
return ioutil.ReadAll(fi) | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,38 @@ | ||
package config_test | ||
|
||
import ( | ||
"testing" | ||
|
||
"github.com/stretchr/testify/require" | ||
|
||
"github.com/coder/coder/cli/config" | ||
) | ||
|
||
func TestFile(t *testing.T) { | ||
t.Parallel() | ||
|
||
t.Run("Write", func(t *testing.T) { | ||
t.Parallel() | ||
err := config.Root(t.TempDir()).Session().Write("test") | ||
require.NoError(t, err) | ||
}) | ||
|
||
t.Run("Read", func(t *testing.T) { | ||
t.Parallel() | ||
root := config.Root(t.TempDir()) | ||
err := root.Session().Write("test") | ||
require.NoError(t, err) | ||
data, err := root.Session().Read() | ||
require.NoError(t, err) | ||
require.Equal(t, "test", data) | ||
}) | ||
|
||
t.Run("Delete", func(t *testing.T) { | ||
t.Parallel() | ||
root := config.Root(t.TempDir()) | ||
err := root.Session().Write("test") | ||
require.NoError(t, err) | ||
err = root.Session().Delete() | ||
require.NoError(t, err) | ||
}) | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,135 @@ | ||
package cli | ||
|
||
import ( | ||
"fmt" | ||
"net/url" | ||
"os/user" | ||
"strings" | ||
|
||
"github.com/fatih/color" | ||
"github.com/go-playground/validator/v10" | ||
"github.com/manifoldco/promptui" | ||
"github.com/spf13/cobra" | ||
"golang.org/x/xerrors" | ||
|
||
"github.com/coder/coder/coderd" | ||
"github.com/coder/coder/codersdk" | ||
) | ||
|
||
func login() *cobra.Command { | ||
return &cobra.Command{ | ||
Use: "login <url>", | ||
Args: cobra.ExactArgs(1), | ||
RunE: func(cmd *cobra.Command, args []string) error { | ||
rawURL := args[0] | ||
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" | ||
} | ||
|
||
client := codersdk.New(serverURL) | ||
hasInitialUser, err := client.HasInitialUser(cmd.Context()) | ||
if err != nil { | ||
return xerrors.Errorf("has initial user: %w", err) | ||
} | ||
if !hasInitialUser { | ||
if !isTTY(cmd.InOrStdin()) { | ||
return xerrors.New("the initial user cannot be created in non-interactive mode. use the API") | ||
} | ||
_, _ = fmt.Fprintf(cmd.OutOrStdout(), "%s Your Coder deployment hasn't been set up!\n", color.HiBlackString(">")) | ||
|
||
_, err := runPrompt(cmd, &promptui.Prompt{ | ||
Label: "Would you like to create the first user?", | ||
IsConfirm: true, | ||
Default: "y", | ||
}) | ||
if err != nil { | ||
return xerrors.Errorf("create user prompt: %w", err) | ||
} | ||
currentUser, err := user.Current() | ||
if err != nil { | ||
return xerrors.Errorf("get current user: %w", err) | ||
} | ||
username, err := runPrompt(cmd, &promptui.Prompt{ | ||
Label: "What username would you like?", | ||
Default: currentUser.Username, | ||
}) | ||
if err != nil { | ||
return xerrors.Errorf("pick username prompt: %w", err) | ||
} | ||
|
||
organization, err := runPrompt(cmd, &promptui.Prompt{ | ||
Label: "What is the name of your organization?", | ||
Default: "acme-corp", | ||
}) | ||
if err != nil { | ||
return xerrors.Errorf("pick organization prompt: %w", err) | ||
} | ||
|
||
email, err := runPrompt(cmd, &promptui.Prompt{ | ||
Label: "What's your 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) | ||
} | ||
|
||
password, err := runPrompt(cmd, &promptui.Prompt{ | ||
Label: "Enter a password:", | ||
Mask: '*', | ||
}) | ||
if err != nil { | ||
return xerrors.Errorf("specify password prompt: %w", err) | ||
} | ||
|
||
_, err = client.CreateInitialUser(cmd.Context(), coderd.CreateInitialUserRequest{ | ||
Email: email, | ||
Username: username, | ||
Password: password, | ||
Organization: organization, | ||
}) | ||
if err != nil { | ||
return xerrors.Errorf("create initial user: %w", err) | ||
} | ||
resp, err := client.LoginWithPassword(cmd.Context(), coderd.LoginWithPasswordRequest{ | ||
Email: email, | ||
Password: password, | ||
}) | ||
if err != nil { | ||
return xerrors.Errorf("login with password: %w", err) | ||
} | ||
config := createConfig(cmd) | ||
err = config.Session().Write(resp.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) | ||
} | ||
|
||
_, _ = fmt.Fprintf(cmd.OutOrStdout(), "%s Welcome to Coder, %s! You're authenticated.\n", color.HiBlackString(">"), color.HiCyanString(username)) | ||
return nil | ||
} | ||
|
||
return nil | ||
}, | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,56 @@ | ||
//go:build !windows | ||
|
||
package cli_test | ||
|
||
import ( | ||
"testing" | ||
|
||
"github.com/coder/coder/cli/clitest" | ||
"github.com/coder/coder/coderd/coderdtest" | ||
"github.com/stretchr/testify/require" | ||
|
||
"github.com/Netflix/go-expect" | ||
) | ||
|
||
func TestLogin(t *testing.T) { | ||
t.Parallel() | ||
t.Run("InitialUserNoTTY", func(t *testing.T) { | ||
t.Parallel() | ||
client := coderdtest.New(t) | ||
root, _ := clitest.New(t, "login", client.URL.String()) | ||
err := root.Execute() | ||
require.Error(t, err) | ||
}) | ||
|
||
t.Run("InitialUserTTY", func(t *testing.T) { | ||
t.Parallel() | ||
console, err := expect.NewConsole(expect.WithStdout(clitest.StdoutLogs(t))) | ||
require.NoError(t, err) | ||
client := coderdtest.New(t) | ||
root, _ := clitest.New(t, "login", client.URL.String()) | ||
root.SetIn(console.Tty()) | ||
root.SetOut(console.Tty()) | ||
go func() { | ||
err := root.Execute() | ||
require.NoError(t, err) | ||
}() | ||
|
||
matches := []string{ | ||
"first user?", "y", | ||
"username", "testuser", | ||
"organization", "testorg", | ||
"email", "user@coder.com", | ||
"password", "password", | ||
} | ||
for i := 0; i < len(matches); i += 2 { | ||
match := matches[i] | ||
value := matches[i+1] | ||
_, err = console.ExpectString(match) | ||
require.NoError(t, err) | ||
_, err = console.SendLine(value) | ||
require.NoError(t, err) | ||
} | ||
_, err = console.ExpectString("Welcome to Coder") | ||
require.NoError(t, err) | ||
}) | ||
} |
Oops, something went wrong.