Skip to content

Commit

Permalink
feat: Add "coder" CLI (#221)
Browse files Browse the repository at this point in the history
* 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
kylecarbs committed Feb 10, 2022
1 parent 277318b commit 07fe5ce
Show file tree
Hide file tree
Showing 24 changed files with 921 additions and 7 deletions.
6 changes: 6 additions & 0 deletions .vscode/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -31,16 +31,22 @@
"drpcconn",
"drpcmux",
"drpcserver",
"fatih",
"goleak",
"hashicorp",
"httpmw",
"isatty",
"Jobf",
"kirsle",
"manifoldco",
"mattn",
"moby",
"nhooyr",
"nolint",
"nosec",
"oneof",
"parameterscopeid",
"promptui",
"protobuf",
"provisionerd",
"provisionersdk",
Expand Down
7 changes: 6 additions & 1 deletion Makefile
Original file line number Diff line number Diff line change
@@ -1,9 +1,14 @@
bin/coder:
mkdir -p bin
go build -o bin/coder cmd/coder/main.go
.PHONY: bin/coder

bin/coderd:
mkdir -p bin
go build -o bin/coderd cmd/coderd/main.go
.PHONY: bin/coderd

build: site/out bin/coderd
build: site/out bin/coder bin/coderd
.PHONY: build

# Runs migrations to output a dump of the database.
Expand Down
38 changes: 38 additions & 0 deletions cli/clitest/clitest.go
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
}
71 changes: 71 additions & 0 deletions cli/config/file.go
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)
}
38 changes: 38 additions & 0 deletions cli/config/file_test.go
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)
})
}
135 changes: 135 additions & 0 deletions cli/login.go
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
},
}
}
56 changes: 56 additions & 0 deletions cli/login_test.go
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)
})
}

0 comments on commit 07fe5ce

Please sign in to comment.