Thanks to visit codestin.com
Credit goes to github.com

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", "[email protected]",
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)