feat: Add "coder" CLI (#221)

* 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
This commit is contained in:
Kyle Carberry
2022-02-10 08:33:27 -06:00
committed by GitHub
parent 277318bdb4
commit 07fe5ced68
24 changed files with 921 additions and 7 deletions
+38
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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)
})
}
+151
View File
@@ -0,0 +1,151 @@
package cli
import (
"archive/tar"
"bytes"
"context"
"fmt"
"io"
"os"
"path/filepath"
"time"
"github.com/briandowns/spinner"
"github.com/fatih/color"
"github.com/manifoldco/promptui"
"github.com/spf13/cobra"
"golang.org/x/xerrors"
"github.com/coder/coder/coderd"
"github.com/coder/coder/codersdk"
"github.com/coder/coder/database"
)
func projectCreate() *cobra.Command {
return &cobra.Command{
Use: "create",
Short: "Create a project from the current directory",
RunE: func(cmd *cobra.Command, args []string) error {
client, err := createClient(cmd)
if err != nil {
return err
}
organization, err := currentOrganization(cmd, client)
if err != nil {
return err
}
workingDir, err := os.Getwd()
if err != nil {
return err
}
_, err = runPrompt(cmd, &promptui.Prompt{
Default: "y",
IsConfirm: true,
Label: fmt.Sprintf("Set up %s in your organization?", color.New(color.FgHiCyan).Sprintf("%q", workingDir)),
})
if err != nil {
return err
}
name, err := runPrompt(cmd, &promptui.Prompt{
Default: filepath.Base(workingDir),
Label: "What's your project's name?",
Validate: func(s string) error {
_, err = client.Project(cmd.Context(), organization.Name, s)
if err == nil {
return xerrors.New("A project already exists with that name!")
}
return nil
},
})
if err != nil {
return err
}
spin := spinner.New(spinner.CharSets[0], 50*time.Millisecond)
spin.Suffix = " Uploading current directory..."
spin.Start()
defer spin.Stop()
bytes, err := tarDirectory(workingDir)
if err != nil {
return err
}
resp, err := client.UploadFile(cmd.Context(), codersdk.ContentTypeTar, bytes)
if err != nil {
return err
}
job, err := client.CreateProjectVersionImportProvisionerJob(cmd.Context(), organization.Name, coderd.CreateProjectImportJobRequest{
StorageMethod: database.ProvisionerStorageMethodFile,
StorageSource: resp.Hash,
Provisioner: database.ProvisionerTypeTerraform,
// SkipResources on first import to detect variables defined by the project.
SkipResources: true,
})
if err != nil {
return err
}
spin.Stop()
logs, err := client.FollowProvisionerJobLogsAfter(context.Background(), organization.Name, job.ID, time.Time{})
if err != nil {
return err
}
for {
log, ok := <-logs
if !ok {
break
}
_, _ = fmt.Fprintf(cmd.OutOrStdout(), "%s %s\n", color.HiGreenString("[parse]"), log.Output)
}
_, _ = fmt.Fprintf(cmd.OutOrStdout(), "Create project %q!\n", name)
return nil
},
}
}
func tarDirectory(directory string) ([]byte, error) {
var buffer bytes.Buffer
tarWriter := tar.NewWriter(&buffer)
err := filepath.Walk(directory, func(file string, fileInfo os.FileInfo, err error) error {
if err != nil {
return err
}
header, err := tar.FileInfoHeader(fileInfo, file)
if err != nil {
return err
}
rel, err := filepath.Rel(directory, file)
if err != nil {
return err
}
header.Name = rel
if err := tarWriter.WriteHeader(header); err != nil {
return err
}
if fileInfo.IsDir() {
return nil
}
data, err := os.Open(file)
if err != nil {
return err
}
if _, err := io.Copy(tarWriter, data); err != nil {
return err
}
return data.Close()
})
if err != nil {
return nil, err
}
err = tarWriter.Flush()
if err != nil {
return nil, err
}
return buffer.Bytes(), nil
}
+16
View File
@@ -0,0 +1,16 @@
package cli
import (
"github.com/spf13/cobra"
)
func projectPlan() *cobra.Command {
return &cobra.Command{
Use: "plan <directory>",
Args: cobra.MinimumNArgs(1),
Short: "Plan a project update from the current directory",
RunE: func(cmd *cobra.Command, args []string) error {
return nil
},
}
}
+30
View File
@@ -0,0 +1,30 @@
package cli
import (
"github.com/fatih/color"
"github.com/spf13/cobra"
)
func projects() *cobra.Command {
cmd := &cobra.Command{
Use: "projects",
Long: "Testing something",
Example: `
- Create a project for developers to create workspaces
` + color.New(color.FgHiMagenta).Sprint("$ coder projects create") + `
- Make changes to your project, and plan the changes
` + color.New(color.FgHiMagenta).Sprint("$ coder projects plan <name>") + `
- Update the project. Your developers can update their workspaces
` + color.New(color.FgHiMagenta).Sprint("$ coder projects update <name>"),
}
cmd.AddCommand(projectCreate())
cmd.AddCommand(projectPlan())
cmd.AddCommand(projectUpdate())
return cmd
}
+14
View File
@@ -0,0 +1,14 @@
package cli
import "github.com/spf13/cobra"
func projectUpdate() *cobra.Command {
return &cobra.Command{
Use: "update <name>",
Args: cobra.MinimumNArgs(1),
Short: "Update a project from the current directory",
RunE: func(cmd *cobra.Command, args []string) error {
return nil
},
}
}
+170
View File
@@ -0,0 +1,170 @@
package cli
import (
"fmt"
"io"
"net/url"
"os"
"strings"
"github.com/fatih/color"
"github.com/kirsle/configdir"
"github.com/manifoldco/promptui"
"github.com/mattn/go-isatty"
"github.com/spf13/cobra"
"golang.org/x/xerrors"
"github.com/coder/coder/cli/config"
"github.com/coder/coder/coderd"
"github.com/coder/coder/codersdk"
)
const (
varGlobalConfig = "global-config"
)
func Root() *cobra.Command {
cmd := &cobra.Command{
Use: "coder",
Long: ` ▄█▀ ▀█▄
▄▄ ▀▀▀ █▌ ██▀▀█▄ ▐█
▄▄██▀▀█▄▄▄ ██ ██ █▀▀█ ▐█▀▀██ ▄█▀▀█ █▀▀
█▌ ▄▌ ▐█ █▌ ▀█▄▄▄█▌ █ █ ▐█ ██ ██▀▀ █
██████▀▄█ ▀▀▀▀ ▀▀▀▀ ▀▀▀▀▀ ▀▀▀▀ ▀
` + color.New(color.Underline).Sprint("Self-hosted developer workspaces on your infra") + `
`,
Example: `
- Create a project for developers to create workspaces
` + color.New(color.FgHiMagenta).Sprint("$ coder projects create <directory>") + `
- Create a workspace for a specific project
` + color.New(color.FgHiMagenta).Sprint("$ coder workspaces create <project>") + `
- Maintain consistency by updating a workspace
` + color.New(color.FgHiMagenta).Sprint("$ coder workspaces update <workspace>"),
}
// Customizes the color of headings to make subcommands
// more visually appealing.
header := color.New(color.FgHiBlack)
cmd.SetUsageTemplate(strings.NewReplacer(
`Usage:`, header.Sprint("Usage:"),
`Examples:`, header.Sprint("Examples:"),
`Available Commands:`, header.Sprint("Commands:"),
`Global Flags:`, header.Sprint("Global Flags:"),
`Flags:`, header.Sprint("Flags:"),
`Additional help topics:`, header.Sprint("Additional help:"),
).Replace(cmd.UsageTemplate()))
cmd.AddCommand(login())
cmd.AddCommand(projects())
cmd.AddCommand(workspaces())
cmd.AddCommand(users())
cmd.PersistentFlags().String(varGlobalConfig, configdir.LocalConfig("coder"), "Path to the global `coder` config directory")
return cmd
}
func createClient(cmd *cobra.Command) (*codersdk.Client, error) {
root := createConfig(cmd)
rawURL, err := root.URL().Read()
if err != nil {
return nil, err
}
serverURL, err := url.Parse(rawURL)
if err != nil {
return nil, err
}
token, err := root.Session().Read()
if err != nil {
return nil, err
}
client := codersdk.New(serverURL)
return client, client.SetSessionToken(token)
}
func currentOrganization(cmd *cobra.Command, client *codersdk.Client) (coderd.Organization, error) {
orgs, err := client.UserOrganizations(cmd.Context(), "me")
if err != nil {
return coderd.Organization{}, nil
}
// For now, we won't use the config to set this.
// Eventually, we will support changing using "coder switch <org>"
return orgs[0], nil
}
func createConfig(cmd *cobra.Command) config.Root {
globalRoot, err := cmd.Flags().GetString(varGlobalConfig)
if err != nil {
panic(err)
}
return config.Root(globalRoot)
}
// isTTY returns whether the passed reader is a TTY or not.
// This accepts a reader to work with Cobra's "InOrStdin"
// function for simple testing.
func isTTY(reader io.Reader) bool {
file, ok := reader.(*os.File)
if !ok {
return false
}
return isatty.IsTerminal(file.Fd())
}
func runPrompt(cmd *cobra.Command, prompt *promptui.Prompt) (string, error) {
var ok bool
prompt.Stdin, ok = cmd.InOrStdin().(io.ReadCloser)
if !ok {
return "", xerrors.New("stdin must be a readcloser")
}
prompt.Stdout, ok = cmd.OutOrStdout().(io.WriteCloser)
if !ok {
return "", xerrors.New("stdout must be a readcloser")
}
// The prompt library displays defaults in a jarring way for the user
// by attempting to autocomplete it. This sets no default enabling us
// to customize the display.
defaultValue := prompt.Default
if !prompt.IsConfirm {
prompt.Default = ""
}
// Rewrite the confirm template to remove bold, and fit to the Coder style.
confirmEnd := fmt.Sprintf("[y/%s] ", color.New(color.Bold).Sprint("N"))
if prompt.Default == "y" {
confirmEnd = fmt.Sprintf("[%s/n] ", color.New(color.Bold).Sprint("Y"))
}
confirm := color.HiBlackString("?") + ` {{ . }} ` + confirmEnd
// Customize to remove bold.
valid := color.HiBlackString("?") + " {{ . }} "
if defaultValue != "" {
valid += fmt.Sprintf("(%s) ", defaultValue)
}
success := valid
invalid := valid
if prompt.IsConfirm {
success = confirm
invalid = confirm
}
prompt.Templates = &promptui.PromptTemplates{
Confirm: confirm,
Success: success,
Invalid: invalid,
Valid: valid,
}
value, err := prompt.Run()
if value == "" && !prompt.IsConfirm {
value = defaultValue
}
return value, err
}
+1
View File
@@ -0,0 +1 @@
package cli
+10
View File
@@ -0,0 +1,10 @@
package cli
import "github.com/spf13/cobra"
func users() *cobra.Command {
cmd := &cobra.Command{
Use: "users",
}
return cmd
}
+11
View File
@@ -0,0 +1,11 @@
package cli
import "github.com/spf13/cobra"
func workspaces() *cobra.Command {
cmd := &cobra.Command{
Use: "workspaces",
}
return cmd
}