From 457cd51898667bb049156ec49fa84e3740adcc8c Mon Sep 17 00:00:00 2001 From: Dean Sheather Date: Tue, 7 Feb 2023 15:04:33 +0000 Subject: [PATCH 1/7] chore: make output formatting generic --- cli/cliui/output.go | 153 ++++++++++++++++++++++++++++++++++++++++++++ cli/cliui/table.go | 81 ++++++++++++----------- cli/list.go | 6 +- cli/userlist.go | 98 ++++++++++++++-------------- codersdk/users.go | 2 +- 5 files changed, 249 insertions(+), 91 deletions(-) create mode 100644 cli/cliui/output.go diff --git a/cli/cliui/output.go b/cli/cliui/output.go new file mode 100644 index 0000000000000..8048333249a74 --- /dev/null +++ b/cli/cliui/output.go @@ -0,0 +1,153 @@ +package cliui + +import ( + "context" + "encoding/json" + "reflect" + "strings" + + "github.com/spf13/cobra" + "golang.org/x/xerrors" +) + +type OutputFormat interface { + ID() string + AttachFlags(cmd *cobra.Command) + Format(ctx context.Context, data any) (string, error) +} + +type OutputFormatter struct { + formats []OutputFormat + formatID string +} + +// NewOutputFormatter creates a new OutputFormatter with the given formats. The +// first format is the default format. At least two formats must be provided. +func NewOutputFormatter(formats ...OutputFormat) *OutputFormatter { + if len(formats) < 2 { + panic("at least two output formats must be provided") + } + + formatIDs := make(map[string]struct{}, len(formats)) + for _, format := range formats { + if _, ok := formatIDs[format.ID()]; ok { + panic("duplicate format ID: " + format.ID()) + } + formatIDs[format.ID()] = struct{}{} + } + + return &OutputFormatter{ + formats: formats, + formatID: formats[0].ID(), + } +} + +// AttachFlags attaches the --output flag to the given command, and any +// additional flags required by the output formatters. +func (f *OutputFormatter) AttachFlags(cmd *cobra.Command) { + for _, format := range f.formats { + format.AttachFlags(cmd) + } + + formatNames := make([]string, 0, len(f.formats)) + for _, format := range f.formats { + formatNames = append(formatNames, format.ID()) + } + + cmd.Flags().StringVarP(&f.formatID, "output", "o", f.formats[0].ID(), "Output format. Available formats: "+strings.Join(formatNames, ", ")) +} + +// Format formats the given data using the format specified by the --output +// flag. If the flag is not set, the default format is used. +func (f *OutputFormatter) Format(ctx context.Context, data any) (string, error) { + for _, format := range f.formats { + if format.ID() == f.formatID { + return format.Format(ctx, data) + } + } + + return "", xerrors.Errorf("unknown output format %q", f.formatID) +} + +type tableFormat struct { + defaultColumns []string + allColumns []string + sort string + + columns []string +} + +var _ OutputFormat = &tableFormat{} + +// TableFormat creates a table formatter for the given output type. The output +// type should be specified as an empty slice of the desired type. +// +// E.g.: TableFormat([]MyType{}, []string{"foo", "bar"}) +// +// defaultColumns is optional and specifies the default columns to display. If +// not specified, all columns are displayed by default. +func TableFormat(out any, defaultColumns []string) OutputFormat { + v := reflect.Indirect(reflect.ValueOf(out)) + if v.Kind() != reflect.Slice { + panic("DisplayTable called with a non-slice type") + } + + // Get the list of table column headers. + headers, defaultSort, err := typeToTableHeaders(v.Type().Elem()) + if err != nil { + panic("parse table headers: " + err.Error()) + } + + tf := &tableFormat{ + defaultColumns: headers, + allColumns: headers, + sort: defaultSort, + } + if len(defaultColumns) > 0 { + tf.defaultColumns = defaultColumns + } + + return tf +} + +// ID implements OutputFormat. +func (*tableFormat) ID() string { + return "table" +} + +// AttachFlags implements OutputFormat. +func (f *tableFormat) AttachFlags(cmd *cobra.Command) { + cmd.Flags().StringSliceVarP(&f.columns, "column", "c", f.defaultColumns, "Columns to display in table output. Available columns: "+strings.Join(f.allColumns, ", ")) +} + +// Format implements OutputFormat. +func (f *tableFormat) Format(_ context.Context, data any) (string, error) { + return DisplayTable(data, f.sort, f.columns) +} + +type jsonFormat struct{} + +var _ OutputFormat = jsonFormat{} + +// JSONFormat creates a JSON formatter. +func JSONFormat() OutputFormat { + return jsonFormat{} +} + +// ID implements OutputFormat. +func (jsonFormat) ID() string { + return "json" +} + +// AttachFlags implements OutputFormat. +func (jsonFormat) AttachFlags(_ *cobra.Command) {} + +// Format implements OutputFormat. +func (jsonFormat) Format(_ context.Context, data any) (string, error) { + outBytes, err := json.MarshalIndent(data, "", " ") + if err != nil { + return "", xerrors.Errorf("marshal output to JSON: %w", err) + } + + return string(outBytes), nil +} diff --git a/cli/cliui/table.go b/cli/cliui/table.go index cd417641f6892..1defbdca71d9d 100644 --- a/cli/cliui/table.go +++ b/cli/cliui/table.go @@ -22,10 +22,10 @@ func Table() table.Writer { return tableWriter } -// FilterTableColumns returns configurations to hide columns +// filterTableColumns returns configurations to hide columns // that are not provided in the array. If the array is empty, // no filtering will occur! -func FilterTableColumns(header table.Row, columns []string) []table.ColumnConfig { +func filterTableColumns(header table.Row, columns []string) []table.ColumnConfig { if len(columns) == 0 { return nil } @@ -51,6 +51,9 @@ func FilterTableColumns(header table.Row, columns []string) []table.ColumnConfig // of structs. At least one field in the struct must have a `table:""` tag // containing the name of the column in the outputted table. // +// If `sort` is not specified, the field with the `table:"$NAME,default_sort"` +// tag will be used to sort. An error will be returned if no field has this tag. +// // Nested structs are processed if the field has the `table:"$NAME,recursive"` // tag and their fields will be named as `$PARENT_NAME $NAME`. If the tag is // malformed or a field is marked as recursive but does not contain a struct or @@ -67,13 +70,16 @@ func DisplayTable(out any, sort string, filterColumns []string) (string, error) } // Get the list of table column headers. - headersRaw, err := typeToTableHeaders(v.Type().Elem()) + headersRaw, defaultSort, err := typeToTableHeaders(v.Type().Elem()) if err != nil { return "", xerrors.Errorf("get table headers recursively for type %q: %w", v.Type().Elem().String(), err) } if len(headersRaw) == 0 { return "", xerrors.New(`no table headers found on the input type, make sure there is at least one "table" struct tag`) } + if sort == "" { + sort = defaultSort + } headers := make(table.Row, len(headersRaw)) for i, header := range headersRaw { headers[i] = header @@ -128,7 +134,7 @@ func DisplayTable(out any, sort string, filterColumns []string) (string, error) // Setup the table formatter. tw := Table() tw.AppendHeader(headers) - tw.SetColumnConfigs(FilterTableColumns(headers, filterColumns)) + tw.SetColumnConfigs(filterTableColumns(headers, filterColumns)) if sort != "" { tw.SortBy([]table.SortBy{{ Name: sort, @@ -182,29 +188,32 @@ func DisplayTable(out any, sort string, filterColumns []string) (string, error) // returned. If the table tag is malformed, an error is returned. // // The returned name is transformed from "snake_case" to "normal text". -func parseTableStructTag(field reflect.StructField) (name string, recurse bool, err error) { +func parseTableStructTag(field reflect.StructField) (name string, defaultSort, recursive bool, err error) { tags, err := structtag.Parse(string(field.Tag)) if err != nil { - return "", false, xerrors.Errorf("parse struct field tag %q: %w", string(field.Tag), err) + return "", false, false, xerrors.Errorf("parse struct field tag %q: %w", string(field.Tag), err) } tag, err := tags.Get("table") if err != nil || tag.Name == "-" { // tags.Get only returns an error if the tag is not found. - return "", false, nil + return "", false, false, nil } - recursive := false + defaultSortOpt := false + recursiveOpt := false for _, opt := range tag.Options { - if opt == "recursive" { - recursive = true - continue + switch opt { + case "default_sort": + defaultSortOpt = true + case "recursive": + recursiveOpt = true + default: + return "", false, false, xerrors.Errorf("unknown option %q in struct field tag", opt) } - - return "", false, xerrors.Errorf("unknown option %q in struct field tag", opt) } - return strings.ReplaceAll(tag.Name, "_", " "), recursive, nil + return strings.ReplaceAll(tag.Name, "_", " "), defaultSortOpt, recursiveOpt, nil } func isStructOrStructPointer(t reflect.Type) bool { @@ -214,34 +223,41 @@ func isStructOrStructPointer(t reflect.Type) bool { // typeToTableHeaders converts a type to a slice of column names. If the given // type is invalid (not a struct or a pointer to a struct, has invalid table // tags, etc.), an error is returned. -func typeToTableHeaders(t reflect.Type) ([]string, error) { +func typeToTableHeaders(t reflect.Type) ([]string, string, error) { if !isStructOrStructPointer(t) { - return nil, xerrors.Errorf("typeToTableHeaders called with a non-struct or a non-pointer-to-a-struct type") + return nil, "", xerrors.Errorf("typeToTableHeaders called with a non-struct or a non-pointer-to-a-struct type") } if t.Kind() == reflect.Pointer { t = t.Elem() } headers := []string{} + defaultSortName := "" for i := 0; i < t.NumField(); i++ { field := t.Field(i) - name, recursive, err := parseTableStructTag(field) + name, defaultSort, recursive, err := parseTableStructTag(field) if err != nil { - return nil, xerrors.Errorf("parse struct tags for field %q in type %q: %w", field.Name, t.String(), err) + return nil, "", xerrors.Errorf("parse struct tags for field %q in type %q: %w", field.Name, t.String(), err) } if name == "" { continue } + if defaultSort { + if defaultSortName != "" { + return nil, "", xerrors.Errorf("multiple fields marked as default sort in type %q", t.String()) + } + defaultSortName = name + } fieldType := field.Type if recursive { if !isStructOrStructPointer(fieldType) { - return nil, xerrors.Errorf("field %q in type %q is marked as recursive but does not contain a struct or a pointer to a struct", field.Name, t.String()) + return nil, "", xerrors.Errorf("field %q in type %q is marked as recursive but does not contain a struct or a pointer to a struct", field.Name, t.String()) } - childNames, err := typeToTableHeaders(fieldType) + childNames, _, err := typeToTableHeaders(fieldType) if err != nil { - return nil, xerrors.Errorf("get child field header names for field %q in type %q: %w", field.Name, fieldType.String(), err) + return nil, "", xerrors.Errorf("get child field header names for field %q in type %q: %w", field.Name, fieldType.String(), err) } for _, childName := range childNames { headers = append(headers, fmt.Sprintf("%s %s", name, childName)) @@ -252,7 +268,11 @@ func typeToTableHeaders(t reflect.Type) ([]string, error) { headers = append(headers, name) } - return headers, nil + if defaultSortName == "" { + return nil, "", xerrors.Errorf("no field marked as default_sort in type %q", t.String()) + } + + return headers, defaultSortName, nil } // valueToTableMap converts a struct to a map of column name to value. If the @@ -276,7 +296,7 @@ func valueToTableMap(val reflect.Value) (map[string]any, error) { for i := 0; i < val.NumField(); i++ { field := val.Type().Field(i) fieldVal := val.Field(i) - name, recursive, err := parseTableStructTag(field) + name, _, recursive, err := parseTableStructTag(field) if err != nil { return nil, xerrors.Errorf("parse struct tags for field %q in type %T: %w", field.Name, val, err) } @@ -309,18 +329,3 @@ func valueToTableMap(val reflect.Value) (map[string]any, error) { return row, nil } - -// TableHeaders returns the table header names of all -// fields in tSlice. tSlice must be a slice of some type. -func TableHeaders(tSlice any) ([]string, error) { - v := reflect.Indirect(reflect.ValueOf(tSlice)) - rawHeaders, err := typeToTableHeaders(v.Type().Elem()) - if err != nil { - return nil, xerrors.Errorf("type to table headers: %w", err) - } - out := make([]string, 0, len(rawHeaders)) - for _, hdr := range rawHeaders { - out = append(out, strings.Replace(hdr, " ", "_", -1)) - } - return out, nil -} diff --git a/cli/list.go b/cli/list.go index a198dd25b86b6..4b6275a854896 100644 --- a/cli/list.go +++ b/cli/list.go @@ -131,10 +131,8 @@ func list() *cobra.Command { }, } - availColumns, err := cliui.TableHeaders(displayWorkspaces) - if err != nil { - panic(err) - } + // TODO: fix this + availColumns := []string{"name"} columnString := strings.Join(availColumns[:], ", ") cmd.Flags().BoolVarP(&all, "all", "a", false, diff --git a/cli/userlist.go b/cli/userlist.go index 1f78854583566..a35b26eb03c35 100644 --- a/cli/userlist.go +++ b/cli/userlist.go @@ -2,12 +2,9 @@ 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" @@ -17,9 +14,9 @@ import ( ) func userList() *cobra.Command { - var ( - columns []string - outputFormat string + formatter := cliui.NewOutputFormatter( + cliui.TableFormat([]codersdk.User{}, []string{"username", "email", "created_at", "status"}), + cliui.JSONFormat(), ) cmd := &cobra.Command{ @@ -35,22 +32,9 @@ func userList() *cobra.Command { return err } - out := "" - switch outputFormat { - case "table", "": - out, err = cliui.DisplayTable(res.Users, "Username", columns) - if err != nil { - return xerrors.Errorf("render table: %w", err) - } - case "json": - outBytes, err := json.Marshal(res.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) + out, err := formatter.Format(cmd.Context(), res.Users) + if err != nil { + return err } _, err = fmt.Fprintln(cmd.OutOrStdout(), out) @@ -58,14 +42,16 @@ func userList() *cobra.Command { }, } - 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.") + formatter.AttachFlags(cmd) return cmd } func userSingle() *cobra.Command { - var outputFormat string + formatter := cliui.NewOutputFormatter( + &userShowFormat{}, + cliui.JSONFormat(), + ) + cmd := &cobra.Command{ Use: "show ", Short: "Show a single user. Use 'me' to indicate the currently authenticated user.", @@ -86,31 +72,54 @@ func userSingle() *cobra.Command { return err } - out := "" - switch outputFormat { - case "table", "": - out = displayUser(cmd.Context(), cmd.ErrOrStderr(), client, user) - case "json": - outBytes, err := json.Marshal(user) + orgNames := make([]string, len(user.OrganizationIDs)) + for i, orgID := range user.OrganizationIDs { + org, err := client.Organization(cmd.Context(), orgID) if err != nil { - return xerrors.Errorf("marshal user to JSON: %w", err) + return xerrors.Errorf("get organization %q: %w", orgID.String(), err) } - out = string(outBytes) - default: - return xerrors.Errorf(`unknown output format %q, only "table" and "json" are supported`, outputFormat) + orgNames[i] = org.Name } + out, err := formatter.Format(cmd.Context(), userWithOrgNames{ + User: user, + OrganizationNames: orgNames, + }) + _, err = fmt.Fprintln(cmd.OutOrStdout(), out) return err }, } - cmd.Flags().StringVarP(&outputFormat, "output", "o", "table", "Output format. Available formats are: table, json.") + formatter.AttachFlags(cmd) return cmd } -func displayUser(ctx context.Context, stderr io.Writer, client *codersdk.Client, user codersdk.User) string { +type userWithOrgNames struct { + codersdk.User + OrganizationNames []string `json:"organization_names" table:"organization_names"` +} + +type userShowFormat struct{} + +var _ cliui.OutputFormat = &userShowFormat{} + +// ID implements OutputFormat. +func (*userShowFormat) ID() string { + return "table" +} + +// AttachFlags implements OutputFormat. +func (*userShowFormat) AttachFlags(_ *cobra.Command) {} + +// Format implements OutputFormat. +func (*userShowFormat) Format(_ context.Context, out interface{}) (string, error) { + user, ok := out.(userWithOrgNames) + if !ok { + return "", xerrors.Errorf("expected type %T, got %T", user, out) + } + tw := cliui.Table() addRow := func(name string, value interface{}) { key := "" @@ -150,25 +159,18 @@ func displayUser(ctx context.Context, stderr io.Writer, client *codersdk.Client, 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 - } - + for _, orgName := range user.OrganizationNames { key := "" if firstOrg { key = "Organizations" firstOrg = false } - addRow(key, org.Name) + addRow(key, orgName) } if firstOrg { addRow("Organizations", "(none)") } - return tw.Render() + return tw.Render(), nil } diff --git a/codersdk/users.go b/codersdk/users.go index f3ff654482458..b0cebc720f665 100644 --- a/codersdk/users.go +++ b/codersdk/users.go @@ -36,7 +36,7 @@ type UsersRequest struct { // User represents a user in Coder. type User struct { ID uuid.UUID `json:"id" validate:"required" table:"id" format:"uuid"` - Username string `json:"username" validate:"required" table:"username"` + Username string `json:"username" validate:"required" table:"username,default_sort"` Email string `json:"email" validate:"required" table:"email" format:"email"` CreatedAt time.Time `json:"created_at" validate:"required" table:"created at" format:"date-time"` LastSeenAt time.Time `json:"last_seen_at" format:"date-time"` From e33bde832f134c9ae38e91b76d3da19d51380b38 Mon Sep 17 00:00:00 2001 From: Dean Sheather Date: Tue, 7 Feb 2023 15:55:12 +0000 Subject: [PATCH 2/7] feat: add JSON output format to many CLI commands --- cli/cliui/table.go | 2 +- cli/list.go | 61 +++++++++---------- cli/list_test.go | 27 ++++++++ cli/parameterslist.go | 14 +++-- cli/ssh_test.go | 3 + cli/templatelist.go | 15 +++-- cli/templatelist_test.go | 29 ++++++++- cli/templates.go | 30 +++++---- cli/templateversions.go | 35 +++++++---- cli/testdata/coder_list_--help.golden | 14 +++-- .../coder_templates_list_--help.golden | 8 ++- ...oder_templates_versions_list_--help.golden | 6 +- cli/testdata/coder_tokens_list_--help.golden | 5 +- cli/testdata/coder_users_list_--help.golden | 9 ++- cli/testdata/coder_users_show_--help.golden | 2 +- cli/tokens.go | 25 +++----- cli/tokens_test.go | 14 +++++ cli/userlist.go | 5 +- cli/userstatus.go | 4 +- codersdk/apikey.go | 8 +-- codersdk/parameters.go | 2 +- 21 files changed, 207 insertions(+), 111 deletions(-) diff --git a/cli/cliui/table.go b/cli/cliui/table.go index 1defbdca71d9d..b7ed7a2dff2e6 100644 --- a/cli/cliui/table.go +++ b/cli/cliui/table.go @@ -107,7 +107,7 @@ func DisplayTable(out any, sort string, filterColumns []string) (string, error) column := strings.ToLower(strings.ReplaceAll(column, "_", " ")) h, ok := headersMap[column] if !ok { - return "", xerrors.Errorf(`specified filter column %q not found in table headers, available columns are "%v"`, sort, strings.Join(headersRaw, `", "`)) + return "", xerrors.Errorf(`specified filter column %q not found in table headers, available columns are "%v"`, column, strings.Join(headersRaw, `", "`)) } // Autocorrect diff --git a/cli/list.go b/cli/list.go index 4b6275a854896..510589eff64d0 100644 --- a/cli/list.go +++ b/cli/list.go @@ -2,7 +2,6 @@ package cli import ( "fmt" - "strings" "time" "github.com/google/uuid" @@ -14,14 +13,21 @@ import ( "github.com/coder/coder/codersdk" ) +// workspaceListRow is the type provided to the OutputFormatter. This is a bit +// dodgy but it's the only way to do complex display code for one format vs. the +// other. type workspaceListRow struct { - Workspace string `table:"workspace"` - Template string `table:"template"` - Status string `table:"status"` - LastBuilt string `table:"last built"` - Outdated bool `table:"outdated"` - StartsAt string `table:"starts at"` - StopsAfter string `table:"stops after"` + // For JSON format: + codersdk.Workspace `table:"-"` + + // For table format: + WorkspaceName string `json:"-" table:"workspace,default_sort"` + Template string `json:"-" table:"template"` + Status string `json:"-" table:"status"` + LastBuilt string `json:"-" table:"last built"` + Outdated bool `json:"-" table:"outdated"` + StartsAt string `json:"-" table:"starts at"` + StopsAfter string `json:"-" table:"stops after"` } func workspaceListRowFromWorkspace(now time.Time, usersByID map[uuid.UUID]codersdk.User, workspace codersdk.Workspace) workspaceListRow { @@ -47,24 +53,27 @@ func workspaceListRowFromWorkspace(now time.Time, usersByID map[uuid.UUID]coders user := usersByID[workspace.OwnerID] return workspaceListRow{ - Workspace: user.Username + "/" + workspace.Name, - Template: workspace.TemplateName, - Status: status, - LastBuilt: durationDisplay(lastBuilt), - Outdated: workspace.Outdated, - StartsAt: autostartDisplay, - StopsAfter: autostopDisplay, + Workspace: workspace, + WorkspaceName: user.Username + "/" + workspace.Name, + Template: workspace.TemplateName, + Status: status, + LastBuilt: durationDisplay(lastBuilt), + Outdated: workspace.Outdated, + StartsAt: autostartDisplay, + StopsAfter: autostopDisplay, } } func list() *cobra.Command { var ( all bool - columns []string defaultQuery = "owner:me" searchQuery string - me bool displayWorkspaces []workspaceListRow + formatter = cliui.NewOutputFormatter( + cliui.TableFormat([]workspaceListRow{}, nil), + cliui.JSONFormat(), + ) ) cmd := &cobra.Command{ Annotations: workspaceCommand, @@ -85,14 +94,6 @@ func list() *cobra.Command { filter.FilterQuery = "" } - if me { - myUser, err := client.User(cmd.Context(), codersdk.Me) - if err != nil { - return err - } - filter.Owner = myUser.Username - } - res, err := client.Workspaces(cmd.Context(), filter) if err != nil { return err @@ -121,7 +122,7 @@ func list() *cobra.Command { displayWorkspaces[i] = workspaceListRowFromWorkspace(now, usersByID, workspace) } - out, err := cliui.DisplayTable(displayWorkspaces, "workspace", columns) + out, err := formatter.Format(cmd.Context(), displayWorkspaces) if err != nil { return err } @@ -131,14 +132,10 @@ func list() *cobra.Command { }, } - // TODO: fix this - availColumns := []string{"name"} - columnString := strings.Join(availColumns[:], ", ") - cmd.Flags().BoolVarP(&all, "all", "a", false, "Specifies whether all workspaces will be listed or not.") - cmd.Flags().StringArrayVarP(&columns, "column", "c", nil, - fmt.Sprintf("Specify a column to filter in the table. Available columns are: %v", columnString)) cmd.Flags().StringVar(&searchQuery, "search", defaultQuery, "Search for a workspace with a query.") + + formatter.AttachFlags(cmd) return cmd } diff --git a/cli/list_test.go b/cli/list_test.go index e3309e05763f2..7fa1b318ac7f6 100644 --- a/cli/list_test.go +++ b/cli/list_test.go @@ -1,13 +1,17 @@ package cli_test import ( + "bytes" "context" + "encoding/json" "testing" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" "github.com/coder/coder/cli/clitest" "github.com/coder/coder/coderd/coderdtest" + "github.com/coder/coder/codersdk" "github.com/coder/coder/pty/ptytest" "github.com/coder/coder/testutil" ) @@ -42,4 +46,27 @@ func TestList(t *testing.T) { cancelFunc() <-done }) + + t.Run("JSON", func(t *testing.T) { + t.Parallel() + client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true}) + user := coderdtest.CreateFirstUser(t, client) + version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, nil) + coderdtest.AwaitTemplateVersionJob(t, client, version.ID) + template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID) + workspace := coderdtest.CreateWorkspace(t, client, user.OrganizationID, template.ID) + coderdtest.AwaitWorkspaceBuildJob(t, client, workspace.LatestBuild.ID) + + cmd, root := clitest.New(t, "list", "--output=json") + clitest.SetupConfig(t, client, root) + + out := bytes.NewBuffer(nil) + cmd.SetOut(out) + err := cmd.Execute() + require.NoError(t, err) + + var templates []codersdk.Workspace + require.NoError(t, json.Unmarshal(out.Bytes(), &templates)) + require.Len(t, templates, 1) + }) } diff --git a/cli/parameterslist.go b/cli/parameterslist.go index 3978df26a850f..1249f2a642be7 100644 --- a/cli/parameterslist.go +++ b/cli/parameterslist.go @@ -12,9 +12,11 @@ import ( ) func parameterList() *cobra.Command { - var ( - columns []string + formatter := cliui.NewOutputFormatter( + cliui.TableFormat([]codersdk.Parameter{}, []string{"name", "scope", "destination scheme"}), + cliui.JSONFormat(), ) + cmd := &cobra.Command{ Use: "list", Aliases: []string{"ls"}, @@ -71,16 +73,16 @@ func parameterList() *cobra.Command { return xerrors.Errorf("fetch params: %w", err) } - out, err := cliui.DisplayTable(params, "name", columns) + out, err := formatter.Format(cmd.Context(), params) if err != nil { - return xerrors.Errorf("render table: %w", err) + return xerrors.Errorf("render output: %w", err) } _, err = fmt.Fprintln(cmd.OutOrStdout(), out) return err }, } - cmd.Flags().StringArrayVarP(&columns, "column", "c", []string{"name", "scope", "destination scheme"}, - "Specify a column to filter in the table.") + + formatter.AttachFlags(cmd) return cmd } diff --git a/cli/ssh_test.go b/cli/ssh_test.go index 1e9ddf2e8f221..a943de616a34c 100644 --- a/cli/ssh_test.go +++ b/cli/ssh_test.go @@ -306,6 +306,9 @@ func TestSSH_ForwardGPG(t *testing.T) { // same process. t.Skip("Test not supported on windows") } + if testing.Short() { + t.SkipNow() + } // This key is for dean@coder.com. const randPublicKeyFingerprint = "7BDFBA0CC7F5A96537C806C427BC6335EB5117F1" diff --git a/cli/templatelist.go b/cli/templatelist.go index e528687a7459b..874f74e876c63 100644 --- a/cli/templatelist.go +++ b/cli/templatelist.go @@ -5,12 +5,16 @@ import ( "github.com/fatih/color" "github.com/spf13/cobra" + + "github.com/coder/coder/cli/cliui" ) func templateList() *cobra.Command { - var ( - columns []string + formatter := cliui.NewOutputFormatter( + cliui.TableFormat([]templateTableRow{}, []string{"name", "last updated", "used by"}), + cliui.JSONFormat(), ) + cmd := &cobra.Command{ Use: "list", Short: "List all the templates available for the organization", @@ -35,7 +39,8 @@ func templateList() *cobra.Command { return nil } - out, err := displayTemplates(columns, templates...) + rows := templatesToRows(templates...) + out, err := formatter.Format(cmd.Context(), rows) if err != nil { return err } @@ -44,7 +49,7 @@ func templateList() *cobra.Command { return err }, } - cmd.Flags().StringArrayVarP(&columns, "column", "c", []string{"name", "last_updated", "used_by"}, - "Specify a column to filter in the table.") + + formatter.AttachFlags(cmd) return cmd } diff --git a/cli/templatelist_test.go b/cli/templatelist_test.go index c113dc7e4f0b1..5a07a056a02a6 100644 --- a/cli/templatelist_test.go +++ b/cli/templatelist_test.go @@ -1,6 +1,8 @@ package cli_test import ( + "bytes" + "encoding/json" "sort" "testing" @@ -8,6 +10,7 @@ import ( "github.com/coder/coder/cli/clitest" "github.com/coder/coder/coderd/coderdtest" + "github.com/coder/coder/codersdk" "github.com/coder/coder/pty/ptytest" ) @@ -37,7 +40,7 @@ func TestTemplateList(t *testing.T) { errC <- cmd.Execute() }() - // expect that templates are listed alphebetically + // expect that templates are listed alphabetically var templatesList = []string{firstTemplate.Name, secondTemplate.Name} sort.Strings(templatesList) @@ -47,6 +50,30 @@ func TestTemplateList(t *testing.T) { pty.ExpectMatch(name) } }) + t.Run("ListTemplatesJSON", func(t *testing.T) { + t.Parallel() + client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true}) + user := coderdtest.CreateFirstUser(t, client) + firstVersion := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, nil) + _ = coderdtest.AwaitTemplateVersionJob(t, client, firstVersion.ID) + _ = coderdtest.CreateTemplate(t, client, user.OrganizationID, firstVersion.ID) + + secondVersion := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, nil) + _ = coderdtest.AwaitTemplateVersionJob(t, client, secondVersion.ID) + _ = coderdtest.CreateTemplate(t, client, user.OrganizationID, secondVersion.ID) + + cmd, root := clitest.New(t, "templates", "list", "--output=json") + clitest.SetupConfig(t, client, root) + + out := bytes.NewBuffer(nil) + cmd.SetOut(out) + err := cmd.Execute() + require.NoError(t, err) + + var templates []codersdk.Template + require.NoError(t, json.Unmarshal(out.Bytes(), &templates)) + require.Len(t, templates, 2) + }) t.Run("NoTemplates", func(t *testing.T) { t.Parallel() client := coderdtest.New(t, &coderdtest.Options{}) diff --git a/cli/templates.go b/cli/templates.go index 7f8aa7e861792..58bd1bdde1742 100644 --- a/cli/templates.go +++ b/cli/templates.go @@ -50,23 +50,27 @@ func templates() *cobra.Command { } type templateTableRow struct { - Name string `table:"name"` - CreatedAt string `table:"created at"` - LastUpdated string `table:"last updated"` - OrganizationID uuid.UUID `table:"organization id"` - Provisioner codersdk.ProvisionerType `table:"provisioner"` - ActiveVersionID uuid.UUID `table:"active version id"` - UsedBy string `table:"used by"` - DefaultTTL time.Duration `table:"default ttl"` + // Used by json format: + Template codersdk.Template + + // Used by table format: + Name string `json:"-" table:"name,default_sort"` + CreatedAt string `json:"-" table:"created at"` + LastUpdated string `json:"-" table:"last updated"` + OrganizationID uuid.UUID `json:"-" table:"organization id"` + Provisioner codersdk.ProvisionerType `json:"-" table:"provisioner"` + ActiveVersionID uuid.UUID `json:"-" table:"active version id"` + UsedBy string `json:"-" table:"used by"` + DefaultTTL time.Duration `json:"-" table:"default ttl"` } -// displayTemplates will return a table displaying all templates passed in. -// filterColumns must be a subset of the template fields and will determine which -// columns to display -func displayTemplates(filterColumns []string, templates ...codersdk.Template) (string, error) { +// templateToRows converts a list of templates to a list of templateTableRow for +// outputting. +func templatesToRows(templates ...codersdk.Template) []templateTableRow { rows := make([]templateTableRow, len(templates)) for i, template := range templates { rows[i] = templateTableRow{ + Template: template, Name: template.Name, CreatedAt: template.CreatedAt.Format("January 2, 2006"), LastUpdated: template.UpdatedAt.Format("January 2, 2006"), @@ -78,5 +82,5 @@ func displayTemplates(filterColumns []string, templates ...codersdk.Template) (s } } - return cliui.DisplayTable(rows, "name", filterColumns) + return rows } diff --git a/cli/templateversions.go b/cli/templateversions.go index 147a15753b930..c8c50de2f3054 100644 --- a/cli/templateversions.go +++ b/cli/templateversions.go @@ -36,7 +36,12 @@ func templateVersions() *cobra.Command { } func templateVersionsList() *cobra.Command { - return &cobra.Command{ + formatter := cliui.NewOutputFormatter( + cliui.TableFormat([]templateVersionRow{}, nil), + cliui.JSONFormat(), + ) + + cmd := &cobra.Command{ Use: "list