Skip to content

Commit 07fe5ce

Browse files
authored
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
1 parent 277318b commit 07fe5ce

24 files changed

+921
-7
lines changed

.vscode/settings.json

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,16 +31,22 @@
3131
"drpcconn",
3232
"drpcmux",
3333
"drpcserver",
34+
"fatih",
3435
"goleak",
3536
"hashicorp",
3637
"httpmw",
38+
"isatty",
3739
"Jobf",
40+
"kirsle",
41+
"manifoldco",
42+
"mattn",
3843
"moby",
3944
"nhooyr",
4045
"nolint",
4146
"nosec",
4247
"oneof",
4348
"parameterscopeid",
49+
"promptui",
4450
"protobuf",
4551
"provisionerd",
4652
"provisionersdk",

Makefile

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,14 @@
1+
bin/coder:
2+
mkdir -p bin
3+
go build -o bin/coder cmd/coder/main.go
4+
.PHONY: bin/coder
5+
16
bin/coderd:
27
mkdir -p bin
38
go build -o bin/coderd cmd/coderd/main.go
49
.PHONY: bin/coderd
510

6-
build: site/out bin/coderd
11+
build: site/out bin/coder bin/coderd
712
.PHONY: build
813

914
# Runs migrations to output a dump of the database.

cli/clitest/clitest.go

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
package clitest
2+
3+
import (
4+
"bufio"
5+
"io"
6+
"testing"
7+
8+
"github.com/spf13/cobra"
9+
10+
"github.com/coder/coder/cli"
11+
"github.com/coder/coder/cli/config"
12+
)
13+
14+
func New(t *testing.T, args ...string) (*cobra.Command, config.Root) {
15+
cmd := cli.Root()
16+
dir := t.TempDir()
17+
root := config.Root(dir)
18+
cmd.SetArgs(append([]string{"--global-config", dir}, args...))
19+
return cmd, root
20+
}
21+
22+
func StdoutLogs(t *testing.T) io.Writer {
23+
reader, writer := io.Pipe()
24+
scanner := bufio.NewScanner(reader)
25+
t.Cleanup(func() {
26+
_ = reader.Close()
27+
_ = writer.Close()
28+
})
29+
go func() {
30+
for scanner.Scan() {
31+
if scanner.Err() != nil {
32+
return
33+
}
34+
t.Log(scanner.Text())
35+
}
36+
}()
37+
return writer
38+
}

cli/config/file.go

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
package config
2+
3+
import (
4+
"io/ioutil"
5+
"os"
6+
"path/filepath"
7+
)
8+
9+
// Root represents the configuration directory.
10+
type Root string
11+
12+
func (r Root) Session() File {
13+
return File(filepath.Join(string(r), "session"))
14+
}
15+
16+
func (r Root) URL() File {
17+
return File(filepath.Join(string(r), "url"))
18+
}
19+
20+
func (r Root) Organization() File {
21+
return File(filepath.Join(string(r), "organization"))
22+
}
23+
24+
// File provides convenience methods for interacting with *os.File.
25+
type File string
26+
27+
// Delete deletes the file.
28+
func (f File) Delete() error {
29+
return os.Remove(string(f))
30+
}
31+
32+
// Write writes the string to the file.
33+
func (f File) Write(s string) error {
34+
return write(string(f), 0600, []byte(s))
35+
}
36+
37+
// Read reads the file to a string.
38+
func (f File) Read() (string, error) {
39+
byt, err := read(string(f))
40+
return string(byt), err
41+
}
42+
43+
// open opens a file in the configuration directory,
44+
// creating all intermediate directories.
45+
func open(path string, flag int, mode os.FileMode) (*os.File, error) {
46+
err := os.MkdirAll(filepath.Dir(path), 0750)
47+
if err != nil {
48+
return nil, err
49+
}
50+
51+
return os.OpenFile(path, flag, mode)
52+
}
53+
54+
func write(path string, mode os.FileMode, dat []byte) error {
55+
fi, err := open(path, os.O_WRONLY|os.O_TRUNC|os.O_CREATE, mode)
56+
if err != nil {
57+
return err
58+
}
59+
defer fi.Close()
60+
_, err = fi.Write(dat)
61+
return err
62+
}
63+
64+
func read(path string) ([]byte, error) {
65+
fi, err := open(path, os.O_RDONLY, 0)
66+
if err != nil {
67+
return nil, err
68+
}
69+
defer fi.Close()
70+
return ioutil.ReadAll(fi)
71+
}

