diff --git a/cli/root.go b/cli/root.go index b38b7c72441f5..a39e0a3b5cc57 100644 --- a/cli/root.go +++ b/cli/root.go @@ -471,8 +471,8 @@ download the server version with: 'curl -L https://coder.com/install.sh | sh -s if !buildinfo.VersionsMatch(clientVersion, info.Version) { warn := cliui.Styles.Warn.Copy().Align(lipgloss.Left) // Trim the leading 'v', our install.sh script does not handle this case well. - _, _ = fmt.Fprintf(cmd.OutOrStdout(), warn.Render(fmtWarningText), clientVersion, info.Version, strings.TrimPrefix(info.CanonicalVersion(), "v")) - _, _ = fmt.Fprintln(cmd.OutOrStdout()) + _, _ = fmt.Fprintf(cmd.ErrOrStderr(), warn.Render(fmtWarningText), clientVersion, info.Version, strings.TrimPrefix(info.CanonicalVersion(), "v")) + _, _ = fmt.Fprintln(cmd.ErrOrStderr()) } return nil diff --git a/cli/userlist.go b/cli/userlist.go index 23325611d47fb..dcd3ecb7d3a6b 100644 --- a/cli/userlist.go +++ b/cli/userlist.go @@ -1,15 +1,27 @@ package cli import ( + "context" + "encoding/json" "fmt" + "io" + "time" + "github.com/charmbracelet/lipgloss" + "github.com/jedib0t/go-pretty/v6/table" "github.com/spf13/cobra" + "golang.org/x/xerrors" + "github.com/coder/coder/cli/cliui" "github.com/coder/coder/codersdk" ) func userList() *cobra.Command { - var columns []string + var ( + columns []string + outputFormat string + ) + cmd := &cobra.Command{ Use: "list", Aliases: []string{"ls"}, @@ -23,17 +35,34 @@ func userList() *cobra.Command { return err } - _, err = fmt.Fprintln(cmd.OutOrStdout(), displayUsers(columns, users...)) + out := "" + switch outputFormat { + case "table", "": + out = displayUsers(columns, users...) + case "json": + outBytes, err := json.Marshal(users) + if err != nil { + return xerrors.Errorf("marshal users to JSON: %w", err) + } + + out = string(outBytes) + default: + return xerrors.Errorf(`unknown output format %q, only "table" and "json" are supported`, outputFormat) + } + + _, err = fmt.Fprintln(cmd.OutOrStdout(), out) return err }, } - cmd.Flags().StringArrayVarP(&columns, "column", "c", []string{"username", "email", "created_at"}, - "Specify a column to filter in the table.") + + cmd.Flags().StringArrayVarP(&columns, "column", "c", []string{"username", "email", "created_at", "status"}, + "Specify a column to filter in the table. Available columns are: id, username, email, created_at, status.") + cmd.Flags().StringVarP(&outputFormat, "output", "o", "table", "Output format. Available formats are: table, json.") return cmd } func userSingle() *cobra.Command { - var columns []string + var outputFormat string cmd := &cobra.Command{ Use: "show ", Short: "Show a single user. Use 'me' to indicate the currently authenticated user.", @@ -54,11 +83,89 @@ func userSingle() *cobra.Command { return err } - _, err = fmt.Fprintln(cmd.OutOrStdout(), displayUsers(columns, user)) + out := "" + switch outputFormat { + case "table", "": + out = displayUser(cmd.Context(), cmd.ErrOrStderr(), client, user) + case "json": + outBytes, err := json.Marshal(user) + if err != nil { + return xerrors.Errorf("marshal user to JSON: %w", err) + } + + out = string(outBytes) + default: + return xerrors.Errorf(`unknown output format %q, only "table" and "json" are supported`, outputFormat) + } + + _, err = fmt.Fprintln(cmd.OutOrStdout(), out) return err }, } - cmd.Flags().StringArrayVarP(&columns, "column", "c", []string{"username", "email", "created_at"}, - "Specify a column to filter in the table.") + + cmd.Flags().StringVarP(&outputFormat, "output", "o", "table", "Output format. Available formats are: table, json.") return cmd } + +func displayUser(ctx context.Context, stderr io.Writer, client *codersdk.Client, user codersdk.User) string { + tableWriter := cliui.Table() + addRow := func(name string, value interface{}) { + key := "" + if name != "" { + key = name + ":" + } + tableWriter.AppendRow(table.Row{ + key, value, + }) + } + + // Add rows for each of the user's fields. + addRow("ID", user.ID.String()) + addRow("Username", user.Username) + addRow("Email", user.Email) + addRow("Status", user.Status) + addRow("Created At", user.CreatedAt.Format(time.Stamp)) + + addRow("", "") + firstRole := true + for _, role := range user.Roles { + if role.DisplayName == "" { + // Skip roles with no display name. + continue + } + + key := "" + if firstRole { + key = "Roles" + firstRole = false + } + addRow(key, role.DisplayName) + } + if firstRole { + addRow("Roles", "(none)") + } + + addRow("", "") + firstOrg := true + for _, orgID := range user.OrganizationIDs { + org, err := client.Organization(ctx, orgID) + if err != nil { + warn := cliui.Styles.Warn.Copy().Align(lipgloss.Left) + _, _ = fmt.Fprintf(stderr, warn.Render("Could not fetch organization %s: %+v"), orgID, err) + continue + } + + key := "" + if firstOrg { + key = "Organizations" + firstOrg = false + } + + addRow(key, org.Name) + } + if firstOrg { + addRow("Organizations", "(none)") + } + + return tableWriter.Render() +} diff --git a/cli/userlist_test.go b/cli/userlist_test.go index c8b207a0a92fb..35ba24aeec064 100644 --- a/cli/userlist_test.go +++ b/cli/userlist_test.go @@ -1,7 +1,9 @@ package cli_test import ( + "bytes" "context" + "encoding/json" "testing" "github.com/stretchr/testify/assert" @@ -15,7 +17,7 @@ import ( func TestUserList(t *testing.T) { t.Parallel() - t.Run("List", func(t *testing.T) { + t.Run("Table", func(t *testing.T) { t.Parallel() client := coderdtest.New(t, nil) coderdtest.CreateFirstUser(t, client) @@ -31,6 +33,31 @@ func TestUserList(t *testing.T) { require.NoError(t, <-errC) pty.ExpectMatch("coder.com") }) + t.Run("JSON", func(t *testing.T) { + t.Parallel() + + client := coderdtest.New(t, nil) + coderdtest.CreateFirstUser(t, client) + cmd, root := clitest.New(t, "users", "list", "-o", "json") + clitest.SetupConfig(t, client, root) + doneChan := make(chan struct{}) + + buf := bytes.NewBuffer(nil) + cmd.SetOut(buf) + go func() { + defer close(doneChan) + err := cmd.Execute() + assert.NoError(t, err) + }() + + <-doneChan + + var users []codersdk.User + err := json.Unmarshal(buf.Bytes(), &users) + require.NoError(t, err, "unmarshal JSON output") + require.Len(t, users, 1) + require.Contains(t, users[0].Email, "coder.com") + }) t.Run("NoURLFileErrorHasHelperText", func(t *testing.T) { t.Parallel() @@ -57,23 +84,58 @@ func TestUserList(t *testing.T) { func TestUserShow(t *testing.T) { t.Parallel() - ctx := context.Background() - client := coderdtest.New(t, nil) - admin := coderdtest.CreateFirstUser(t, client) - other := coderdtest.CreateAnotherUser(t, client, admin.OrganizationID) - otherUser, err := other.User(ctx, codersdk.Me) - require.NoError(t, err, "fetch other user") - cmd, root := clitest.New(t, "users", "show", otherUser.Username) - clitest.SetupConfig(t, client, root) - doneChan := make(chan struct{}) - pty := ptytest.New(t) - cmd.SetIn(pty.Input()) - cmd.SetOut(pty.Output()) - go func() { - defer close(doneChan) - err := cmd.Execute() - assert.NoError(t, err) - }() - pty.ExpectMatch(otherUser.Email) - <-doneChan + + t.Run("Table", func(t *testing.T) { + t.Parallel() + ctx := context.Background() + client := coderdtest.New(t, nil) + admin := coderdtest.CreateFirstUser(t, client) + other := coderdtest.CreateAnotherUser(t, client, admin.OrganizationID) + otherUser, err := other.User(ctx, codersdk.Me) + require.NoError(t, err, "fetch other user") + cmd, root := clitest.New(t, "users", "show", otherUser.Username) + clitest.SetupConfig(t, client, root) + doneChan := make(chan struct{}) + pty := ptytest.New(t) + cmd.SetIn(pty.Input()) + cmd.SetOut(pty.Output()) + go func() { + defer close(doneChan) + err := cmd.Execute() + assert.NoError(t, err) + }() + pty.ExpectMatch(otherUser.Email) + <-doneChan + }) + + t.Run("JSON", func(t *testing.T) { + t.Parallel() + + ctx := context.Background() + client := coderdtest.New(t, nil) + admin := coderdtest.CreateFirstUser(t, client) + other := coderdtest.CreateAnotherUser(t, client, admin.OrganizationID) + otherUser, err := other.User(ctx, codersdk.Me) + require.NoError(t, err, "fetch other user") + cmd, root := clitest.New(t, "users", "show", otherUser.Username, "-o", "json") + clitest.SetupConfig(t, client, root) + doneChan := make(chan struct{}) + + buf := bytes.NewBuffer(nil) + cmd.SetOut(buf) + go func() { + defer close(doneChan) + err := cmd.Execute() + assert.NoError(t, err) + }() + + <-doneChan + + var newUser codersdk.User + err = json.Unmarshal(buf.Bytes(), &newUser) + require.NoError(t, err, "unmarshal JSON output") + require.Equal(t, otherUser.ID, newUser.ID) + require.Equal(t, otherUser.Username, newUser.Username) + require.Equal(t, otherUser.Email, newUser.Email) + }) } diff --git a/cli/users.go b/cli/users.go index 9e8e075b56082..009c4216dcca4 100644 --- a/cli/users.go +++ b/cli/users.go @@ -35,7 +35,7 @@ func displayUsers(filterColumns []string, users ...codersdk.User) string { tableWriter.AppendHeader(header) tableWriter.SetColumnConfigs(cliui.FilterTableColumns(header, filterColumns)) tableWriter.SortBy([]table.SortBy{{ - Name: "Username", + Name: "username", }}) for _, user := range users { tableWriter.AppendRow(table.Row{