Skip to content

Commit df20dd7

Browse files
authored
feat: improve coder users show output, add json format (#3176)
1 parent aaf0da2 commit df20dd7

File tree

4 files changed

+200
-31
lines changed

4 files changed

+200
-31
lines changed

cli/root.go

+2-2
Original file line numberDiff line numberDiff line change
@@ -476,8 +476,8 @@ download the server version with: 'curl -L https://coder.com/install.sh | sh -s
476476
if !buildinfo.VersionsMatch(clientVersion, info.Version) {
477477
warn := cliui.Styles.Warn.Copy().Align(lipgloss.Left)
478478
// Trim the leading 'v', our install.sh script does not handle this case well.
479-
_, _ = fmt.Fprintf(cmd.OutOrStdout(), warn.Render(fmtWarningText), clientVersion, info.Version, strings.TrimPrefix(info.CanonicalVersion(), "v"))
480-
_, _ = fmt.Fprintln(cmd.OutOrStdout())
479+
_, _ = fmt.Fprintf(cmd.ErrOrStderr(), warn.Render(fmtWarningText), clientVersion, info.Version, strings.TrimPrefix(info.CanonicalVersion(), "v"))
480+
_, _ = fmt.Fprintln(cmd.ErrOrStderr())
481481
}
482482

483483
return nil

cli/userlist.go

+115-8
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,27 @@
11
package cli
22

33
import (
4+
"context"
5+
"encoding/json"
46
"fmt"
7+
"io"
8+
"time"
59

10+
"github.com/charmbracelet/lipgloss"
11+
"github.com/jedib0t/go-pretty/v6/table"
612
"github.com/spf13/cobra"
13+
"golang.org/x/xerrors"
714

15+
"github.com/coder/coder/cli/cliui"
816
"github.com/coder/coder/codersdk"
917
)
1018

1119
func userList() *cobra.Command {
12-
var columns []string
20+
var (
21+
columns []string
22+
outputFormat string
23+
)
24+
1325
cmd := &cobra.Command{
1426
Use: "list",
1527
Aliases: []string{"ls"},
@@ -23,17 +35,34 @@ func userList() *cobra.Command {
2335
return err
2436
}
2537

26-
_, err = fmt.Fprintln(cmd.OutOrStdout(), displayUsers(columns, users...))
38+
out := ""
39+
switch outputFormat {
40+
case "table", "":
41+
out = displayUsers(columns, users...)
42+
case "json":
43+
outBytes, err := json.Marshal(users)
44+
if err != nil {
45+
return xerrors.Errorf("marshal users to JSON: %w", err)
46+
}
47+
48+
out = string(outBytes)
49+
default:
50+
return xerrors.Errorf(`unknown output format %q, only "table" and "json" are supported`, outputFormat)
51+
}
52+
53+
_, err = fmt.Fprintln(cmd.OutOrStdout(), out)
2754
return err
2855
},
2956
}
30-
cmd.Flags().StringArrayVarP(&columns, "column", "c", []string{"username", "email", "created_at"},
31-
"Specify a column to filter in the table.")
57+
58+
cmd.Flags().StringArrayVarP(&columns, "column", "c", []string{"username", "email", "created_at", "status"},
59+
"Specify a column to filter in the table. Available columns are: id, username, email, created_at, status.")
60+
cmd.Flags().StringVarP(&outputFormat, "output", "o", "table", "Output format. Available formats are: table, json.")
3261
return cmd
3362
}
3463