cli/config/file_test.go

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
package config_test
2+
3+
import (
4+
"testing"
5+
6+
"github.com/stretchr/testify/require"
7+
8+
"github.com/coder/coder/cli/config"
9+
)
10+
11+
func TestFile(t *testing.T) {
12+
t.Parallel()
13+
14+
t.Run("Write", func(t *testing.T) {
15+
t.Parallel()
16+
err := config.Root(t.TempDir()).Session().Write("test")
17+
require.NoError(t, err)
18+
})
19+
20+
t.Run("Read", func(t *testing.T) {
21+
t.Parallel()
22+
root := config.Root(t.TempDir())
23+
err := root.Session().Write("test")
24+
require.NoError(t, err)
25+
data, err := root.Session().Read()
26+
require.NoError(t, err)
27+
require.Equal(t, "test", data)
28+
})
29+
30+
t.Run("Delete", func(t *testing.T) {
31+
t.Parallel()
32+
root := config.Root(t.TempDir())
33+
err := root.Session().Write("test")
34+
require.NoError(t, err)
35+
err = root.Session().Delete()
36+
require.NoError(t, err)
37+
})
38+
}

cli/login.go

Lines changed: 135 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,135 @@
1+
package cli
2+
3+
import (
4+
"fmt"
5+
"net/url"
6+
"os/user"
7+
"strings"
8+
9+
"github.com/fatih/color"
10+
"github.com/go-playground/validator/v10"
11+
"github.com/manifoldco/promptui"
12+
"github.com/spf13/cobra"
13+
"golang.org/x/xerrors"
14+
15+
"github.com/coder/coder/coderd"
16+
"github.com/coder/coder/codersdk"
17+
)
18+
19+
func login() *cobra.Command {
20+
return &cobra.Command{
21+
Use: "login <url>",
22+
Args: cobra.ExactArgs(1),
23+
RunE: func(cmd *cobra.Command, args []string) error {
24+
rawURL := args[0]
25+
if !strings.HasPrefix(rawURL, "http://") && !strings.HasPrefix(rawURL, "https://") {
26+
scheme := "https"
27+
if strings.HasPrefix(rawURL, "localhost") {
28+
scheme = "http"
29+
}
30+
rawURL = fmt.Sprintf("%s://%s", scheme, rawURL)
31+
}
32+
serverURL, err := url.Parse(rawURL)
33+
if err != nil {
34+
return xerrors.Errorf("parse raw url %q: %w", rawURL, err)
35+
}
36+
// Default to HTTPs. Enables simple URLs like: master.cdr.dev
37+
if serverURL.Scheme == "" {
38+
serverURL.Scheme = "https"
39+
}
40+
41+
client := codersdk.New(serverURL)
42+
hasInitialUser, err := client.HasInitialUser(cmd.Context())
43+
if err != nil {
44+
return xerrors.Errorf("has initial user: %w", err)
45+
}
46+
if !hasInitialUser {
47+
if !isTTY(cmd.InOrStdin()) {
48+
return xerrors.New("the initial user cannot be created in non-interactive mode. use the API")
49+
}
50+
_, _ = fmt.Fprintf(cmd.OutOrStdout(), "%s Your Coder deployment hasn't been set up!\n", color.HiBlackString(">"))
51+
52+
_, err := runPrompt(cmd, &promptui.Prompt{
53+
Label: "Would you like to create the first user?",
54+
IsConfirm: true,
55+
Default: "y",
56+
})
57+
if err != nil {
58+
return xerrors.Errorf("create user prompt: %w", err)
59+
}
60+
currentUser, err := user.Current()
61+
if err != nil {
62+
return xerrors.Errorf("get current user: %w", err)
63+
}
64+
username, err := runPrompt(cmd, &promptui.Prompt{
65+
Label: "What username would you like?",
66+
Default: currentUser.Username,
67+
})
68+
if err != nil {
69+
return xerrors.Errorf("pick username prompt: %w", err)
70+
}
71+
72+
organization, err := runPrompt(cmd, &promptui.Prompt{
73+
Label: "What is the name of your organization?",
74+
Default: "acme-corp",
75+
})
76+
if err != nil {
77+
return xerrors.Errorf("pick organization prompt: %w", err)
78+
}
79+
80+
email, err := runPrompt(cmd, &promptui.Prompt{
81+
Label: "What's your email?",
82+
Validate: func(s string) error {
83+
err := validator.New().Var(s, "email")
84+
if err != nil {
85+
return xerrors.New("That's not a valid email address!")
86+
}
87+
return err
88+
},
89+
})
90+
if err != nil {
91+
return xerrors.Errorf("specify email prompt: %w", err)
92+
}
93+
94+
password, err := runPrompt(cmd, &promptui.Prompt{
95+
Label: "Enter a password:",
96+
Mask: '*',
97+
})
98+
if err != nil {
99+
return xerrors.Errorf("specify password prompt: %w", err)
100+
}
101+
102+
_, err = client.CreateInitialUser(cmd.Context(), coderd.CreateInitialUserRequest{
103+
Email: email,
104+
Username: username,
105+
Password: password,
106+
Organization: organization,
107+
})
108+
if err != nil {
109+
return xerrors.Errorf("create initial user: %w", err)
110+
}
111+
resp, err := client.LoginWithPassword(cmd.Context(), coderd.LoginWithPasswordRequest{
112+
Email: email,
113+
Password: password,
114+
})
115+
if err != nil {
116+
return xerrors.Errorf("login with password: %w", err)
117+
}
118+
config := createConfig(cmd)
119+
err = config.Session().Write(resp.SessionToken)
120+
if err != nil {
121+
return xerrors.Errorf("write session token: %w", err)
122+
}
123+
err = config.URL().Write(serverURL.String())
124+
if err != nil {
125+
return xerrors.Errorf("write server url: %w", err)
126+
}
127+
128+
_, _ = fmt.Fprintf(cmd.OutOrStdout(), "%s Welcome to Coder, %s! You're authenticated.\n", color.HiBlackString(">"), color.HiCyanString(username))
129+
return nil
130+
}
131+
132+
return nil
133+
},
134+
}
135+
}

