mirror of
https://github.com/coder/coder.git
synced 2025-07-18 14:17:22 +00:00
feat: add JSON output format to many CLI commands (#6082)
This commit is contained in:
156
cli/cliui/output.go
Normal file
156
cli/cliui/output.go
Normal file
@ -0,0 +1,156 @@
|
|||||||
|
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 format.ID() == "" {
|
||||||
|
panic("output format ID must not be empty")
|
||||||
|
}
|
||||||
|
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
|
||||||
|
}
|
128
cli/cliui/output_test.go
Normal file
128
cli/cliui/output_test.go
Normal file
@ -0,0 +1,128 @@
|
|||||||
|
package cliui_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"sync/atomic"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/spf13/cobra"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
|
||||||
|
"github.com/coder/coder/cli/cliui"
|
||||||
|
)
|
||||||
|
|
||||||
|
type format struct {
|
||||||
|
id string
|
||||||
|
attachFlagsFn func(cmd *cobra.Command)
|
||||||
|
formatFn func(ctx context.Context, data any) (string, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
var _ cliui.OutputFormat = &format{}
|
||||||
|
|
||||||
|
func (f *format) ID() string {
|
||||||
|
return f.id
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *format) AttachFlags(cmd *cobra.Command) {
|
||||||
|
if f.attachFlagsFn != nil {
|
||||||
|
f.attachFlagsFn(cmd)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *format) Format(ctx context.Context, data any) (string, error) {
|
||||||
|
if f.formatFn != nil {
|
||||||
|
return f.formatFn(ctx, data)
|
||||||
|
}
|
||||||
|
|
||||||
|
return "", nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func Test_OutputFormatter(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
t.Run("RequiresTwoFormatters", func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
require.Panics(t, func() {
|
||||||
|
cliui.NewOutputFormatter()
|
||||||
|
})
|
||||||
|
require.Panics(t, func() {
|
||||||
|
cliui.NewOutputFormatter(cliui.JSONFormat())
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("NoMissingFormatID", func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
require.Panics(t, func() {
|
||||||
|
cliui.NewOutputFormatter(
|
||||||
|
cliui.JSONFormat(),
|
||||||
|
&format{id: ""},
|
||||||
|
)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("NoDuplicateFormats", func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
require.Panics(t, func() {
|
||||||
|
cliui.NewOutputFormatter(
|
||||||
|
cliui.JSONFormat(),
|
||||||
|
cliui.JSONFormat(),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("OK", func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
var called int64
|
||||||
|
f := cliui.NewOutputFormatter(
|
||||||
|
cliui.JSONFormat(),
|
||||||
|
&format{
|
||||||
|
id: "foo",
|
||||||
|
attachFlagsFn: func(cmd *cobra.Command) {
|
||||||
|
cmd.Flags().StringP("foo", "f", "", "foo flag 1234")
|
||||||
|
},
|
||||||
|
formatFn: func(_ context.Context, _ any) (string, error) {
|
||||||
|
atomic.AddInt64(&called, 1)
|
||||||
|
return "foo", nil
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
cmd := &cobra.Command{}
|
||||||
|
f.AttachFlags(cmd)
|
||||||
|
|
||||||
|
selected, err := cmd.Flags().GetString("output")
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, "json", selected)
|
||||||
|
usage := cmd.Flags().FlagUsages()
|
||||||
|
require.Contains(t, usage, "Available formats: json, foo")
|
||||||
|
require.Contains(t, usage, "foo flag 1234")
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
data := []string{"hi", "dean", "was", "here"}
|
||||||
|
out, err := f.Format(ctx, data)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
var got []string
|
||||||
|
require.NoError(t, json.Unmarshal([]byte(out), &got))
|
||||||
|
require.Equal(t, data, got)
|
||||||
|
require.EqualValues(t, 0, atomic.LoadInt64(&called))
|
||||||
|
|
||||||
|
require.NoError(t, cmd.Flags().Set("output", "foo"))
|
||||||
|
out, err = f.Format(ctx, data)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, "foo", out)
|
||||||
|
require.EqualValues(t, 1, atomic.LoadInt64(&called))
|
||||||
|
|
||||||
|
require.NoError(t, cmd.Flags().Set("output", "bar"))
|
||||||
|
out, err = f.Format(ctx, data)
|
||||||
|
require.Error(t, err)
|
||||||
|
require.ErrorContains(t, err, "bar")
|
||||||
|
require.Equal(t, "", out)
|
||||||
|
require.EqualValues(t, 1, atomic.LoadInt64(&called))
|
||||||
|
})
|
||||||
|
}
|
@ -22,10 +22,10 @@ func Table() table.Writer {
|
|||||||
return tableWriter
|
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,
|
// that are not provided in the array. If the array is empty,
|
||||||
// no filtering will occur!
|
// 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 {
|
if len(columns) == 0 {
|
||||||
return nil
|
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
|
// of structs. At least one field in the struct must have a `table:""` tag
|
||||||
// containing the name of the column in the outputted table.
|
// 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"`
|
// 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
|
// 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
|
// 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.
|
// Get the list of table column headers.
|
||||||
headersRaw, err := typeToTableHeaders(v.Type().Elem())
|
headersRaw, defaultSort, err := typeToTableHeaders(v.Type().Elem())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", xerrors.Errorf("get table headers recursively for type %q: %w", v.Type().Elem().String(), err)
|
return "", xerrors.Errorf("get table headers recursively for type %q: %w", v.Type().Elem().String(), err)
|
||||||
}
|
}
|
||||||
if len(headersRaw) == 0 {
|
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`)
|
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))
|
headers := make(table.Row, len(headersRaw))
|
||||||
for i, header := range headersRaw {
|
for i, header := range headersRaw {
|
||||||
headers[i] = header
|
headers[i] = header
|
||||||
@ -101,7 +107,7 @@ func DisplayTable(out any, sort string, filterColumns []string) (string, error)
|
|||||||
column := strings.ToLower(strings.ReplaceAll(column, "_", " "))
|
column := strings.ToLower(strings.ReplaceAll(column, "_", " "))
|
||||||
h, ok := headersMap[column]
|
h, ok := headersMap[column]
|
||||||
if !ok {
|
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
|
// Autocorrect
|
||||||
@ -128,7 +134,7 @@ func DisplayTable(out any, sort string, filterColumns []string) (string, error)
|
|||||||
// Setup the table formatter.
|
// Setup the table formatter.
|
||||||
tw := Table()
|
tw := Table()
|
||||||
tw.AppendHeader(headers)
|
tw.AppendHeader(headers)
|
||||||
tw.SetColumnConfigs(FilterTableColumns(headers, filterColumns))
|
tw.SetColumnConfigs(filterTableColumns(headers, filterColumns))
|
||||||
if sort != "" {
|
if sort != "" {
|
||||||
tw.SortBy([]table.SortBy{{
|
tw.SortBy([]table.SortBy{{
|
||||||
Name: sort,
|
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.
|
// returned. If the table tag is malformed, an error is returned.
|
||||||
//
|
//
|
||||||
// The returned name is transformed from "snake_case" to "normal text".
|
// 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))
|
tags, err := structtag.Parse(string(field.Tag))
|
||||||
if err != nil {
|
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")
|
tag, err := tags.Get("table")
|
||||||
if err != nil || tag.Name == "-" {
|
if err != nil || tag.Name == "-" {
|
||||||
// tags.Get only returns an error if the tag is not found.
|
// 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 {
|
for _, opt := range tag.Options {
|
||||||
if opt == "recursive" {
|
switch opt {
|
||||||
recursive = true
|
case "default_sort":
|
||||||
continue
|
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 {
|
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
|
// 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
|
// type is invalid (not a struct or a pointer to a struct, has invalid table
|
||||||
// tags, etc.), an error is returned.
|
// tags, etc.), an error is returned.
|
||||||
func typeToTableHeaders(t reflect.Type) ([]string, error) {
|
func typeToTableHeaders(t reflect.Type) ([]string, string, error) {
|
||||||
if !isStructOrStructPointer(t) {
|
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 {
|
if t.Kind() == reflect.Pointer {
|
||||||
t = t.Elem()
|
t = t.Elem()
|
||||||
}
|
}
|
||||||
|
|
||||||
headers := []string{}
|
headers := []string{}
|
||||||
|
defaultSortName := ""
|
||||||
for i := 0; i < t.NumField(); i++ {
|
for i := 0; i < t.NumField(); i++ {
|
||||||
field := t.Field(i)
|
field := t.Field(i)
|
||||||
name, recursive, err := parseTableStructTag(field)
|
name, defaultSort, recursive, err := parseTableStructTag(field)
|
||||||
if err != nil {
|
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 == "" {
|
if name == "" {
|
||||||
continue
|
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
|
fieldType := field.Type
|
||||||
if recursive {
|
if recursive {
|
||||||
if !isStructOrStructPointer(fieldType) {
|
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 {
|
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 {
|
for _, childName := range childNames {
|
||||||
headers = append(headers, fmt.Sprintf("%s %s", name, childName))
|
headers = append(headers, fmt.Sprintf("%s %s", name, childName))
|
||||||
@ -252,7 +268,11 @@ func typeToTableHeaders(t reflect.Type) ([]string, error) {
|
|||||||
headers = append(headers, name)
|
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
|
// 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++ {
|
for i := 0; i < val.NumField(); i++ {
|
||||||
field := val.Type().Field(i)
|
field := val.Type().Field(i)
|
||||||
fieldVal := val.Field(i)
|
fieldVal := val.Field(i)
|
||||||
name, recursive, err := parseTableStructTag(field)
|
name, _, recursive, err := parseTableStructTag(field)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, xerrors.Errorf("parse struct tags for field %q in type %T: %w", field.Name, val, err)
|
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
|
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
|
|
||||||
}
|
|
||||||
|
@ -24,7 +24,7 @@ func (s stringWrapper) String() string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type tableTest1 struct {
|
type tableTest1 struct {
|
||||||
Name string `table:"name"`
|
Name string `table:"name,default_sort"`
|
||||||
NotIncluded string // no table tag
|
NotIncluded string // no table tag
|
||||||
Age int `table:"age"`
|
Age int `table:"age"`
|
||||||
Roles []string `table:"roles"`
|
Roles []string `table:"roles"`
|
||||||
@ -39,21 +39,45 @@ type tableTest1 struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type tableTest2 struct {
|
type tableTest2 struct {
|
||||||
Name stringWrapper `table:"name"`
|
Name stringWrapper `table:"name,default_sort"`
|
||||||
Age int `table:"age"`
|
Age int `table:"age"`
|
||||||
NotIncluded string `table:"-"`
|
NotIncluded string `table:"-"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type tableTest3 struct {
|
type tableTest3 struct {
|
||||||
NotIncluded string // no table tag
|
NotIncluded string // no table tag
|
||||||
Sub tableTest2 `table:"inner,recursive"`
|
Sub tableTest2 `table:"inner,recursive,default_sort"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func Test_DisplayTable(t *testing.T) {
|
func Test_DisplayTable(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
someTime := time.Date(2022, 8, 2, 15, 49, 10, 0, time.UTC)
|
someTime := time.Date(2022, 8, 2, 15, 49, 10, 0, time.UTC)
|
||||||
|
|
||||||
|
// Not sorted by name or age to test sorting.
|
||||||
in := []tableTest1{
|
in := []tableTest1{
|
||||||
|
{
|
||||||
|
Name: "bar",
|
||||||
|
Age: 20,
|
||||||
|
Roles: []string{"a"},
|
||||||
|
Sub1: tableTest2{
|
||||||
|
Name: stringWrapper{str: "bar1"},
|
||||||
|
Age: 21,
|
||||||
|
},
|
||||||
|
Sub2: nil,
|
||||||
|
Sub3: tableTest3{
|
||||||
|
Sub: tableTest2{
|
||||||
|
Name: stringWrapper{str: "bar3"},
|
||||||
|
Age: 23,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Sub4: tableTest2{
|
||||||
|
Name: stringWrapper{str: "bar4"},
|
||||||
|
Age: 24,
|
||||||
|
},
|
||||||
|
Time: someTime,
|
||||||
|
TimePtr: nil,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
Name: "foo",
|
Name: "foo",
|
||||||
Age: 10,
|
Age: 10,
|
||||||
@ -79,28 +103,6 @@ func Test_DisplayTable(t *testing.T) {
|
|||||||
Time: someTime,
|
Time: someTime,
|
||||||
TimePtr: &someTime,
|
TimePtr: &someTime,
|
||||||
},
|
},
|
||||||
{
|
|
||||||
Name: "bar",
|
|
||||||
Age: 20,
|
|
||||||
Roles: []string{"a"},
|
|
||||||
Sub1: tableTest2{
|
|
||||||
Name: stringWrapper{str: "bar1"},
|
|
||||||
Age: 21,
|
|
||||||
},
|
|
||||||
Sub2: nil,
|
|
||||||
Sub3: tableTest3{
|
|
||||||
Sub: tableTest2{
|
|
||||||
Name: stringWrapper{str: "bar3"},
|
|
||||||
Age: 23,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
Sub4: tableTest2{
|
|
||||||
Name: stringWrapper{str: "bar4"},
|
|
||||||
Age: 24,
|
|
||||||
},
|
|
||||||
Time: someTime,
|
|
||||||
TimePtr: nil,
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
Name: "baz",
|
Name: "baz",
|
||||||
Age: 30,
|
Age: 30,
|
||||||
@ -132,9 +134,9 @@ func Test_DisplayTable(t *testing.T) {
|
|||||||
|
|
||||||
expected := `
|
expected := `
|
||||||
NAME AGE ROLES SUB 1 NAME SUB 1 AGE SUB 2 NAME SUB 2 AGE SUB 3 INNER NAME SUB 3 INNER AGE SUB 4 TIME TIME PTR
|
NAME AGE ROLES SUB 1 NAME SUB 1 AGE SUB 2 NAME SUB 2 AGE SUB 3 INNER NAME SUB 3 INNER AGE SUB 4 TIME TIME PTR
|
||||||
foo 10 [a b c] foo1 11 foo2 12 foo3 13 {foo4 14 } 2022-08-02T15:49:10Z 2022-08-02T15:49:10Z
|
|
||||||
bar 20 [a] bar1 21 <nil> <nil> bar3 23 {bar4 24 } 2022-08-02T15:49:10Z <nil>
|
bar 20 [a] bar1 21 <nil> <nil> bar3 23 {bar4 24 } 2022-08-02T15:49:10Z <nil>
|
||||||
baz 30 [] baz1 31 <nil> <nil> baz3 33 {baz4 34 } 2022-08-02T15:49:10Z <nil>
|
baz 30 [] baz1 31 <nil> <nil> baz3 33 {baz4 34 } 2022-08-02T15:49:10Z <nil>
|
||||||
|
foo 10 [a b c] foo1 11 foo2 12 foo3 13 {foo4 14 } 2022-08-02T15:49:10Z 2022-08-02T15:49:10Z
|
||||||
`
|
`
|
||||||
|
|
||||||
// Test with non-pointer values.
|
// Test with non-pointer values.
|
||||||
@ -154,17 +156,17 @@ baz 30 [] baz1 31 <nil> <nil> baz3
|
|||||||
compareTables(t, expected, out)
|
compareTables(t, expected, out)
|
||||||
})
|
})
|
||||||
|
|
||||||
t.Run("Sort", func(t *testing.T) {
|
t.Run("CustomSort", func(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
expected := `
|
expected := `
|
||||||
NAME AGE ROLES SUB 1 NAME SUB 1 AGE SUB 2 NAME SUB 2 AGE SUB 3 INNER NAME SUB 3 INNER AGE SUB 4 TIME TIME PTR
|
NAME AGE ROLES SUB 1 NAME SUB 1 AGE SUB 2 NAME SUB 2 AGE SUB 3 INNER NAME SUB 3 INNER AGE SUB 4 TIME TIME PTR
|
||||||
|
foo 10 [a b c] foo1 11 foo2 12 foo3 13 {foo4 14 } 2022-08-02T15:49:10Z 2022-08-02T15:49:10Z
|
||||||
bar 20 [a] bar1 21 <nil> <nil> bar3 23 {bar4 24 } 2022-08-02T15:49:10Z <nil>
|
bar 20 [a] bar1 21 <nil> <nil> bar3 23 {bar4 24 } 2022-08-02T15:49:10Z <nil>
|
||||||
baz 30 [] baz1 31 <nil> <nil> baz3 33 {baz4 34 } 2022-08-02T15:49:10Z <nil>
|
baz 30 [] baz1 31 <nil> <nil> baz3 33 {baz4 34 } 2022-08-02T15:49:10Z <nil>
|
||||||
foo 10 [a b c] foo1 11 foo2 12 foo3 13 {foo4 14 } 2022-08-02T15:49:10Z 2022-08-02T15:49:10Z
|
|
||||||
`
|
`
|
||||||
|
|
||||||
out, err := cliui.DisplayTable(in, "name", nil)
|
out, err := cliui.DisplayTable(in, "age", nil)
|
||||||
log.Println("rendered table:\n" + out)
|
log.Println("rendered table:\n" + out)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
compareTables(t, expected, out)
|
compareTables(t, expected, out)
|
||||||
@ -175,9 +177,9 @@ foo 10 [a b c] foo1 11 foo2 12 foo3
|
|||||||
|
|
||||||
expected := `
|
expected := `
|
||||||
NAME SUB 1 NAME SUB 3 INNER NAME TIME
|
NAME SUB 1 NAME SUB 3 INNER NAME TIME
|
||||||
foo foo1 foo3 2022-08-02T15:49:10Z
|
|
||||||
bar bar1 bar3 2022-08-02T15:49:10Z
|
bar bar1 bar3 2022-08-02T15:49:10Z
|
||||||
baz baz1 baz3 2022-08-02T15:49:10Z
|
baz baz1 baz3 2022-08-02T15:49:10Z
|
||||||
|
foo foo1 foo3 2022-08-02T15:49:10Z
|
||||||
`
|
`
|
||||||
|
|
||||||
out, err := cliui.DisplayTable(in, "", []string{"name", "sub_1_name", "sub_3 inner name", "time"})
|
out, err := cliui.DisplayTable(in, "", []string{"name", "sub_1_name", "sub_3 inner name", "time"})
|
||||||
@ -327,28 +329,6 @@ baz baz1 baz3 2022-08-02T15:49:10Z
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func Test_TableHeaders(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
s := []tableTest1{}
|
|
||||||
expectedFields := []string{
|
|
||||||
"name",
|
|
||||||
"age",
|
|
||||||
"roles",
|
|
||||||
"sub_1_name",
|
|
||||||
"sub_1_age",
|
|
||||||
"sub_2_name",
|
|
||||||
"sub_2_age",
|
|
||||||
"sub_3_inner_name",
|
|
||||||
"sub_3_inner_age",
|
|
||||||
"sub_4",
|
|
||||||
"time",
|
|
||||||
"time_ptr",
|
|
||||||
}
|
|
||||||
headers, err := cliui.TableHeaders(s)
|
|
||||||
require.NoError(t, err)
|
|
||||||
require.EqualValues(t, expectedFields, headers)
|
|
||||||
}
|
|
||||||
|
|
||||||
// compareTables normalizes the incoming table lines
|
// compareTables normalizes the incoming table lines
|
||||||
func compareTables(t *testing.T, expected, out string) {
|
func compareTables(t *testing.T, expected, out string) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
63
cli/list.go
63
cli/list.go
@ -2,7 +2,6 @@ package cli
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"strings"
|
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
@ -14,14 +13,21 @@ import (
|
|||||||
"github.com/coder/coder/codersdk"
|
"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 {
|
type workspaceListRow struct {
|
||||||
Workspace string `table:"workspace"`
|
// For JSON format:
|
||||||
Template string `table:"template"`
|
codersdk.Workspace `table:"-"`
|
||||||
Status string `table:"status"`
|
|
||||||
LastBuilt string `table:"last built"`
|
// For table format:
|
||||||
Outdated bool `table:"outdated"`
|
WorkspaceName string `json:"-" table:"workspace,default_sort"`
|
||||||
StartsAt string `table:"starts at"`
|
Template string `json:"-" table:"template"`
|
||||||
StopsAfter string `table:"stops after"`
|
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 {
|
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]
|
user := usersByID[workspace.OwnerID]
|
||||||
return workspaceListRow{
|
return workspaceListRow{
|
||||||
Workspace: user.Username + "/" + workspace.Name,
|
Workspace: workspace,
|
||||||
Template: workspace.TemplateName,
|
WorkspaceName: user.Username + "/" + workspace.Name,
|
||||||
Status: status,
|
Template: workspace.TemplateName,
|
||||||
LastBuilt: durationDisplay(lastBuilt),
|
Status: status,
|
||||||
Outdated: workspace.Outdated,
|
LastBuilt: durationDisplay(lastBuilt),
|
||||||
StartsAt: autostartDisplay,
|
Outdated: workspace.Outdated,
|
||||||
StopsAfter: autostopDisplay,
|
StartsAt: autostartDisplay,
|
||||||
|
StopsAfter: autostopDisplay,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func list() *cobra.Command {
|
func list() *cobra.Command {
|
||||||
var (
|
var (
|
||||||
all bool
|
all bool
|
||||||
columns []string
|
|
||||||
defaultQuery = "owner:me"
|
defaultQuery = "owner:me"
|
||||||
searchQuery string
|
searchQuery string
|
||||||
me bool
|
|
||||||
displayWorkspaces []workspaceListRow
|
displayWorkspaces []workspaceListRow
|
||||||
|
formatter = cliui.NewOutputFormatter(
|
||||||
|
cliui.TableFormat([]workspaceListRow{}, nil),
|
||||||
|
cliui.JSONFormat(),
|
||||||
|
)
|
||||||
)
|
)
|
||||||
cmd := &cobra.Command{
|
cmd := &cobra.Command{
|
||||||
Annotations: workspaceCommand,
|
Annotations: workspaceCommand,
|
||||||
@ -85,14 +94,6 @@ func list() *cobra.Command {
|
|||||||
filter.FilterQuery = ""
|
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)
|
res, err := client.Workspaces(cmd.Context(), filter)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
@ -121,7 +122,7 @@ func list() *cobra.Command {
|
|||||||
displayWorkspaces[i] = workspaceListRowFromWorkspace(now, usersByID, workspace)
|
displayWorkspaces[i] = workspaceListRowFromWorkspace(now, usersByID, workspace)
|
||||||
}
|
}
|
||||||
|
|
||||||
out, err := cliui.DisplayTable(displayWorkspaces, "workspace", columns)
|
out, err := formatter.Format(cmd.Context(), displayWorkspaces)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@ -131,16 +132,10 @@ func list() *cobra.Command {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
availColumns, err := cliui.TableHeaders(displayWorkspaces)
|
|
||||||
if err != nil {
|
|
||||||
panic(err)
|
|
||||||
}
|
|
||||||
columnString := strings.Join(availColumns[:], ", ")
|
|
||||||
|
|
||||||
cmd.Flags().BoolVarP(&all, "all", "a", false,
|
cmd.Flags().BoolVarP(&all, "all", "a", false,
|
||||||
"Specifies whether all workspaces will be listed or not.")
|
"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.")
|
cmd.Flags().StringVar(&searchQuery, "search", defaultQuery, "Search for a workspace with a query.")
|
||||||
|
|
||||||
|
formatter.AttachFlags(cmd)
|
||||||
return cmd
|
return cmd
|
||||||
}
|
}
|
||||||
|
@ -1,13 +1,17 @@
|
|||||||
package cli_test
|
package cli_test
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
|
"encoding/json"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
|
||||||
"github.com/coder/coder/cli/clitest"
|
"github.com/coder/coder/cli/clitest"
|
||||||
"github.com/coder/coder/coderd/coderdtest"
|
"github.com/coder/coder/coderd/coderdtest"
|
||||||
|
"github.com/coder/coder/codersdk"
|
||||||
"github.com/coder/coder/pty/ptytest"
|
"github.com/coder/coder/pty/ptytest"
|
||||||
"github.com/coder/coder/testutil"
|
"github.com/coder/coder/testutil"
|
||||||
)
|
)
|
||||||
@ -42,4 +46,30 @@ func TestList(t *testing.T) {
|
|||||||
cancelFunc()
|
cancelFunc()
|
||||||
<-done
|
<-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)
|
||||||
|
|
||||||
|
ctx, cancelFunc := context.WithTimeout(context.Background(), testutil.WaitLong)
|
||||||
|
defer cancelFunc()
|
||||||
|
|
||||||
|
out := bytes.NewBuffer(nil)
|
||||||
|
cmd.SetOut(out)
|
||||||
|
err := cmd.ExecuteContext(ctx)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
var templates []codersdk.Workspace
|
||||||
|
require.NoError(t, json.Unmarshal(out.Bytes(), &templates))
|
||||||
|
require.Len(t, templates, 1)
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
@ -12,9 +12,11 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
func parameterList() *cobra.Command {
|
func parameterList() *cobra.Command {
|
||||||
var (
|
formatter := cliui.NewOutputFormatter(
|
||||||
columns []string
|
cliui.TableFormat([]codersdk.Parameter{}, []string{"name", "scope", "destination scheme"}),
|
||||||
|
cliui.JSONFormat(),
|
||||||
)
|
)
|
||||||
|
|
||||||
cmd := &cobra.Command{
|
cmd := &cobra.Command{
|
||||||
Use: "list",
|
Use: "list",
|
||||||
Aliases: []string{"ls"},
|
Aliases: []string{"ls"},
|
||||||
@ -71,16 +73,16 @@ func parameterList() *cobra.Command {
|
|||||||
return xerrors.Errorf("fetch params: %w", err)
|
return xerrors.Errorf("fetch params: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
out, err := cliui.DisplayTable(params, "name", columns)
|
out, err := formatter.Format(cmd.Context(), params)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return xerrors.Errorf("render table: %w", err)
|
return xerrors.Errorf("render output: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
_, err = fmt.Fprintln(cmd.OutOrStdout(), out)
|
_, err = fmt.Fprintln(cmd.OutOrStdout(), out)
|
||||||
return err
|
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
|
return cmd
|
||||||
}
|
}
|
||||||
|
@ -2,12 +2,14 @@ package cli_test
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
|
"context"
|
||||||
"flag"
|
"flag"
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"regexp"
|
||||||
"runtime"
|
"runtime"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
@ -20,6 +22,8 @@ import (
|
|||||||
"github.com/coder/coder/buildinfo"
|
"github.com/coder/coder/buildinfo"
|
||||||
"github.com/coder/coder/cli"
|
"github.com/coder/coder/cli"
|
||||||
"github.com/coder/coder/cli/clitest"
|
"github.com/coder/coder/cli/clitest"
|
||||||
|
"github.com/coder/coder/coderd/coderdtest"
|
||||||
|
"github.com/coder/coder/coderd/database/dbtestutil"
|
||||||
"github.com/coder/coder/codersdk"
|
"github.com/coder/coder/codersdk"
|
||||||
"github.com/coder/coder/testutil"
|
"github.com/coder/coder/testutil"
|
||||||
)
|
)
|
||||||
@ -28,6 +32,8 @@ import (
|
|||||||
// make update-golden-files
|
// make update-golden-files
|
||||||
var updateGoldenFiles = flag.Bool("update", false, "update .golden files")
|
var updateGoldenFiles = flag.Bool("update", false, "update .golden files")
|
||||||
|
|
||||||
|
var timestampRegex = regexp.MustCompile(`(?i)\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(.\d+)?Z`)
|
||||||
|
|
||||||
//nolint:tparallel,paralleltest // These test sets env vars.
|
//nolint:tparallel,paralleltest // These test sets env vars.
|
||||||
func TestCommandHelp(t *testing.T) {
|
func TestCommandHelp(t *testing.T) {
|
||||||
commonEnv := map[string]string{
|
commonEnv := map[string]string{
|
||||||
@ -35,6 +41,8 @@ func TestCommandHelp(t *testing.T) {
|
|||||||
"CODER_CONFIG_DIR": "~/.config/coderv2",
|
"CODER_CONFIG_DIR": "~/.config/coderv2",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
rootClient, replacements := prepareTestData(t)
|
||||||
|
|
||||||
type testCase struct {
|
type testCase struct {
|
||||||
name string
|
name string
|
||||||
cmd []string
|
cmd []string
|
||||||
@ -59,6 +67,14 @@ func TestCommandHelp(t *testing.T) {
|
|||||||
"CODER_AGENT_LOG_DIR": "/tmp",
|
"CODER_AGENT_LOG_DIR": "/tmp",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: "coder list --output json",
|
||||||
|
cmd: []string{"list", "--output", "json"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "coder users list --output json",
|
||||||
|
cmd: []string{"users", "list", "--output", "json"},
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
root := cli.Root(cli.AGPL())
|
root := cli.Root(cli.AGPL())
|
||||||
@ -111,21 +127,33 @@ ExtractCommandPathsLoop:
|
|||||||
}
|
}
|
||||||
err := os.Chdir(tmpwd)
|
err := os.Chdir(tmpwd)
|
||||||
var buf bytes.Buffer
|
var buf bytes.Buffer
|
||||||
root, _ := clitest.New(t, tt.cmd...)
|
cmd, cfg := clitest.New(t, tt.cmd...)
|
||||||
root.SetOut(&buf)
|
clitest.SetupConfig(t, rootClient, cfg)
|
||||||
|
cmd.SetOut(&buf)
|
||||||
assert.NoError(t, err)
|
assert.NoError(t, err)
|
||||||
err = root.ExecuteContext(ctx)
|
err = cmd.ExecuteContext(ctx)
|
||||||
err2 := os.Chdir(wd)
|
err2 := os.Chdir(wd)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
require.NoError(t, err2)
|
require.NoError(t, err2)
|
||||||
|
|
||||||
got := buf.Bytes()
|
got := buf.Bytes()
|
||||||
// Remove CRLF newlines (Windows).
|
|
||||||
got = bytes.ReplaceAll(got, []byte{'\r', '\n'}, []byte{'\n'})
|
|
||||||
|
|
||||||
// The `coder templates create --help` command prints the path
|
replace := map[string][]byte{
|
||||||
// to the working directory (--directory flag default value).
|
// Remove CRLF newlines (Windows).
|
||||||
got = bytes.ReplaceAll(got, []byte(fmt.Sprintf("%q", tmpwd)), []byte("\"[current directory]\""))
|
string([]byte{'\r', '\n'}): []byte("\n"),
|
||||||
|
// The `coder templates create --help` command prints the path
|
||||||
|
// to the working directory (--directory flag default value).
|
||||||
|
fmt.Sprintf("%q", tmpwd): []byte("\"[current directory]\""),
|
||||||
|
}
|
||||||
|
for k, v := range replacements {
|
||||||
|
replace[k] = []byte(v)
|
||||||
|
}
|
||||||
|
for k, v := range replace {
|
||||||
|
got = bytes.ReplaceAll(got, []byte(k), v)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Replace any timestamps with a placeholder.
|
||||||
|
got = timestampRegex.ReplaceAll(got, []byte("[timestamp]"))
|
||||||
|
|
||||||
gf := filepath.Join("testdata", strings.Replace(tt.name, " ", "_", -1)+".golden")
|
gf := filepath.Join("testdata", strings.Replace(tt.name, " ", "_", -1)+".golden")
|
||||||
if *updateGoldenFiles {
|
if *updateGoldenFiles {
|
||||||
@ -156,6 +184,56 @@ func extractVisibleCommandPaths(cmdPath []string, cmds []*cobra.Command) [][]str
|
|||||||
return cmdPaths
|
return cmdPaths
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func prepareTestData(t *testing.T) (*codersdk.Client, map[string]string) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
db, pubsub := dbtestutil.NewDB(t)
|
||||||
|
rootClient := coderdtest.New(t, &coderdtest.Options{
|
||||||
|
Database: db,
|
||||||
|
Pubsub: pubsub,
|
||||||
|
IncludeProvisionerDaemon: true,
|
||||||
|
})
|
||||||
|
firstUser := coderdtest.CreateFirstUser(t, rootClient)
|
||||||
|
secondUser, err := rootClient.CreateUser(ctx, codersdk.CreateUserRequest{
|
||||||
|
Email: "testuser2@coder.com",
|
||||||
|
Username: "testuser2",
|
||||||
|
Password: coderdtest.FirstUserParams.Password,
|
||||||
|
OrganizationID: firstUser.OrganizationID,
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
version := coderdtest.CreateTemplateVersion(t, rootClient, firstUser.OrganizationID, nil)
|
||||||
|
version = coderdtest.AwaitTemplateVersionJob(t, rootClient, version.ID)
|
||||||
|
template := coderdtest.CreateTemplate(t, rootClient, firstUser.OrganizationID, version.ID, func(req *codersdk.CreateTemplateRequest) {
|
||||||
|
req.Name = "test-template"
|
||||||
|
})
|
||||||
|
workspace := coderdtest.CreateWorkspace(t, rootClient, firstUser.OrganizationID, template.ID, func(req *codersdk.CreateWorkspaceRequest) {
|
||||||
|
req.Name = "test-workspace"
|
||||||
|
})
|
||||||
|
workspaceBuild := coderdtest.AwaitWorkspaceBuildJob(t, rootClient, workspace.LatestBuild.ID)
|
||||||
|
|
||||||
|
replacements := map[string]string{
|
||||||
|
firstUser.UserID.String(): "[first user ID]",
|
||||||
|
secondUser.ID.String(): "[second user ID]",
|
||||||
|
firstUser.OrganizationID.String(): "[first org ID]",
|
||||||
|
version.ID.String(): "[version ID]",
|
||||||
|
version.Name: "[version name]",
|
||||||
|
version.Job.ID.String(): "[version job ID]",
|
||||||
|
version.Job.FileID.String(): "[version file ID]",
|
||||||
|
version.Job.WorkerID.String(): "[version worker ID]",
|
||||||
|
template.ID.String(): "[template ID]",
|
||||||
|
workspace.ID.String(): "[workspace ID]",
|
||||||
|
workspaceBuild.ID.String(): "[workspace build ID]",
|
||||||
|
workspaceBuild.Job.ID.String(): "[workspace build job ID]",
|
||||||
|
workspaceBuild.Job.FileID.String(): "[workspace build file ID]",
|
||||||
|
workspaceBuild.Job.WorkerID.String(): "[workspace build worker ID]",
|
||||||
|
}
|
||||||
|
|
||||||
|
return rootClient, replacements
|
||||||
|
}
|
||||||
|
|
||||||
func TestRoot(t *testing.T) {
|
func TestRoot(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
t.Run("FormatCobraError", func(t *testing.T) {
|
t.Run("FormatCobraError", func(t *testing.T) {
|
||||||
|
@ -306,6 +306,9 @@ func TestSSH_ForwardGPG(t *testing.T) {
|
|||||||
// same process.
|
// same process.
|
||||||
t.Skip("Test not supported on windows")
|
t.Skip("Test not supported on windows")
|
||||||
}
|
}
|
||||||
|
if testing.Short() {
|
||||||
|
t.SkipNow()
|
||||||
|
}
|
||||||
|
|
||||||
// This key is for dean@coder.com.
|
// This key is for dean@coder.com.
|
||||||
const randPublicKeyFingerprint = "7BDFBA0CC7F5A96537C806C427BC6335EB5117F1"
|
const randPublicKeyFingerprint = "7BDFBA0CC7F5A96537C806C427BC6335EB5117F1"
|
||||||
|
@ -5,12 +5,16 @@ import (
|
|||||||
|
|
||||||
"github.com/fatih/color"
|
"github.com/fatih/color"
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
|
|
||||||
|
"github.com/coder/coder/cli/cliui"
|
||||||
)
|
)
|
||||||
|
|
||||||
func templateList() *cobra.Command {
|
func templateList() *cobra.Command {
|
||||||
var (
|
formatter := cliui.NewOutputFormatter(
|
||||||
columns []string
|
cliui.TableFormat([]templateTableRow{}, []string{"name", "last updated", "used by"}),
|
||||||
|
cliui.JSONFormat(),
|
||||||
)
|
)
|
||||||
|
|
||||||
cmd := &cobra.Command{
|
cmd := &cobra.Command{
|
||||||
Use: "list",
|
Use: "list",
|
||||||
Short: "List all the templates available for the organization",
|
Short: "List all the templates available for the organization",
|
||||||
@ -35,7 +39,8 @@ func templateList() *cobra.Command {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
out, err := displayTemplates(columns, templates...)
|
rows := templatesToRows(templates...)
|
||||||
|
out, err := formatter.Format(cmd.Context(), rows)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@ -44,7 +49,7 @@ func templateList() *cobra.Command {
|
|||||||
return err
|
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
|
return cmd
|
||||||
}
|
}
|
||||||
|
@ -1,6 +1,9 @@
|
|||||||
package cli_test
|
package cli_test
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
"sort"
|
"sort"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
@ -8,7 +11,9 @@ import (
|
|||||||
|
|
||||||
"github.com/coder/coder/cli/clitest"
|
"github.com/coder/coder/cli/clitest"
|
||||||
"github.com/coder/coder/coderd/coderdtest"
|
"github.com/coder/coder/coderd/coderdtest"
|
||||||
|
"github.com/coder/coder/codersdk"
|
||||||
"github.com/coder/coder/pty/ptytest"
|
"github.com/coder/coder/pty/ptytest"
|
||||||
|
"github.com/coder/coder/testutil"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestTemplateList(t *testing.T) {
|
func TestTemplateList(t *testing.T) {
|
||||||
@ -32,12 +37,15 @@ func TestTemplateList(t *testing.T) {
|
|||||||
cmd.SetIn(pty.Input())
|
cmd.SetIn(pty.Input())
|
||||||
cmd.SetOut(pty.Output())
|
cmd.SetOut(pty.Output())
|
||||||
|
|
||||||
|
ctx, cancelFunc := context.WithTimeout(context.Background(), testutil.WaitLong)
|
||||||
|
defer cancelFunc()
|
||||||
|
|
||||||
errC := make(chan error)
|
errC := make(chan error)
|
||||||
go func() {
|
go func() {
|
||||||
errC <- cmd.Execute()
|
errC <- cmd.ExecuteContext(ctx)
|
||||||
}()
|
}()
|
||||||
|
|
||||||
// expect that templates are listed alphebetically
|
// expect that templates are listed alphabetically
|
||||||
var templatesList = []string{firstTemplate.Name, secondTemplate.Name}
|
var templatesList = []string{firstTemplate.Name, secondTemplate.Name}
|
||||||
sort.Strings(templatesList)
|
sort.Strings(templatesList)
|
||||||
|
|
||||||
@ -47,6 +55,33 @@ func TestTemplateList(t *testing.T) {
|
|||||||
pty.ExpectMatch(name)
|
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)
|
||||||
|
|
||||||
|
ctx, cancelFunc := context.WithTimeout(context.Background(), testutil.WaitLong)
|
||||||
|
defer cancelFunc()
|
||||||
|
|
||||||
|
out := bytes.NewBuffer(nil)
|
||||||
|
cmd.SetOut(out)
|
||||||
|
err := cmd.ExecuteContext(ctx)
|
||||||
|
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.Run("NoTemplates", func(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
client := coderdtest.New(t, &coderdtest.Options{})
|
client := coderdtest.New(t, &coderdtest.Options{})
|
||||||
@ -59,9 +94,12 @@ func TestTemplateList(t *testing.T) {
|
|||||||
cmd.SetIn(pty.Input())
|
cmd.SetIn(pty.Input())
|
||||||
cmd.SetErr(pty.Output())
|
cmd.SetErr(pty.Output())
|
||||||
|
|
||||||
|
ctx, cancelFunc := context.WithTimeout(context.Background(), testutil.WaitLong)
|
||||||
|
defer cancelFunc()
|
||||||
|
|
||||||
errC := make(chan error)
|
errC := make(chan error)
|
||||||
go func() {
|
go func() {
|
||||||
errC <- cmd.Execute()
|
errC <- cmd.ExecuteContext(ctx)
|
||||||
}()
|
}()
|
||||||
|
|
||||||
require.NoError(t, <-errC)
|
require.NoError(t, <-errC)
|
||||||
|
@ -50,23 +50,27 @@ func templates() *cobra.Command {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type templateTableRow struct {
|
type templateTableRow struct {
|
||||||
Name string `table:"name"`
|
// Used by json format:
|
||||||
CreatedAt string `table:"created at"`
|
Template codersdk.Template
|
||||||
LastUpdated string `table:"last updated"`
|
|
||||||
OrganizationID uuid.UUID `table:"organization id"`
|
// Used by table format:
|
||||||
Provisioner codersdk.ProvisionerType `table:"provisioner"`
|
Name string `json:"-" table:"name,default_sort"`
|
||||||
ActiveVersionID uuid.UUID `table:"active version id"`
|
CreatedAt string `json:"-" table:"created at"`
|
||||||
UsedBy string `table:"used by"`
|
LastUpdated string `json:"-" table:"last updated"`
|
||||||
DefaultTTL time.Duration `table:"default ttl"`
|
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.
|
// templateToRows converts a list of templates to a list of templateTableRow for
|
||||||
// filterColumns must be a subset of the template fields and will determine which
|
// outputting.
|
||||||
// columns to display
|
func templatesToRows(templates ...codersdk.Template) []templateTableRow {
|
||||||
func displayTemplates(filterColumns []string, templates ...codersdk.Template) (string, error) {
|
|
||||||
rows := make([]templateTableRow, len(templates))
|
rows := make([]templateTableRow, len(templates))
|
||||||
for i, template := range templates {
|
for i, template := range templates {
|
||||||
rows[i] = templateTableRow{
|
rows[i] = templateTableRow{
|
||||||
|
Template: template,
|
||||||
Name: template.Name,
|
Name: template.Name,
|
||||||
CreatedAt: template.CreatedAt.Format("January 2, 2006"),
|
CreatedAt: template.CreatedAt.Format("January 2, 2006"),
|
||||||
LastUpdated: template.UpdatedAt.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
|
||||||
}
|
}
|
||||||
|
@ -36,7 +36,12 @@ func templateVersions() *cobra.Command {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func templateVersionsList() *cobra.Command {
|
func templateVersionsList() *cobra.Command {
|
||||||
return &cobra.Command{
|
formatter := cliui.NewOutputFormatter(
|
||||||
|
cliui.TableFormat([]templateVersionRow{}, nil),
|
||||||
|
cliui.JSONFormat(),
|
||||||
|
)
|
||||||
|
|
||||||
|
cmd := &cobra.Command{
|
||||||
Use: "list <template>",
|
Use: "list <template>",
|
||||||
Args: cobra.ExactArgs(1),
|
Args: cobra.ExactArgs(1),
|
||||||
Short: "List all the versions of the specified template",
|
Short: "List all the versions of the specified template",
|
||||||
@ -62,7 +67,8 @@ func templateVersionsList() *cobra.Command {
|
|||||||
return xerrors.Errorf("get template versions by template: %w", err)
|
return xerrors.Errorf("get template versions by template: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
out, err := displayTemplateVersions(template.ActiveVersionID, versions...)
|
rows := templateVersionsToRows(template.ActiveVersionID, versions...)
|
||||||
|
out, err := formatter.Format(cmd.Context(), rows)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return xerrors.Errorf("render table: %w", err)
|
return xerrors.Errorf("render table: %w", err)
|
||||||
}
|
}
|
||||||
@ -71,19 +77,26 @@ func templateVersionsList() *cobra.Command {
|
|||||||
return err
|
return err
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
formatter.AttachFlags(cmd)
|
||||||
|
return cmd
|
||||||
}
|
}
|
||||||
|
|
||||||
type templateVersionRow struct {
|
type templateVersionRow struct {
|
||||||
Name string `table:"name"`
|
// For json format:
|
||||||
CreatedAt time.Time `table:"created at"`
|
TemplateVersion codersdk.TemplateVersion `table:"-"`
|
||||||
CreatedBy string `table:"created by"`
|
|
||||||
Status string `table:"status"`
|
// For table format:
|
||||||
Active string `table:"active"`
|
Name string `json:"-" table:"name,default_sort"`
|
||||||
|
CreatedAt time.Time `json:"-" table:"created at"`
|
||||||
|
CreatedBy string `json:"-" table:"created by"`
|
||||||
|
Status string `json:"-" table:"status"`
|
||||||
|
Active string `json:"-" table:"active"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// displayTemplateVersions will return a table displaying existing
|
// templateVersionsToRows converts a list of template versions to a list of rows
|
||||||
// template versions for the specified template.
|
// for outputting.
|
||||||
func displayTemplateVersions(activeVersionID uuid.UUID, templateVersions ...codersdk.TemplateVersion) (string, error) {
|
func templateVersionsToRows(activeVersionID uuid.UUID, templateVersions ...codersdk.TemplateVersion) []templateVersionRow {
|
||||||
rows := make([]templateVersionRow, len(templateVersions))
|
rows := make([]templateVersionRow, len(templateVersions))
|
||||||
for i, templateVersion := range templateVersions {
|
for i, templateVersion := range templateVersions {
|
||||||
var activeStatus = ""
|
var activeStatus = ""
|
||||||
@ -100,5 +113,5 @@ func displayTemplateVersions(activeVersionID uuid.UUID, templateVersions ...code
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return cliui.DisplayTable(rows, "name", nil)
|
return rows
|
||||||
}
|
}
|
||||||
|
14
cli/testdata/coder_list_--help.golden
vendored
14
cli/testdata/coder_list_--help.golden
vendored
@ -7,12 +7,14 @@ Aliases:
|
|||||||
list, ls
|
list, ls
|
||||||
|
|
||||||
Flags:
|
Flags:
|
||||||
-a, --all Specifies whether all workspaces will be listed or not.
|
-a, --all Specifies whether all workspaces will be listed or not.
|
||||||
-c, --column stringArray Specify a column to filter in the table. Available columns are:
|
-c, --column strings Columns to display in table output. Available columns: workspace,
|
||||||
workspace, template, status, last_built, outdated, starts_at,
|
template, status, last built, outdated, starts at, stops after
|
||||||
stops_after
|
(default [workspace,template,status,last built,outdated,starts
|
||||||
-h, --help help for list
|
at,stops after])
|
||||||
--search string Search for a workspace with a query. (default "owner:me")
|
-h, --help help for list
|
||||||
|
-o, --output string Output format. Available formats: table, json (default "table")
|
||||||
|
--search string Search for a workspace with a query. (default "owner:me")
|
||||||
|
|
||||||
Global Flags:
|
Global Flags:
|
||||||
--global-config coder Path to the global coder config directory.
|
--global-config coder Path to the global coder config directory.
|
||||||
|
51
cli/testdata/coder_list_--output_json.golden
vendored
Normal file
51
cli/testdata/coder_list_--output_json.golden
vendored
Normal file
@ -0,0 +1,51 @@
|
|||||||
|
[
|
||||||
|
{
|
||||||
|
"id": "[workspace ID]",
|
||||||
|
"created_at": "[timestamp]",
|
||||||
|
"updated_at": "[timestamp]",
|
||||||
|
"owner_id": "[first user ID]",
|
||||||
|
"owner_name": "testuser",
|
||||||
|
"template_id": "[template ID]",
|
||||||
|
"template_name": "test-template",
|
||||||
|
"template_display_name": "",
|
||||||
|
"template_icon": "",
|
||||||
|
"template_allow_user_cancel_workspace_jobs": false,
|
||||||
|
"latest_build": {
|
||||||
|
"id": "[workspace build ID]",
|
||||||
|
"created_at": "[timestamp]",
|
||||||
|
"updated_at": "[timestamp]",
|
||||||
|
"workspace_id": "[workspace ID]",
|
||||||
|
"workspace_name": "test-workspace",
|
||||||
|
"workspace_owner_id": "[first user ID]",
|
||||||
|
"workspace_owner_name": "testuser",
|
||||||
|
"template_version_id": "[version ID]",
|
||||||
|
"template_version_name": "[version name]",
|
||||||
|
"build_number": 1,
|
||||||
|
"transition": "start",
|
||||||
|
"initiator_id": "[first user ID]",
|
||||||
|
"initiator_name": "testuser",
|
||||||
|
"job": {
|
||||||
|
"id": "[workspace build job ID]",
|
||||||
|
"created_at": "[timestamp]",
|
||||||
|
"started_at": "[timestamp]",
|
||||||
|
"completed_at": "[timestamp]",
|
||||||
|
"status": "succeeded",
|
||||||
|
"worker_id": "[workspace build worker ID]",
|
||||||
|
"file_id": "[workspace build file ID]",
|
||||||
|
"tags": {
|
||||||
|
"scope": "organization"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"reason": "initiator",
|
||||||
|
"resources": [],
|
||||||
|
"deadline": "[timestamp]",
|
||||||
|
"status": "running",
|
||||||
|
"daily_cost": 0
|
||||||
|
},
|
||||||
|
"outdated": false,
|
||||||
|
"name": "test-workspace",
|
||||||
|
"autostart_schedule": "CRON_TZ=US/Central 30 9 * * 1-5",
|
||||||
|
"ttl_ms": 28800000,
|
||||||
|
"last_used_at": "[timestamp]"
|
||||||
|
}
|
||||||
|
]
|
@ -7,9 +7,11 @@ Aliases:
|
|||||||
list, ls
|
list, ls
|
||||||
|
|
||||||
Flags:
|
Flags:
|
||||||
-c, --column stringArray Specify a column to filter in the table. (default
|
-c, --column strings Columns to display in table output. Available columns: name, created
|
||||||
[name,last_updated,used_by])
|
at, last updated, organization id, provisioner, active version id,
|
||||||
-h, --help help for list
|
used by, default ttl (default [name,last updated,used by])
|
||||||
|
-h, --help help for list
|
||||||
|
-o, --output string Output format. Available formats: table, json (default "table")
|
||||||
|
|
||||||
Global Flags:
|
Global Flags:
|
||||||
--global-config coder Path to the global coder config directory.
|
--global-config coder Path to the global coder config directory.
|
||||||
|
@ -4,7 +4,11 @@ Usage:
|
|||||||
coder templates versions list <template> [flags]
|
coder templates versions list <template> [flags]
|
||||||
|
|
||||||
Flags:
|
Flags:
|
||||||
-h, --help help for list
|
-c, --column strings Columns to display in table output. Available columns: name, created
|
||||||
|
at, created by, status, active (default [name,created at,created
|
||||||
|
by,status,active])
|
||||||
|
-h, --help help for list
|
||||||
|
-o, --output string Output format. Available formats: table, json (default "table")
|
||||||
|
|
||||||
Global Flags:
|
Global Flags:
|
||||||
--global-config coder Path to the global coder config directory.
|
--global-config coder Path to the global coder config directory.
|
||||||
|
5
cli/testdata/coder_tokens_list_--help.golden
vendored
5
cli/testdata/coder_tokens_list_--help.golden
vendored
@ -7,7 +7,10 @@ Aliases:
|
|||||||
list, ls
|
list, ls
|
||||||
|
|
||||||
Flags:
|
Flags:
|
||||||
-h, --help help for list
|
-c, --column strings Columns to display in table output. Available columns: id, last used,
|
||||||
|
expires at, created at (default [id,last used,expires at,created at])
|
||||||
|
-h, --help help for list
|
||||||
|
-o, --output string Output format. Available formats: table, json (default "table")
|
||||||
|
|
||||||
Global Flags:
|
Global Flags:
|
||||||
--global-config coder Path to the global coder config directory.
|
--global-config coder Path to the global coder config directory.
|
||||||
|
9
cli/testdata/coder_users_list_--help.golden
vendored
9
cli/testdata/coder_users_list_--help.golden
vendored
@ -5,11 +5,10 @@ Aliases:
|
|||||||
list, ls
|
list, ls
|
||||||
|
|
||||||
Flags:
|
Flags:
|
||||||
-c, --column stringArray Specify a column to filter in the table. Available columns are:
|
-c, --column strings Columns to display in table output. Available columns: id, username,
|
||||||
id, username, email, created_at, status. (default
|
email, created at, status (default [username,email,created_at,status])
|
||||||
[username,email,created_at,status])
|
-h, --help help for list
|
||||||
-h, --help help for list
|
-o, --output string Output format. Available formats: table, json (default "table")
|
||||||
-o, --output string Output format. Available formats are: table, json. (default "table")
|
|
||||||
|
|
||||||
Global Flags:
|
Global Flags:
|
||||||
--global-config coder Path to the global coder config directory.
|
--global-config coder Path to the global coder config directory.
|
||||||
|
33
cli/testdata/coder_users_list_--output_json.golden
vendored
Normal file
33
cli/testdata/coder_users_list_--output_json.golden
vendored
Normal file
@ -0,0 +1,33 @@
|
|||||||
|
[
|
||||||
|
{
|
||||||
|
"id": "[first user ID]",
|
||||||
|
"username": "testuser",
|
||||||
|
"email": "testuser@coder.com",
|
||||||
|
"created_at": "[timestamp]",
|
||||||
|
"last_seen_at": "[timestamp]",
|
||||||
|
"status": "active",
|
||||||
|
"organization_ids": [
|
||||||
|
"[first org ID]"
|
||||||
|
],
|
||||||
|
"roles": [
|
||||||
|
{
|
||||||
|
"name": "owner",
|
||||||
|
"display_name": "Owner"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"avatar_url": ""
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "[second user ID]",
|
||||||
|
"username": "testuser2",
|
||||||
|
"email": "testuser2@coder.com",
|
||||||
|
"created_at": "[timestamp]",
|
||||||
|
"last_seen_at": "[timestamp]",
|
||||||
|
"status": "active",
|
||||||
|
"organization_ids": [
|
||||||
|
"[first org ID]"
|
||||||
|
],
|
||||||
|
"roles": [],
|
||||||
|
"avatar_url": ""
|
||||||
|
}
|
||||||
|
]
|
2
cli/testdata/coder_users_show_--help.golden
vendored
2
cli/testdata/coder_users_show_--help.golden
vendored
@ -8,7 +8,7 @@ Get Started:
|
|||||||
|
|
||||||
Flags:
|
Flags:
|
||||||
-h, --help help for show
|
-h, --help help for show
|
||||||
-o, --output string Output format. Available formats are: table, json. (default "table")
|
-o, --output string Output format. Available formats: table, json (default "table")
|
||||||
|
|
||||||
Global Flags:
|
Global Flags:
|
||||||
--global-config coder Path to the global coder config directory.
|
--global-config coder Path to the global coder config directory.
|
||||||
|
@ -85,14 +85,12 @@ func createToken() *cobra.Command {
|
|||||||
return cmd
|
return cmd
|
||||||
}
|
}
|
||||||
|
|
||||||
type tokenRow struct {
|
|
||||||
ID string `table:"ID"`
|
|
||||||
LastUsed time.Time `table:"Last Used"`
|
|
||||||
ExpiresAt time.Time `table:"Expires At"`
|
|
||||||
CreatedAt time.Time `table:"Created At"`
|
|
||||||
}
|
|
||||||
|
|
||||||
func listTokens() *cobra.Command {
|
func listTokens() *cobra.Command {
|
||||||
|
formatter := cliui.NewOutputFormatter(
|
||||||
|
cliui.TableFormat([]codersdk.APIKey{}, nil),
|
||||||
|
cliui.JSONFormat(),
|
||||||
|
)
|
||||||
|
|
||||||
cmd := &cobra.Command{
|
cmd := &cobra.Command{
|
||||||
Use: "list",
|
Use: "list",
|
||||||
Aliases: []string{"ls"},
|
Aliases: []string{"ls"},
|
||||||
@ -114,17 +112,7 @@ func listTokens() *cobra.Command {
|
|||||||
))
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
var rows []tokenRow
|
out, err := formatter.Format(cmd.Context(), keys)
|
||||||
for _, key := range keys {
|
|
||||||
rows = append(rows, tokenRow{
|
|
||||||
ID: key.ID,
|
|
||||||
LastUsed: key.LastUsed,
|
|
||||||
ExpiresAt: key.ExpiresAt,
|
|
||||||
CreatedAt: key.CreatedAt,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
out, err := cliui.DisplayTable(rows, "", nil)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@ -134,6 +122,7 @@ func listTokens() *cobra.Command {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
formatter.AttachFlags(cmd)
|
||||||
return cmd
|
return cmd
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -2,6 +2,8 @@ package cli_test
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
"regexp"
|
"regexp"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
@ -9,6 +11,8 @@ import (
|
|||||||
|
|
||||||
"github.com/coder/coder/cli/clitest"
|
"github.com/coder/coder/cli/clitest"
|
||||||
"github.com/coder/coder/coderd/coderdtest"
|
"github.com/coder/coder/coderd/coderdtest"
|
||||||
|
"github.com/coder/coder/codersdk"
|
||||||
|
"github.com/coder/coder/testutil"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestTokens(t *testing.T) {
|
func TestTokens(t *testing.T) {
|
||||||
@ -16,12 +20,15 @@ func TestTokens(t *testing.T) {
|
|||||||
client := coderdtest.New(t, nil)
|
client := coderdtest.New(t, nil)
|
||||||
_ = coderdtest.CreateFirstUser(t, client)
|
_ = coderdtest.CreateFirstUser(t, client)
|
||||||
|
|
||||||
|
ctx, cancelFunc := context.WithTimeout(context.Background(), testutil.WaitLong)
|
||||||
|
defer cancelFunc()
|
||||||
|
|
||||||
// helpful empty response
|
// helpful empty response
|
||||||
cmd, root := clitest.New(t, "tokens", "ls")
|
cmd, root := clitest.New(t, "tokens", "ls")
|
||||||
clitest.SetupConfig(t, client, root)
|
clitest.SetupConfig(t, client, root)
|
||||||
buf := new(bytes.Buffer)
|
buf := new(bytes.Buffer)
|
||||||
cmd.SetOut(buf)
|
cmd.SetOut(buf)
|
||||||
err := cmd.Execute()
|
err := cmd.ExecuteContext(ctx)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
res := buf.String()
|
res := buf.String()
|
||||||
require.Contains(t, res, "tokens found")
|
require.Contains(t, res, "tokens found")
|
||||||
@ -30,7 +37,7 @@ func TestTokens(t *testing.T) {
|
|||||||
clitest.SetupConfig(t, client, root)
|
clitest.SetupConfig(t, client, root)
|
||||||
buf = new(bytes.Buffer)
|
buf = new(bytes.Buffer)
|
||||||
cmd.SetOut(buf)
|
cmd.SetOut(buf)
|
||||||
err = cmd.Execute()
|
err = cmd.ExecuteContext(ctx)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
res = buf.String()
|
res = buf.String()
|
||||||
require.NotEmpty(t, res)
|
require.NotEmpty(t, res)
|
||||||
@ -44,7 +51,7 @@ func TestTokens(t *testing.T) {
|
|||||||
clitest.SetupConfig(t, client, root)
|
clitest.SetupConfig(t, client, root)
|
||||||
buf = new(bytes.Buffer)
|
buf = new(bytes.Buffer)
|
||||||
cmd.SetOut(buf)
|
cmd.SetOut(buf)
|
||||||
err = cmd.Execute()
|
err = cmd.ExecuteContext(ctx)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
res = buf.String()
|
res = buf.String()
|
||||||
require.NotEmpty(t, res)
|
require.NotEmpty(t, res)
|
||||||
@ -54,11 +61,23 @@ func TestTokens(t *testing.T) {
|
|||||||
require.Contains(t, res, "LAST USED")
|
require.Contains(t, res, "LAST USED")
|
||||||
require.Contains(t, res, id)
|
require.Contains(t, res, id)
|
||||||
|
|
||||||
|
cmd, root = clitest.New(t, "tokens", "ls", "--output=json")
|
||||||
|
clitest.SetupConfig(t, client, root)
|
||||||
|
buf = new(bytes.Buffer)
|
||||||
|
cmd.SetOut(buf)
|
||||||
|
err = cmd.ExecuteContext(ctx)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
var tokens []codersdk.APIKey
|
||||||
|
require.NoError(t, json.Unmarshal(buf.Bytes(), &tokens))
|
||||||
|
require.Len(t, tokens, 1)
|
||||||
|
require.Equal(t, id, tokens[0].ID)
|
||||||
|
|
||||||
cmd, root = clitest.New(t, "tokens", "rm", id)
|
cmd, root = clitest.New(t, "tokens", "rm", id)
|
||||||
clitest.SetupConfig(t, client, root)
|
clitest.SetupConfig(t, client, root)
|
||||||
buf = new(bytes.Buffer)
|
buf = new(bytes.Buffer)
|
||||||
cmd.SetOut(buf)
|
cmd.SetOut(buf)
|
||||||
err = cmd.Execute()
|
err = cmd.ExecuteContext(ctx)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
res = buf.String()
|
res = buf.String()
|
||||||
require.NotEmpty(t, res)
|
require.NotEmpty(t, res)
|
||||||
|
101
cli/userlist.go
101
cli/userlist.go
@ -2,12 +2,9 @@ package cli
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/charmbracelet/lipgloss"
|
|
||||||
"github.com/jedib0t/go-pretty/v6/table"
|
"github.com/jedib0t/go-pretty/v6/table"
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
"golang.org/x/xerrors"
|
"golang.org/x/xerrors"
|
||||||
@ -17,9 +14,9 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
func userList() *cobra.Command {
|
func userList() *cobra.Command {
|
||||||
var (
|
formatter := cliui.NewOutputFormatter(
|
||||||
columns []string
|
cliui.TableFormat([]codersdk.User{}, []string{"username", "email", "created_at", "status"}),
|
||||||
outputFormat string
|
cliui.JSONFormat(),
|
||||||
)
|
)
|
||||||
|
|
||||||
cmd := &cobra.Command{
|
cmd := &cobra.Command{
|
||||||
@ -35,22 +32,9 @@ func userList() *cobra.Command {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
out := ""
|
out, err := formatter.Format(cmd.Context(), res.Users)
|
||||||
switch outputFormat {
|
if err != nil {
|
||||||
case "table", "":
|
return err
|
||||||
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)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
_, err = fmt.Fprintln(cmd.OutOrStdout(), out)
|
_, 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"},
|
formatter.AttachFlags(cmd)
|
||||||
"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
|
return cmd
|
||||||
}
|
}
|
||||||
|
|
||||||
func userSingle() *cobra.Command {
|
func userSingle() *cobra.Command {
|
||||||
var outputFormat string
|
formatter := cliui.NewOutputFormatter(
|
||||||
|
&userShowFormat{},
|
||||||
|
cliui.JSONFormat(),
|
||||||
|
)
|
||||||
|
|
||||||
cmd := &cobra.Command{
|
cmd := &cobra.Command{
|
||||||
Use: "show <username|user_id|'me'>",
|
Use: "show <username|user_id|'me'>",
|
||||||
Short: "Show a single user. Use 'me' to indicate the currently authenticated user.",
|
Short: "Show a single user. Use 'me' to indicate the currently authenticated user.",
|
||||||
@ -86,19 +72,22 @@ func userSingle() *cobra.Command {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
out := ""
|
orgNames := make([]string, len(user.OrganizationIDs))
|
||||||
switch outputFormat {
|
for i, orgID := range user.OrganizationIDs {
|
||||||
case "table", "":
|
org, err := client.Organization(cmd.Context(), orgID)
|
||||||
out = displayUser(cmd.Context(), cmd.ErrOrStderr(), client, user)
|
|
||||||
case "json":
|
|
||||||
outBytes, err := json.Marshal(user)
|
|
||||||
if err != nil {
|
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)
|
orgNames[i] = org.Name
|
||||||
default:
|
}
|
||||||
return xerrors.Errorf(`unknown output format %q, only "table" and "json" are supported`, outputFormat)
|
|
||||||
|
out, err := formatter.Format(cmd.Context(), userWithOrgNames{
|
||||||
|
User: user,
|
||||||
|
OrganizationNames: orgNames,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
_, err = fmt.Fprintln(cmd.OutOrStdout(), out)
|
_, err = fmt.Fprintln(cmd.OutOrStdout(), out)
|
||||||
@ -106,11 +95,34 @@ func userSingle() *cobra.Command {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
cmd.Flags().StringVarP(&outputFormat, "output", "o", "table", "Output format. Available formats are: table, json.")
|
formatter.AttachFlags(cmd)
|
||||||
return 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"`
|
||||||
|
}
|
||||||
|
|
||||||
|
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()
|
tw := cliui.Table()
|
||||||
addRow := func(name string, value interface{}) {
|
addRow := func(name string, value interface{}) {
|
||||||
key := ""
|
key := ""
|
||||||
@ -150,25 +162,18 @@ func displayUser(ctx context.Context, stderr io.Writer, client *codersdk.Client,
|
|||||||
|
|
||||||
addRow("", "")
|
addRow("", "")
|
||||||
firstOrg := true
|
firstOrg := true
|
||||||
for _, orgID := range user.OrganizationIDs {
|
for _, orgName := range user.OrganizationNames {
|
||||||
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 := ""
|
key := ""
|
||||||
if firstOrg {
|
if firstOrg {
|
||||||
key = "Organizations"
|
key = "Organizations"
|
||||||
firstOrg = false
|
firstOrg = false
|
||||||
}
|
}
|
||||||
|
|
||||||
addRow(key, org.Name)
|
addRow(key, orgName)
|
||||||
}
|
}
|
||||||
if firstOrg {
|
if firstOrg {
|
||||||
addRow("Organizations", "(none)")
|
addRow("Organizations", "(none)")
|
||||||
}
|
}
|
||||||
|
|
||||||
return tw.Render()
|
return tw.Render(), nil
|
||||||
}
|
}
|
||||||
|
@ -58,7 +58,9 @@ func createUserStatusCommand(sdkStatus codersdk.UserStatus) *cobra.Command {
|
|||||||
return xerrors.Errorf("fetch user: %w", err)
|
return xerrors.Errorf("fetch user: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Display the user
|
// Display the user. This uses cliui.DisplayTable directly instead
|
||||||
|
// of cliui.NewOutputFormatter because we prompt immediately
|
||||||
|
// afterwards.
|
||||||
table, err := cliui.DisplayTable([]codersdk.User{user}, "", columns)
|
table, err := cliui.DisplayTable([]codersdk.User{user}, "", columns)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return xerrors.Errorf("render user table: %w", err)
|
return xerrors.Errorf("render user table: %w", err)
|
||||||
|
@ -12,11 +12,11 @@ import (
|
|||||||
|
|
||||||
// APIKey: do not ever return the HashedSecret
|
// APIKey: do not ever return the HashedSecret
|
||||||
type APIKey struct {
|
type APIKey struct {
|
||||||
ID string `json:"id" validate:"required"`
|
ID string `json:"id" table:"id,default_sort" validate:"required"`
|
||||||
UserID uuid.UUID `json:"user_id" validate:"required" format:"uuid"`
|
UserID uuid.UUID `json:"user_id" validate:"required" format:"uuid"`
|
||||||
LastUsed time.Time `json:"last_used" validate:"required" format:"date-time"`
|
LastUsed time.Time `json:"last_used" table:"last used" validate:"required" format:"date-time"`
|
||||||
ExpiresAt time.Time `json:"expires_at" validate:"required" format:"date-time"`
|
ExpiresAt time.Time `json:"expires_at" table:"expires at" validate:"required" format:"date-time"`
|
||||||
CreatedAt time.Time `json:"created_at" validate:"required" format:"date-time"`
|
CreatedAt time.Time `json:"created_at" table:"created at" validate:"required" format:"date-time"`
|
||||||
UpdatedAt time.Time `json:"updated_at" validate:"required" format:"date-time"`
|
UpdatedAt time.Time `json:"updated_at" validate:"required" format:"date-time"`
|
||||||
LoginType LoginType `json:"login_type" validate:"required" enums:"password,github,oidc,token"`
|
LoginType LoginType `json:"login_type" validate:"required" enums:"password,github,oidc,token"`
|
||||||
Scope APIKeyScope `json:"scope" validate:"required" enums:"all,application_connect"`
|
Scope APIKeyScope `json:"scope" validate:"required" enums:"all,application_connect"`
|
||||||
|
@ -55,7 +55,7 @@ type Parameter struct {
|
|||||||
ID uuid.UUID `json:"id" table:"id" format:"uuid"`
|
ID uuid.UUID `json:"id" table:"id" format:"uuid"`
|
||||||
Scope ParameterScope `json:"scope" table:"scope" enums:"template,workspace,import_job"`
|
Scope ParameterScope `json:"scope" table:"scope" enums:"template,workspace,import_job"`
|
||||||
ScopeID uuid.UUID `json:"scope_id" table:"scope id" format:"uuid"`
|
ScopeID uuid.UUID `json:"scope_id" table:"scope id" format:"uuid"`
|
||||||
Name string `json:"name" table:"name"`
|
Name string `json:"name" table:"name,default_sort"`
|
||||||
SourceScheme ParameterSourceScheme `json:"source_scheme" table:"source scheme" validate:"ne=none" enums:"none,data"`
|
SourceScheme ParameterSourceScheme `json:"source_scheme" table:"source scheme" validate:"ne=none" enums:"none,data"`
|
||||||
DestinationScheme ParameterDestinationScheme `json:"destination_scheme" table:"destination scheme" validate:"ne=none" enums:"none,environment_variable,provisioner_variable"`
|
DestinationScheme ParameterDestinationScheme `json:"destination_scheme" table:"destination scheme" validate:"ne=none" enums:"none,environment_variable,provisioner_variable"`
|
||||||
CreatedAt time.Time `json:"created_at" table:"created at" format:"date-time"`
|
CreatedAt time.Time `json:"created_at" table:"created at" format:"date-time"`
|
||||||
|
@ -36,7 +36,7 @@ type UsersRequest struct {
|
|||||||
// User represents a user in Coder.
|
// User represents a user in Coder.
|
||||||
type User struct {
|
type User struct {
|
||||||
ID uuid.UUID `json:"id" validate:"required" table:"id" format:"uuid"`
|
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"`
|
Email string `json:"email" validate:"required" table:"email" format:"email"`
|
||||||
CreatedAt time.Time `json:"created_at" validate:"required" table:"created at" format:"date-time"`
|
CreatedAt time.Time `json:"created_at" validate:"required" table:"created at" format:"date-time"`
|
||||||
LastSeenAt time.Time `json:"last_seen_at" format:"date-time"`
|
LastSeenAt time.Time `json:"last_seen_at" format:"date-time"`
|
||||||
|
@ -9,10 +9,11 @@ coder list [flags]
|
|||||||
### Options
|
### Options
|
||||||
|
|
||||||
```
|
```
|
||||||
-a, --all Specifies whether all workspaces will be listed or not.
|
-a, --all Specifies whether all workspaces will be listed or not.
|
||||||
-c, --column stringArray Specify a column to filter in the table. Available columns are: workspace, template, status, last_built, outdated, starts_at, stops_after
|
-c, --column strings Columns to display in table output. Available columns: workspace, template, status, last built, outdated, starts at, stops after (default [workspace,template,status,last built,outdated,starts at,stops after])
|
||||||
-h, --help help for list
|
-h, --help help for list
|
||||||
--search string Search for a workspace with a query. (default "owner:me")
|
-o, --output string Output format. Available formats: table, json (default "table")
|
||||||
|
--search string Search for a workspace with a query. (default "owner:me")
|
||||||
```
|
```
|
||||||
|
|
||||||
### Options inherited from parent commands
|
### Options inherited from parent commands
|
||||||
|
@ -9,8 +9,9 @@ coder templates list [flags]
|
|||||||
### Options
|
### Options
|
||||||
|
|
||||||
```
|
```
|
||||||
-c, --column stringArray Specify a column to filter in the table. (default [name,last_updated,used_by])
|
-c, --column strings Columns to display in table output. Available columns: name, created at, last updated, organization id, provisioner, active version id, used by, default ttl (default [name,last updated,used by])
|
||||||
-h, --help help for list
|
-h, --help help for list
|
||||||
|
-o, --output string Output format. Available formats: table, json (default "table")
|
||||||
```
|
```
|
||||||
|
|
||||||
### Options inherited from parent commands
|
### Options inherited from parent commands
|
||||||
|
@ -9,7 +9,9 @@ coder templates versions list <template> [flags]
|
|||||||
### Options
|
### Options
|
||||||
|
|
||||||
```
|
```
|
||||||
-h, --help help for list
|
-c, --column strings Columns to display in table output. Available columns: name, created at, created by, status, active (default [name,created at,created by,status,active])
|
||||||
|
-h, --help help for list
|
||||||
|
-o, --output string Output format. Available formats: table, json (default "table")
|
||||||
```
|
```
|
||||||
|
|
||||||
### Options inherited from parent commands
|
### Options inherited from parent commands
|
||||||
|
@ -9,7 +9,9 @@ coder tokens list [flags]
|
|||||||
### Options
|
### Options
|
||||||
|
|
||||||
```
|
```
|
||||||
-h, --help help for list
|
-c, --column strings Columns to display in table output. Available columns: id, last used, expires at, created at (default [id,last used,expires at,created at])
|
||||||
|
-h, --help help for list
|
||||||
|
-o, --output string Output format. Available formats: table, json (default "table")
|
||||||
```
|
```
|
||||||
|
|
||||||
### Options inherited from parent commands
|
### Options inherited from parent commands
|
||||||
|
@ -7,9 +7,9 @@ coder users list [flags]
|
|||||||
### Options
|
### Options
|
||||||
|
|
||||||
```
|
```
|
||||||
-c, --column stringArray Specify a column to filter in the table. Available columns are: id, username, email, created_at, status. (default [username,email,created_at,status])
|
-c, --column strings Columns to display in table output. Available columns: id, username, email, created at, status (default [username,email,created_at,status])
|
||||||
-h, --help help for list
|
-h, --help help for list
|
||||||
-o, --output string Output format. Available formats are: table, json. (default "table")
|
-o, --output string Output format. Available formats: table, json (default "table")
|
||||||
```
|
```
|
||||||
|
|
||||||
### Options inherited from parent commands
|
### Options inherited from parent commands
|
||||||
|
@ -16,7 +16,7 @@ coder users show <username|user_id|'me'> [flags]
|
|||||||
|
|
||||||
```
|
```
|
||||||
-h, --help help for show
|
-h, --help help for show
|
||||||
-o, --output string Output format. Available formats are: table, json. (default "table")
|
-o, --output string Output format. Available formats: table, json (default "table")
|
||||||
```
|
```
|
||||||
|
|
||||||
### Options inherited from parent commands
|
### Options inherited from parent commands
|
||||||
|
@ -55,6 +55,8 @@ func featuresList() *cobra.Command {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// This uses custom formatting as the JSON output outputs an object
|
||||||
|
// as opposed to a list from the table output.
|
||||||
out := ""
|
out := ""
|
||||||
switch outputFormat {
|
switch outputFormat {
|
||||||
case "table", "":
|
case "table", "":
|
||||||
@ -88,7 +90,7 @@ func featuresList() *cobra.Command {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type featureRow struct {
|
type featureRow struct {
|
||||||
Name codersdk.FeatureName `table:"name"`
|
Name codersdk.FeatureName `table:"name,default_sort"`
|
||||||
Entitlement string `table:"entitlement"`
|
Entitlement string `table:"entitlement"`
|
||||||
Enabled bool `table:"enabled"`
|
Enabled bool `table:"enabled"`
|
||||||
Limit *int64 `table:"limit"`
|
Limit *int64 `table:"limit"`
|
||||||
|
@ -14,6 +14,11 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
func groupList() *cobra.Command {
|
func groupList() *cobra.Command {
|
||||||
|
formatter := cliui.NewOutputFormatter(
|
||||||
|
cliui.TableFormat([]groupTableRow{}, nil),
|
||||||
|
cliui.JSONFormat(),
|
||||||
|
)
|
||||||
|
|
||||||
cmd := &cobra.Command{
|
cmd := &cobra.Command{
|
||||||
Use: "list",
|
Use: "list",
|
||||||
Short: "List user groups",
|
Short: "List user groups",
|
||||||
@ -44,7 +49,8 @@ func groupList() *cobra.Command {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
out, err := displayGroups(groups...)
|
rows := groupsToRows(groups...)
|
||||||
|
out, err := formatter.Format(cmd.Context(), rows)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return xerrors.Errorf("display groups: %w", err)
|
return xerrors.Errorf("display groups: %w", err)
|
||||||
}
|
}
|
||||||
@ -53,17 +59,23 @@ func groupList() *cobra.Command {
|
|||||||
return nil
|
return nil
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
formatter.AttachFlags(cmd)
|
||||||
return cmd
|
return cmd
|
||||||
}
|
}
|
||||||
|
|
||||||
type groupTableRow struct {
|
type groupTableRow struct {
|
||||||
Name string `table:"name"`
|
// For json output:
|
||||||
OrganizationID uuid.UUID `table:"organization_id"`
|
Group codersdk.Group `table:"-"`
|
||||||
Members []string `table:"members"`
|
|
||||||
AvatarURL string `table:"avatar_url"`
|
// For table output:
|
||||||
|
Name string `json:"-" table:"name,default_sort"`
|
||||||
|
OrganizationID uuid.UUID `json:"-" table:"organization_id"`
|
||||||
|
Members []string `json:"-" table:"members"`
|
||||||
|
AvatarURL string `json:"-" table:"avatar_url"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func displayGroups(groups ...codersdk.Group) (string, error) {
|
func groupsToRows(groups ...codersdk.Group) []groupTableRow {
|
||||||
rows := make([]groupTableRow, 0, len(groups))
|
rows := make([]groupTableRow, 0, len(groups))
|
||||||
for _, group := range groups {
|
for _, group := range groups {
|
||||||
members := make([]string, 0, len(group.Members))
|
members := make([]string, 0, len(group.Members))
|
||||||
@ -78,5 +90,5 @@ func displayGroups(groups ...codersdk.Group) (string, error) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
return cliui.DisplayTable(rows, "name", nil)
|
return rows
|
||||||
}
|
}
|
||||||
|
Reference in New Issue
Block a user