3564
func userSingle() *cobra.Command {
36-
var columns []string
65+
var outputFormat string
3766
cmd := &cobra.Command{
3867
Use: "show <username|user_id|'me'>",
3968
Short: "Show a single user. Use 'me' to indicate the currently authenticated user.",
@@ -54,11 +83,89 @@ func userSingle() *cobra.Command {
5483
return err
5584
}
5685

57-
_, err = fmt.Fprintln(cmd.OutOrStdout(), displayUsers(columns, user))
86+
out := ""
87+
switch outputFormat {
88+
case "table", "":
89+
out = displayUser(cmd.Context(), cmd.ErrOrStderr(), client, user)
90+
case "json":
91+
outBytes, err := json.Marshal(user)
92+
if err != nil {
93+
return xerrors.Errorf("marshal user to JSON: %w", err)
94+
}
95+
96+
out = string(outBytes)
97+
default:
98+
return xerrors.Errorf(`unknown output format %q, only "table" and "json" are supported`, outputFormat)
99+
}
100+
101+
_, err = fmt.Fprintln(cmd.OutOrStdout(), out)
58102
return err
59103
},
60104
}
61-
cmd.Flags().StringArrayVarP(&columns, "column", "c", []string{"username", "email", "created_at"},
62-
"Specify a column to filter in the table.")
105+
106+
cmd.Flags().StringVarP(&outputFormat, "output", "o", "table", "Output format. Available formats are: table, json.")
63107
return cmd
64108
}
109+
110+
func displayUser(ctx context.Context, stderr io.Writer, client *codersdk.Client, user codersdk.User) string {
111+
tableWriter := cliui.Table()
112+
addRow := func(name string, value interface{}) {
113+
key := ""
114+
if name != "" {
115+
key = name + ":"
116+
}
117+
tableWriter.AppendRow(table.Row{
118+
key, value,
119+
})
120+
}
121+
122+
// Add rows for each of the user's fields.
123+
addRow("ID", user.ID.String())
124+
addRow("Username", user.Username)
125+
addRow("Email", user.Email)
126+
addRow("Status", user.Status)
127+
addRow("Created At", user.CreatedAt.Format(time.Stamp))
128+
129+
addRow("", "")
130+
firstRole := true
131+
for _, role := range user.Roles {
132+
if role.DisplayName == "" {
133+
// Skip roles with no display name.
134+
continue
135+
}
136+
137+
key := ""
138+
if firstRole {
139+
key = "Roles"
140+
firstRole = false
141+
}
142+
addRow(key, role.DisplayName)
143+
}
144+
if firstRole {
145+
addRow("Roles", "(none)")
146+
}
147+
148+
addRow("", "")
149+
firstOrg := true
150+
for _, orgID := range user.OrganizationIDs {
151+
org, err := client.Organization(ctx, orgID)
152+
if err != nil {
153+
warn := cliui.Styles.Warn.Copy().Align(lipgloss.Left)
154+
_, _ = fmt.Fprintf(stderr, warn.Render("Could not fetch organization %s: %+v"), orgID, err)
155+
continue
156+
}
157+
158+
key := ""
159+
if firstOrg {
160+
key = "Organizations"
161+
firstOrg = false
162+
}
163+
164+
addRow(key, org.Name)
165+
}
166+
if firstOrg {
167+
addRow("Organizations", "(none)")
168+
}
169+
170+
return tableWriter.Render()
171+
}

cli/userlist_test.go

+82-20
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
11
package cli_test
22