cli/login_test.go

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
//go:build !windows
2+
3+
package cli_test
4+
5+
import (
6+
"testing"
7+
8+
"github.com/coder/coder/cli/clitest"
9+
"github.com/coder/coder/coderd/coderdtest"
10+
"github.com/stretchr/testify/require"
11+
12+
"github.com/Netflix/go-expect"
13+
)
14+
15+
func TestLogin(t *testing.T) {
16+
t.Parallel()
17+
t.Run("InitialUserNoTTY", func(t *testing.T) {
18+
t.Parallel()
19+
client := coderdtest.New(t)
20+
root, _ := clitest.New(t, "login", client.URL.String())
21+
err := root.Execute()
22+
require.Error(t, err)
23+
})
24+
25+
t.Run("InitialUserTTY", func(t *testing.T) {
26+
t.Parallel()
27+
console, err := expect.NewConsole(expect.WithStdout(clitest.StdoutLogs(t)))
28+
require.NoError(t, err)
29+
client := coderdtest.New(t)
30+
root, _ := clitest.New(t, "login", client.URL.String())
31+
root.SetIn(console.Tty())
32+
root.SetOut(console.Tty())
33+
go func() {
34+
err := root.Execute()
35+
require.NoError(t, err)
36+
}()
37+
38+
matches := []string{
39+
"first user?", "y",
40+
"username", "testuser",
41+
"organization", "testorg",
42+
"email", "user@coder.com",
43+
"password", "password",
44+
}
45+
for i := 0; i < len(matches); i += 2 {
46+
match := matches[i]
47+
value := matches[i+1]
48+
_, err = console.ExpectString(match)
49+
require.NoError(t, err)
50+
_, err = console.SendLine(value)
51+
require.NoError(t, err)
52+
}
53+
_, err = console.ExpectString("Welcome to Coder")
54+
require.NoError(t, err)
55+
})
56+
}

0 commit comments

Comments
 (0)
pFad - Phonifier reborn

Pfad - The Proxy pFad of © 2024 Garber Painting. All rights reserved.

Note: This service is not intended for secure transactions such as banking, social media, email, or purchasing. Use at your own risk. We assume no liability whatsoever for broken pages.


Alternative Proxies:

Alternative Proxy

pFad Proxy

pFad v3 Proxy

pFad v4 Proxy