-
Notifications
You must be signed in to change notification settings - Fork 892
feat: Add "coder" CLI #221
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
d6a1eb8
24cc781
ddb5756
d8b4e88
fc6d9da
4f74b01
aed4a62
6aec3a1
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,38 @@ | ||
package clitest | ||
jawnsy marked this conversation as resolved.
Show resolved
Hide resolved
|
||
|
||
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 | ||
} |
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) | ||
} |
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) | ||
}) | ||
} |
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>", | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. We should have tests in place that run the CLI on Windows / Linux / Mac - maybe that would be a good-first-issue for getting someone on-boarded in the repo? There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I actually just made some! I wasn't sure how to scaffold it, but I think it's OK for now. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I guess having support for a fake db should make these tests easier now! |
||
Args: cobra.ExactArgs(1), | ||
RunE: func(cmd *cobra.Command, args []string) error { | ||
rawURL := args[0] | ||
if !strings.HasPrefix(rawURL, "http://") && !strings.HasPrefix(rawURL, "https://") { | ||
kylecarbs marked this conversation as resolved.
Show resolved
Hide resolved
|
||
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()) | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. That's cool that it guides you through set-up the first time you login, if there isn't a user available yet. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Agreed! I wanted the full flow to be available. I added tests now too! |
||
if err != nil { | ||
return xerrors.Errorf("has initial user: %w", err) | ||
} | ||
kylecarbs marked this conversation as resolved.
Show resolved
Hide resolved
|
||
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 | ||
}, | ||
} | ||
} |
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", "[email protected]", | ||
"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) | ||
}) | ||
} |
Uh oh!
There was an error while loading. Please reload this page.