33
import (
4+
"bytes"
45
"context"
6+
"encoding/json"
57
"testing"
68

79
"github.com/stretchr/testify/assert"
@@ -15,7 +17,7 @@ import (
1517

1618
func TestUserList(t *testing.T) {
1719
t.Parallel()
18-
t.Run("List", func(t *testing.T) {
20+
t.Run("Table", func(t *testing.T) {
1921
t.Parallel()
2022
client := coderdtest.New(t, nil)
2123
coderdtest.CreateFirstUser(t, client)
@@ -31,6 +33,31 @@ func TestUserList(t *testing.T) {
3133
require.NoError(t, <-errC)
3234
pty.ExpectMatch("coder.com")
3335
})
36+
t.Run("JSON", func(t *testing.T) {
37+
t.Parallel()
38+
39+
client := coderdtest.New(t, nil)
40+
coderdtest.CreateFirstUser(t, client)
41+
cmd, root := clitest.New(t, "users", "list", "-o", "json")
42+
clitest.SetupConfig(t, client, root)
43+
doneChan := make(chan struct{})
44+
45+
buf := bytes.NewBuffer(nil)
46+
cmd.SetOut(buf)
47+
go func() {
48+
defer close(doneChan)
49+
err := cmd.Execute()
50+
assert.NoError(t, err)
51+
}()
52+
53+
<-doneChan
54+
55+
var users []codersdk.User
56+
err := json.Unmarshal(buf.Bytes(), &users)
57+
require.NoError(t, err, "unmarshal JSON output")
58+
require.Len(t, users, 1)
59+
require.Contains(t, users[0].Email, "coder.com")
60+
})
3461
t.Run("NoURLFileErrorHasHelperText", func(t *testing.T) {
3562
t.Parallel()
3663

@@ -57,23 +84,58 @@ func TestUserList(t *testing.T) {
5784

5885
func TestUserShow(t *testing.T) {
5986
t.Parallel()
60-
ctx := context.Background()
61-
client := coderdtest.New(t, nil)
62-
admin := coderdtest.CreateFirstUser(t, client)
63-
other := coderdtest.CreateAnotherUser(t, client, admin.OrganizationID)
64-
otherUser, err := other.User(ctx, codersdk.Me)
65-
require.NoError(t, err, "fetch other user")
66-
cmd, root := clitest.New(t, "users", "show", otherUser.Username)
67-
clitest.SetupConfig(t, client, root)
68-
doneChan := make(chan struct{})
69-
pty := ptytest.New(t)
70-
cmd.SetIn(pty.Input())
71-
cmd.SetOut(pty.Output())
72-
go func() {
73-
defer close(doneChan)
74-
err := cmd.Execute()
75-
assert.NoError(t, err)
76-
}()
77-
pty.ExpectMatch(otherUser.Email)
78-
<-doneChan
87+
88+
t.Run("Table", func(t *testing.T) {
89+
t.Parallel()
90+
ctx := context.Background()
91+
client := coderdtest.New(t, nil)
92+
admin := coderdtest.CreateFirstUser(t, client)
93+
other := coderdtest.CreateAnotherUser(t, client, admin.OrganizationID)
94+
otherUser, err := other.User(ctx, codersdk.Me)
95+
require.NoError(t, err, "fetch other user")
96+
cmd, root := clitest.New(t, "users", "show", otherUser.Username)
97+
clitest.SetupConfig(t, client, root)
98+
doneChan := make(chan struct{})
99+
pty := ptytest.New(t)
100+
cmd.SetIn(pty.Input())
101+
cmd.SetOut(pty.Output())
102+
go func() {
103+
defer close(doneChan)
104+
err := cmd.Execute()
105+
assert.NoError(t, err)
106+
}()
107+
pty.ExpectMatch(otherUser.Email)
108+
<-doneChan
109+
})
110+
111+
t.Run("JSON", func(t *testing.T) {
112+
t.Parallel()
113+
114+
ctx := context.Background()
115+
client := coderdtest.New(t, nil)
116+
admin := coderdtest.CreateFirstUser(t, client)
117+
other := coderdtest.CreateAnotherUser(t, client, admin.OrganizationID)
118+
otherUser, err := other.User(ctx, codersdk.Me)
119+
require.NoError(t, err, "fetch other user")
120+
cmd, root := clitest.New(t, "users", "show", otherUser.Username, "-o", "json")
121+
clitest.SetupConfig(t, client, root)
122+
doneChan := make(chan struct{})
123+
124+
buf := bytes.NewBuffer(nil)
125+
cmd.SetOut(buf)
126+
go func() {
127+
defer close(doneChan)
128+
err := cmd.Execute()
129+
assert.NoError(t, err)
130+
}()
131+
132+
<-doneChan
133+
134+
var newUser codersdk.User
135+
err = json.Unmarshal(buf.Bytes(), &newUser)
136+
require.NoError(t, err, "unmarshal JSON output")
137+
require.Equal(t, otherUser.ID, newUser.ID)
138+
require.Equal(t, otherUser.Username, newUser.Username)
139+
require.Equal(t, otherUser.Email, newUser.Email)
140+
})
79141
}

cli/users.go

+1-1
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,7 @@ func displayUsers(filterColumns []string, users ...codersdk.User) string {
3535
tableWriter.AppendHeader(header)
3636
tableWriter.SetColumnConfigs(cliui.FilterTableColumns(header, filterColumns))
3737
tableWriter.SortBy([]table.SortBy{{
38-
Name: "Username",
38+
Name: "username",
3939
}})
4040
for _, user := range users {
4141
tableWriter.AppendRow(table.Row{

0 commit comments

Comments
 (0)