From 2c2f3c1c6d6ea65bfe14bea0701effc7c6fe7ace Mon Sep 17 00:00:00 2001 From: Rowan Smith Date: Thu, 24 Jul 2025 15:05:40 +1000 Subject: [PATCH] feat(cli): add 'read' command for authenticated API endpoint reads with pretty JSON output --- cli/read.go | 79 ++++++++++++++++++++++++++++++++++++++++++++++++ cli/read_test.go | 50 ++++++++++++++++++++++++++++++ cli/root.go | 1 + 3 files changed, 130 insertions(+) create mode 100644 cli/read.go create mode 100644 cli/read_test.go diff --git a/cli/read.go b/cli/read.go new file mode 100644 index 0000000000000..11d286ffe9f58 --- /dev/null +++ b/cli/read.go @@ -0,0 +1,79 @@ +package cli + +import ( + "encoding/json" + "io" + "net/http" + "strings" + + "golang.org/x/xerrors" + + "github.com/coder/coder/v2/codersdk" + "github.com/coder/serpent" +) + +// read returns a CLI command that performs an authenticated GET request to the given API path. +func (r *RootCmd) read() *serpent.Command { + client := new(codersdk.Client) + return &serpent.Command{ + Use: "read ", + Short: "Read an authenticated API endpoint using your current Coder CLI token", + Long: `Read an authenticated API endpoint using your current Coder CLI token. + +Example: + coder read workspacebuilds/my-build/logs +This will perform a GET request to /api/v2/workspacebuilds/my-build/logs on the connected Coder server. +`, + Middleware: serpent.Chain( + serpent.RequireNArgs(1), + r.InitClient(client), + ), + Handler: func(inv *serpent.Invocation) error { + apiPath := inv.Args[0] + if !strings.HasPrefix(apiPath, "/") { + apiPath = "/api/v2/" + apiPath + } + resp, err := client.Request(inv.Context(), http.MethodGet, apiPath, nil) + if err != nil { + return xerrors.Errorf("request failed: %w", err) + } + defer resp.Body.Close() + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + body, _ := io.ReadAll(resp.Body) + return xerrors.Errorf("API error: %s\n%s", resp.Status, string(body)) + } + + contentType := resp.Header.Get("Content-Type") + if strings.HasPrefix(contentType, "application/json") { + // Pretty-print JSON + var raw interface{} + data, err := io.ReadAll(resp.Body) + if err != nil { + return xerrors.Errorf("failed to read response: %w", err) + } + err = json.Unmarshal(data, &raw) + if err == nil { + pretty, err := json.MarshalIndent(raw, "", " ") + if err == nil { + _, err = inv.Stdout.Write(pretty) + if err != nil { + return xerrors.Errorf("failed to write output: %w", err) + } + _, _ = inv.Stdout.Write([]byte("\n")) + return nil + } + } + // If JSON formatting fails, fall back to raw output + _, _ = inv.Stdout.Write(data) + _, _ = inv.Stdout.Write([]byte("\n")) + return nil + } + // Non-JSON: stream as before + _, err = io.Copy(inv.Stdout, resp.Body) + if err != nil { + return xerrors.Errorf("failed to read response: %w", err) + } + return nil + }, + } +} diff --git a/cli/read_test.go b/cli/read_test.go new file mode 100644 index 0000000000000..3aa6b59e5d61f --- /dev/null +++ b/cli/read_test.go @@ -0,0 +1,50 @@ +package cli_test + +import ( + "strings" + "testing" + + "github.com/stretchr/testify/require" + + "github.com/coder/coder/v2/cli/clitest" + "github.com/coder/coder/v2/coderd/coderdtest" +) + +func TestReadCommand(t *testing.T) { + t.Parallel() + client := coderdtest.New(t, nil) + user := coderdtest.CreateFirstUser(t, client) + + inv, root := clitest.New(t, "read", "users/me") + clitest.SetupConfig(t, client, root) + + var sb strings.Builder + inv.Stdout = &sb + + err := inv.Run() + require.NoError(t, err) + output := sb.String() + require.Contains(t, output, user.UserID.String()) + // Check for pretty-printed JSON (indented) + require.Contains(t, output, " \"") // at least one indented JSON key +} + +func TestReadCommand_NonJSON(t *testing.T) { + t.Parallel() + client := coderdtest.New(t, nil) + _ = coderdtest.CreateFirstUser(t, client) + + inv, root := clitest.New(t, "read", "/healthz") + clitest.SetupConfig(t, client, root) + + var sb strings.Builder + inv.Stdout = &sb + + err := inv.Run() + require.NoError(t, err) + output := sb.String() + // Should not be pretty-printed JSON (no two-space indent at start) + require.NotContains(t, output, " \"") + // Should contain the plain text OK + require.Contains(t, output, "OK") +} diff --git a/cli/root.go b/cli/root.go index 54215a67401dd..58d903993f48d 100644 --- a/cli/root.go +++ b/cli/root.go @@ -98,6 +98,7 @@ func (r *RootCmd) CoreSubcommands() []*serpent.Command { r.organizations(), r.portForward(), r.publickey(), + r.read(), r.resetPassword(), r.state(), r.templates(),