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
+[1mOptions[0m
+ -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 [;mserver[0m 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.