diff --git a/cli/cliui/output.go b/cli/cliui/output.go index cf3a981fd5a86..c9ed34677971c 100644 --- a/cli/cliui/output.go +++ b/cli/cliui/output.go @@ -3,6 +3,7 @@ package cliui import ( "context" "encoding/json" + "fmt" "reflect" "strings" @@ -171,3 +172,23 @@ func (jsonFormat) Format(_ context.Context, data any) (string, error) { return string(outBytes), nil } + +type textFormat struct{} + +var _ OutputFormat = textFormat{} + +// TextFormat is a formatter that just outputs unstructured text. +// It uses fmt.Sprintf under the hood. +func TextFormat() OutputFormat { + return textFormat{} +} + +func (textFormat) ID() string { + return "text" +} + +func (textFormat) AttachOptions(_ *clibase.OptionSet) {} + +func (textFormat) Format(_ context.Context, data any) (string, error) { + return fmt.Sprintf("%s", data), nil +} diff --git a/cli/cliui/output_test.go b/cli/cliui/output_test.go index 6dbe2fa144b62..22ef241fba7ea 100644 --- a/cli/cliui/output_test.go +++ b/cli/cliui/output_test.go @@ -50,6 +50,9 @@ func Test_OutputFormatter(t *testing.T) { require.Panics(t, func() { cliui.NewOutputFormatter(cliui.JSONFormat()) }) + require.NotPanics(t, func() { + cliui.NewOutputFormatter(cliui.JSONFormat(), cliui.TextFormat()) + }) }) t.Run("NoMissingFormatID", func(t *testing.T) { diff --git a/cli/root.go b/cli/root.go index 59096f4900bc7..070b8fa0db441 100644 --- a/cli/root.go +++ b/cli/root.go @@ -82,7 +82,7 @@ func (r *RootCmd) Core() []*clibase.Cmd { r.templates(), r.users(), r.tokens(), - r.version(), + r.version(defaultVersionInfo), // Workspace Commands r.configSSH(), @@ -370,36 +370,6 @@ func LoggerFromContext(ctx context.Context) (slog.Logger, bool) { return l, ok } -// version prints the coder version -func (*RootCmd) version() *clibase.Cmd { - return &clibase.Cmd{ - Use: "version", - Short: "Show coder version", - Handler: func(inv *clibase.Invocation) error { - var str strings.Builder - _, _ = str.WriteString("Coder ") - if buildinfo.IsAGPL() { - _, _ = str.WriteString("(AGPL) ") - } - _, _ = str.WriteString(buildinfo.Version()) - buildTime, valid := buildinfo.Time() - if valid { - _, _ = str.WriteString(" " + buildTime.Format(time.UnixDate)) - } - _, _ = str.WriteString("\r\n" + buildinfo.ExternalURL() + "\r\n\r\n") - - if buildinfo.IsSlim() { - _, _ = str.WriteString(fmt.Sprintf("Slim build of Coder, does not support the %s subcommand.\n", cliui.Styles.Code.Render("server"))) - } else { - _, _ = str.WriteString(fmt.Sprintf("Full build of Coder, supports the %s subcommand.\n", cliui.Styles.Code.Render("server"))) - } - - _, _ = fmt.Fprint(inv.Stdout, str.String()) - return nil - }, - } -} - func isTest() bool { return flag.Lookup("test.v") != nil } diff --git a/cli/testdata/coder_version_--help.golden b/cli/testdata/coder_version_--help.golden index 7e7d26274f539..4fa4b6b2c5561 100644 --- a/cli/testdata/coder_version_--help.golden +++ b/cli/testdata/coder_version_--help.golden @@ -1,6 +1,10 @@ -Usage: coder version +Usage: coder version [flags] Show coder version +Options + -o, --output string (default: text) + Output format. Available formats: text, json. + --- Run `coder --help` for a list of global options. diff --git a/cli/version.go b/cli/version.go new file mode 100644 index 0000000000000..9be348257d861 --- /dev/null +++ b/cli/version.go @@ -0,0 +1,84 @@ +package cli + +import ( + "fmt" + "strings" + "time" + + "github.com/coder/coder/buildinfo" + "github.com/coder/coder/cli/clibase" + "github.com/coder/coder/cli/cliui" +) + +// versionInfo wraps the stuff we get from buildinfo so that it's +// easier to emit in different formats. +type versionInfo struct { + Version string `json:"version"` + BuildTime time.Time `json:"build_time"` + ExternalURL string `json:"external_url"` + Slim bool `json:"slim"` + AGPL bool `json:"agpl"` +} + +// String() implements Stringer +func (vi versionInfo) String() string { + var str strings.Builder + _, _ = str.WriteString("Coder ") + if vi.AGPL { + _, _ = str.WriteString("(AGPL) ") + } + _, _ = str.WriteString(vi.Version) + + if !vi.BuildTime.IsZero() { + _, _ = str.WriteString(" " + vi.BuildTime.Format(time.UnixDate)) + } + _, _ = str.WriteString("\r\n" + vi.ExternalURL + "\r\n\r\n") + + if vi.Slim { + _, _ = str.WriteString(fmt.Sprintf("Slim build of Coder, does not support the %s subcommand.", cliui.Styles.Code.Render("server"))) + } else { + _, _ = str.WriteString(fmt.Sprintf("Full build of Coder, supports the %s subcommand.", cliui.Styles.Code.Render("server"))) + } + return str.String() +} + +func defaultVersionInfo() *versionInfo { + buildTime, _ := buildinfo.Time() + return &versionInfo{ + Version: buildinfo.Version(), + BuildTime: buildTime, + ExternalURL: buildinfo.ExternalURL(), + Slim: buildinfo.IsSlim(), + AGPL: buildinfo.IsAGPL(), + } +} + +// version prints the coder version +func (*RootCmd) version(versionInfo func() *versionInfo) *clibase.Cmd { + var ( + formatter = cliui.NewOutputFormatter( + cliui.TextFormat(), + cliui.JSONFormat(), + ) + vi = versionInfo() + ) + + cmd := &clibase.Cmd{ + Use: "version", + Short: "Show coder version", + Options: clibase.OptionSet{}, + Handler: func(inv *clibase.Invocation) error { + out, err := formatter.Format(inv.Context(), vi) + if err != nil { + return err + } + + _, err = fmt.Fprintln(inv.Stdout, out) + return err + }, + } + + formatter.AttachOptions(&cmd.Options) + + return cmd +} diff --git a/cli/version_test.go b/cli/version_test.go new file mode 100644 index 0000000000000..6c2067886219f --- /dev/null +++ b/cli/version_test.go @@ -0,0 +1,66 @@ +package cli_test + +import ( + "bytes" + "context" + "strings" + "testing" + + "github.com/stretchr/testify/require" + + "github.com/coder/coder/cli/clitest" + "github.com/coder/coder/testutil" +) + +func TestVersion(t *testing.T) { + t.Parallel() + expectedText := `Coder v0.0.0-devel +https://github.com/coder/coder + +Full build of Coder, supports the server subcommand. +` + expectedJSON := `{ + "version": "v0.0.0-devel", + "build_time": "0001-01-01T00:00:00Z", + "external_url": "https://github.com/coder/coder", + "slim": false, + "agpl": false +} +` + for _, tt := range []struct { + Name string + Args []string + Expected string + }{ + { + Name: "Defaults to human-readable output", + Args: []string{"version"}, + Expected: expectedText, + }, + { + Name: "JSON output", + Args: []string{"version", "--output=json"}, + Expected: expectedJSON, + }, + { + Name: "Text output", + Args: []string{"version", "--output=text"}, + Expected: expectedText, + }, + } { + tt := tt + t.Run(tt.Name, func(t *testing.T) { + t.Parallel() + ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitShort) + t.Cleanup(cancel) + inv, _ := clitest.New(t, tt.Args...) + buf := new(bytes.Buffer) + inv.Stdout = buf + err := inv.WithContext(ctx).Run() + require.NoError(t, err) + actual := buf.String() + actual = strings.ReplaceAll(actual, "\r\n", "\n") + require.Equal(t, tt.Expected, actual) + }) + } +} diff --git a/docs/cli/version.md b/docs/cli/version.md index b0b2754948c92..365b5ac1d47cd 100644 --- a/docs/cli/version.md +++ b/docs/cli/version.md @@ -7,5 +7,16 @@ Show coder version ## Usage ```console -coder version +coder version [flags] ``` + +## Options + +### -o, --output + +| | | +| ------- | ------------------- | +| Type | string | +| Default | text | + +Output format. Available formats: text, json.