diff --git a/.gitignore b/.gitignore index 35cd2c2..50b815b 100644 --- a/.gitignore +++ b/.gitignore @@ -19,3 +19,4 @@ coverage.out # Codex .codex +.agents diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..33053aa --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,7 @@ +# Repository Instructions + +## CLI output + +- Keep `stdout` clean for command results and machine-readable output. +- Send interactive prompts, progress messages, and other human-oriented terminal UX to `stderr`. +- Preserve this split for Bubble Tea or other TUI flows so commands remain shell-friendly when `stdout` is redirected. diff --git a/Makefile b/Makefile index dac71d7..86a8ccf 100644 --- a/Makefile +++ b/Makefile @@ -1,6 +1,8 @@ BIN ?= bin/kedify +GOLANGCI_LINT_VERSION ?= v1.64.8 +GOLANGCI_LINT := $(CURDIR)/bin/golangci-lint -.PHONY: build fmt vet test +.PHONY: build fmt vet test golangci-lint golangci-lint-bin build: mkdir -p $(dir $(BIN)) @@ -14,3 +16,13 @@ vet: test: GOCACHE=/tmp/go-build go test ./... + +golangci-lint-bin: + @mkdir -p $(dir $(GOLANGCI_LINT)) + @if [ ! -x "$(GOLANGCI_LINT)" ] || ! "$(GOLANGCI_LINT)" version | grep -q "$(GOLANGCI_LINT_VERSION)"; then \ + echo "Installing golangci-lint $(GOLANGCI_LINT_VERSION)"; \ + GOBIN="$(CURDIR)/bin" go install github.com/golangci/golangci-lint/cmd/golangci-lint@$(GOLANGCI_LINT_VERSION); \ + fi + +golangci-lint: golangci-lint-bin + GOCACHE=/tmp/go-build GOLANGCI_LINT_CACHE=/tmp/golangci-lint "$(GOLANGCI_LINT)" run diff --git a/README.md b/README.md index e69de29..2e1ef8c 100644 --- a/README.md +++ b/README.md @@ -0,0 +1,129 @@ +# Kedify CLI + +This repository contains an experimental `kedify` CLI built with `kong` for command parsing and `bubbletea` plus `lipgloss` for interactive terminal flows. + +## Current Features + +- `kedify login` + Reads a Kedify API token and stores it in the OS credential store when available, with a file fallback. +- Interactive hidden token entry + When run in a terminal, `login` uses a Bubble Tea prompt and keeps the token hidden. +- Piped token input + You can also provide a token via `stdin`. +- CI-friendly token injection + Commands can also use `--token` or `KEDIFY_TOKEN` instead of the stored credentials file. +- `kedify list clusters` + Calls the Kedify API and transparently reads all pages before printing the final cluster list. +- `kedify get cluster [name-or-id]` + Prints one cluster by name or id, and shows an interactive picker when no name is provided. +- Output formatting + `kedify list clusters` and `kedify get cluster` support `-o` and `--output` with `text`, `json`, or `yaml`. `text` is the default. + +## Build + +```bash +make build +``` + +The binary will be available at `./bin/kedify`. + +## Authentication + +Generate a Kedify API token at: + +```text +https://dashboard.dev.kedify.io/api-keys +``` + +Interactive login: + +```bash +./bin/kedify login +``` + +Login with a global token flag: + +```bash +./bin/kedify --token "$KEDIFY_TOKEN" login +``` + +Login with a positional token argument: + +```bash +./bin/kedify login "$KEDIFY_TOKEN" +``` + +Piped login: + +```bash +printf '%s\n' "$KEDIFY_TOKEN" | ./bin/kedify login +``` + +Credentials are stored in: + +```text +OS credential store when available +~/.config/kedify/credentials.json as fallback +``` + +## Usage + +Show help: + +```bash +./bin/kedify --help +``` + +List clusters in the default human-readable text format: + +```bash +./bin/kedify list clusters +``` + +List clusters as YAML: + +```bash +./bin/kedify list clusters -o yaml +``` + +Get a cluster by name: + +```bash +./bin/kedify get cluster my-cluster +``` + +Get a cluster as JSON: + +```bash +./bin/kedify get cluster my-cluster -o json +``` + +Pick a cluster interactively: + +```bash +./bin/kedify get cluster +``` + +Override the API URL: + +```bash +./bin/kedify --apiurl https://api.dev.kedify.io/v1 list clusters +``` + +Or with an environment variable: + +```bash +KEDIFY_API_URL=https://api.dev.kedify.io/v1 ./bin/kedify list clusters +``` + +Pass the auth token explicitly in CI: + +```bash +./bin/kedify --token "$KEDIFY_TOKEN" list clusters +``` + +Or via environment variable: + +```bash +KEDIFY_TOKEN="$KEDIFY_TOKEN" ./bin/kedify get cluster my-cluster +``` diff --git a/cmd/kedify/main.go b/cmd/kedify/main.go new file mode 100644 index 0000000..17236b0 --- /dev/null +++ b/cmd/kedify/main.go @@ -0,0 +1,11 @@ +package main + +import ( + "os" + + "github.com/kedify/cli/internal/cli" +) + +func main() { + os.Exit(cli.Run(os.Args[1:], os.Stdin, os.Stdout, os.Stderr)) +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..a1964bf --- /dev/null +++ b/go.mod @@ -0,0 +1,36 @@ +module github.com/kedify/cli + +go 1.26 + +require ( + github.com/alecthomas/kong v1.12.1 + github.com/charmbracelet/bubbletea v1.3.10 + github.com/charmbracelet/lipgloss v1.1.0 + github.com/google/uuid v1.6.0 + github.com/zalando/go-keyring v0.2.8 + gopkg.in/yaml.v3 v3.0.1 +) + +require ( + github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect + github.com/charmbracelet/colorprofile v0.4.3 // indirect + github.com/charmbracelet/x/ansi v0.11.7 // indirect + github.com/charmbracelet/x/cellbuf v0.0.15 // indirect + github.com/charmbracelet/x/term v0.2.2 // indirect + github.com/clipperhouse/displaywidth v0.11.0 // indirect + github.com/clipperhouse/uax29/v2 v2.7.0 // indirect + github.com/danieljoos/wincred v1.2.3 // indirect + github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect + github.com/godbus/dbus/v5 v5.2.2 // indirect + github.com/lucasb-eyer/go-colorful v1.4.0 // indirect + github.com/mattn/go-isatty v0.0.22 // indirect + github.com/mattn/go-localereader v0.0.1 // indirect + github.com/mattn/go-runewidth v0.0.24 // indirect + github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect + github.com/muesli/cancelreader v0.2.2 // indirect + github.com/muesli/termenv v0.16.0 // indirect + github.com/rivo/uniseg v0.4.7 // indirect + github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect + golang.org/x/sys v0.46.0 // indirect + golang.org/x/text v0.38.0 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..a99c701 --- /dev/null +++ b/go.sum @@ -0,0 +1,73 @@ +github.com/alecthomas/assert/v2 v2.11.0 h1:2Q9r3ki8+JYXvGsDyBXwH3LcJ+WK5D0gc5E8vS6K3D0= +github.com/alecthomas/assert/v2 v2.11.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k= +github.com/alecthomas/kong v1.12.1 h1:iq6aMJDcFYP9uFrLdsiZQ2ZMmcshduyGv4Pek0MQPW0= +github.com/alecthomas/kong v1.12.1/go.mod h1:p2vqieVMeTAnaC83txKtXe8FLke2X07aruPWXyMPQrU= +github.com/alecthomas/repr v0.4.0 h1:GhI2A8MACjfegCPVq9f1FLvIBS+DrQ2KQBFZP1iFzXc= +github.com/alecthomas/repr v0.4.0/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4= +github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= +github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= +github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw= +github.com/charmbracelet/bubbletea v1.3.10/go.mod h1:ORQfo0fk8U+po9VaNvnV95UPWA1BitP1E0N6xJPlHr4= +github.com/charmbracelet/colorprofile v0.4.3 h1:QPa1IWkYI+AOB+fE+mg/5/4HRMZcaXex9t5KX76i20Q= +github.com/charmbracelet/colorprofile v0.4.3/go.mod h1:/zT4BhpD5aGFpqQQqw7a+VtHCzu+zrQtt1zhMt9mR4Q= +github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY= +github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30= +github.com/charmbracelet/x/ansi v0.11.7 h1:kzv1kJvjg2S3r9KHo8hDdHFQLEqn4RBCb39dAYC84jI= +github.com/charmbracelet/x/ansi v0.11.7/go.mod h1:9qGpnAVYz+8ACONkZBUWPtL7lulP9No6p1epAihUZwQ= +github.com/charmbracelet/x/cellbuf v0.0.15 h1:ur3pZy0o6z/R7EylET877CBxaiE1Sp1GMxoFPAIztPI= +github.com/charmbracelet/x/cellbuf v0.0.15/go.mod h1:J1YVbR7MUuEGIFPCaaZ96KDl5NoS0DAWkskup+mOY+Q= +github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk= +github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI= +github.com/clipperhouse/displaywidth v0.11.0 h1:lBc6kY44VFw+TDx4I8opi/EtL9m20WSEFgwIwO+UVM8= +github.com/clipperhouse/displaywidth v0.11.0/go.mod h1:bkrFNkf81G8HyVqmKGxsPufD3JhNl3dSqnGhOoSD/o0= +github.com/clipperhouse/uax29/v2 v2.7.0 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJv2v7Vk= +github.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM= +github.com/danieljoos/wincred v1.2.3 h1:v7dZC2x32Ut3nEfRH+vhoZGvN72+dQ/snVXo/vMFLdQ= +github.com/danieljoos/wincred v1.2.3/go.mod h1:6qqX0WNrS4RzPZ1tnroDzq9kY3fu1KwE7MRLQK4X0bs= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4= +github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM= +github.com/godbus/dbus/v5 v5.2.2 h1:TUR3TgtSVDmjiXOgAAyaZbYmIeP3DPkld3jgKGV8mXQ= +github.com/godbus/dbus/v5 v5.2.2/go.mod h1:3AAv2+hPq5rdnr5txxxRwiGjPXamgoIHgz9FPBfOp3c= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM= +github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg= +github.com/lucasb-eyer/go-colorful v1.4.0 h1:UtrWVfLdarDgc44HcS7pYloGHJUjHV/4FwW4TvVgFr4= +github.com/lucasb-eyer/go-colorful v1.4.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= +github.com/mattn/go-isatty v0.0.22 h1:j8l17JJ9i6VGPUFUYoTUKPSgKe/83EYU2zBC7YNKMw4= +github.com/mattn/go-isatty v0.0.22/go.mod h1:ZXfXG4SQHsB/w3ZeOYbR0PrPwLy+n6xiMrJlRFqopa4= +github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4= +github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88= +github.com/mattn/go-runewidth v0.0.24 h1:cpokDiIn0MGnhdHwuWnJBITySJ20QyNGnY2kR/ay2DU= +github.com/mattn/go-runewidth v0.0.24/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs= +github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI= +github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo= +github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= +github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= +github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc= +github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= +github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= +github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= +github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= +github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= +github.com/zalando/go-keyring v0.2.8 h1:6sD/Ucpl7jNq10rM2pgqTs0sZ9V3qMrqfIIy5YPccHs= +github.com/zalando/go-keyring v0.2.8/go.mod h1:tsMo+VpRq5NGyKfxoBVjCuMrG47yj8cmakZDO5QGii0= +golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI= +golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo= +golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw= +golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/text v0.38.0 h1:sXmwo9DwP3OK9EZ7PqAdaooSGozfl/3a6/xJcbzPRhE= +golang.org/x/text v0.38.0/go.mod h1:YXZt3QhHUKYT53r2lLKFIVi6Ao1jdzrTR/KQ09qyxF4= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/internal/api/client.go b/internal/api/client.go new file mode 100644 index 0000000..9b97bce --- /dev/null +++ b/internal/api/client.go @@ -0,0 +1,147 @@ +package api + +import ( + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "strings" + "time" +) + +const requestTimeout = 30 * time.Second + +type Client struct { + httpClient *http.Client +} + +type clustersResponse struct { + Items []map[string]any `json:"items"` + PageInfo pageInfo `json:"pageInfo"` +} + +type pageInfo struct { + HasNext bool `json:"hasNext"` + Page int `json:"page"` +} + +func NewClient() *Client { + return &Client{ + httpClient: &http.Client{Timeout: requestTimeout}, + } +} + +func (c *Client) ListClusters(apiURL, token string) ([]map[string]any, error) { + var allItems []map[string]any + page := 1 + + for { + response, err := c.listClustersPage(apiURL, token, page) + if err != nil { + return nil, err + } + + allItems = append(allItems, response.Items...) + if !response.PageInfo.HasNext { + break + } + + nextPage := response.PageInfo.Page + 1 + if nextPage <= page { + nextPage = page + 1 + } + page = nextPage + } + + return allItems, nil +} + +func (c *Client) GetCluster(apiURL, token, clusterID string) (map[string]any, error) { + requestURL := strings.TrimRight(apiURL, "/") + "/clusters/" + url.PathEscape(clusterID) + + req, err := http.NewRequest(http.MethodGet, requestURL, nil) + if err != nil { + return nil, fmt.Errorf("build request: %w", err) + } + + req.Header.Set("Authorization", "Bearer "+token) + req.Header.Set("Accept", "application/json") + + resp, err := c.httpClient.Do(req) + if err != nil { + return nil, fmt.Errorf("request cluster %s: %w", clusterID, err) + } + + body, err := readResponseBody(resp) + if err != nil { + return nil, err + } + + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return nil, fmt.Errorf("request failed with status %s: %s", resp.Status, strings.TrimSpace(string(body))) + } + + var payload map[string]any + if err := json.Unmarshal(body, &payload); err != nil { + return nil, fmt.Errorf("parse response as json: %w", err) + } + + return payload, nil +} + +func (c *Client) listClustersPage(apiURL, token string, page int) (clustersResponse, error) { + requestURL, err := url.Parse(strings.TrimRight(apiURL, "/") + "/clusters") + if err != nil { + return clustersResponse{}, fmt.Errorf("build request url: %w", err) + } + + if page > 1 { + query := requestURL.Query() + query.Set("page", fmt.Sprintf("%d", page)) + requestURL.RawQuery = query.Encode() + } + + req, err := http.NewRequest(http.MethodGet, requestURL.String(), nil) + if err != nil { + return clustersResponse{}, fmt.Errorf("build request: %w", err) + } + + req.Header.Set("Authorization", "Bearer "+token) + req.Header.Set("Accept", "application/json") + + resp, err := c.httpClient.Do(req) + if err != nil { + return clustersResponse{}, fmt.Errorf("request clusters page %d: %w", page, err) + } + + body, err := readResponseBody(resp) + if err != nil { + return clustersResponse{}, err + } + + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return clustersResponse{}, fmt.Errorf("request failed with status %s: %s", resp.Status, strings.TrimSpace(string(body))) + } + + var payload clustersResponse + if err := json.Unmarshal(body, &payload); err != nil { + return clustersResponse{}, fmt.Errorf("parse response as json: %w", err) + } + + return payload, nil +} + +func readResponseBody(resp *http.Response) ([]byte, error) { + body, err := io.ReadAll(resp.Body) + if err != nil { + _ = resp.Body.Close() + return nil, fmt.Errorf("read response: %w", err) + } + + if err := resp.Body.Close(); err != nil { + return nil, fmt.Errorf("close response body: %w", err) + } + + return body, nil +} diff --git a/internal/api/client_test.go b/internal/api/client_test.go new file mode 100644 index 0000000..4bb0e62 --- /dev/null +++ b/internal/api/client_test.go @@ -0,0 +1,90 @@ +package api + +import ( + "fmt" + "io" + "net/http" + "strings" + "testing" +) + +type roundTripFunc func(*http.Request) (*http.Response, error) + +func (f roundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) { + return f(req) +} + +func TestListClustersReadsAllPages(t *testing.T) { + client := &Client{httpClient: &http.Client{Transport: roundTripFunc(func(r *http.Request) (*http.Response, error) { + page := r.URL.Query().Get("page") + var body string + switch page { + case "": + body = `{"items":[{"name":"alpha"}],"pageInfo":{"hasNext":true,"page":1}}` + case "2": + body = `{"items":[{"name":"beta"}],"pageInfo":{"hasNext":false,"page":2}}` + default: + return &http.Response{ + StatusCode: http.StatusBadRequest, + Status: "400 Bad Request", + Body: io.NopCloser(strings.NewReader("unexpected page")), + Header: make(http.Header), + }, nil + } + return &http.Response{ + StatusCode: http.StatusOK, + Status: "200 OK", + Body: io.NopCloser(strings.NewReader(body)), + Header: make(http.Header), + }, nil + })}} + + clusters, err := client.ListClusters("https://api.dev.kedify.io/v1", "token") + if err != nil { + t.Fatalf("ListClusters() error = %v", err) + } + + if len(clusters) != 2 { + t.Fatalf("len(clusters) = %d, want 2", len(clusters)) + } +} + +func TestListClustersReturnsHTTPError(t *testing.T) { + client := &Client{httpClient: &http.Client{Transport: roundTripFunc(func(r *http.Request) (*http.Response, error) { + return &http.Response{ + StatusCode: http.StatusBadRequest, + Status: fmt.Sprintf("%d %s", http.StatusBadRequest, http.StatusText(http.StatusBadRequest)), + Body: io.NopCloser(strings.NewReader("bad request")), + Header: make(http.Header), + }, nil + })}} + + _, err := client.ListClusters("https://api.dev.kedify.io/v1", "token") + if err == nil { + t.Fatal("expected error, got nil") + } +} + +func TestGetClusterCallsDedicatedEndpoint(t *testing.T) { + client := &Client{httpClient: &http.Client{Transport: roundTripFunc(func(r *http.Request) (*http.Response, error) { + if r.URL.Path != "/v1/clusters/fc6af0dc-685b-4055-805d-0d3e0ead1596" { + t.Fatalf("path = %q", r.URL.Path) + } + + return &http.Response{ + StatusCode: http.StatusOK, + Status: "200 OK", + Body: io.NopCloser(strings.NewReader(`{"id":"fc6af0dc-685b-4055-805d-0d3e0ead1596","name":"alpha"}`)), + Header: make(http.Header), + }, nil + })}} + + cluster, err := client.GetCluster("https://api.dev.kedify.io/v1", "token", "fc6af0dc-685b-4055-805d-0d3e0ead1596") + if err != nil { + t.Fatalf("GetCluster() error = %v", err) + } + + if cluster["name"] != "alpha" { + t.Fatalf("cluster = %#v", cluster) + } +} diff --git a/internal/cli/auth.go b/internal/cli/auth.go new file mode 100644 index 0000000..1d13c7c --- /dev/null +++ b/internal/cli/auth.go @@ -0,0 +1,16 @@ +package cli + +import "strings" + +func resolveToken(ctx *context) (string, error) { + if token := strings.TrimSpace(ctx.token); token != "" { + return token, nil + } + + creds, err := ctx.credentials.ReadCredentials() + if err != nil { + return "", err + } + + return strings.TrimSpace(creds.Token), nil +} diff --git a/internal/cli/commands_test.go b/internal/cli/commands_test.go new file mode 100644 index 0000000..4b6daa2 --- /dev/null +++ b/internal/cli/commands_test.go @@ -0,0 +1,434 @@ +package cli + +import ( + "bytes" + "io" + "strings" + "testing" +) + +type fakeCredentialsStore struct { + creds credentials + readErr error + writeErr error + wrote credentials +} + +func (f *fakeCredentialsStore) ReadCredentials() (credentials, error) { + if f.readErr != nil { + return credentials{}, f.readErr + } + return f.creds, nil +} + +func (f *fakeCredentialsStore) WriteCredentials(creds credentials) error { + if f.writeErr != nil { + return f.writeErr + } + f.wrote = creds + return nil +} + +type fakeClusterService struct { + clusters []map[string]any + cluster map[string]any + err error + lastURL string + lastToken string + lastID string +} + +func (f *fakeClusterService) ListClusters(apiURL, token string) ([]map[string]any, error) { + f.lastURL = apiURL + f.lastToken = token + if f.err != nil { + return nil, f.err + } + return f.clusters, nil +} + +func (f *fakeClusterService) GetCluster(apiURL, token, clusterID string) (map[string]any, error) { + f.lastURL = apiURL + f.lastToken = token + f.lastID = clusterID + if f.err != nil { + return nil, f.err + } + return f.cluster, nil +} + +func TestLoginCmdRunStoresCredentials(t *testing.T) { + store := &fakeCredentialsStore{} + stdout := &bytes.Buffer{} + stderr := &bytes.Buffer{} + ctx := &context{ + stdin: bytes.NewBuffer(nil), + stdout: stdout, + stderr: stderr, + credentials: store, + readSecret: func(_ io.Reader, _ io.Writer, _ io.Writer) (string, error) { + return "secret-token", nil + }, + } + + if err := (&LoginCmd{}).Run(ctx); err != nil { + t.Fatalf("Run() error = %v", err) + } + + if store.wrote.Token != "secret-token" { + t.Fatalf("stored token = %q, want %q", store.wrote.Token, "secret-token") + } + if stdout.Len() != 0 { + t.Fatalf("stdout = %q, want empty", stdout.String()) + } + if !strings.Contains(stderr.String(), "Credentials stored.") { + t.Fatalf("stderr = %q, want confirmation message", stderr.String()) + } +} + +func TestLoginCmdRunUsesExplicitToken(t *testing.T) { + store := &fakeCredentialsStore{} + ctx := &context{ + stdin: bytes.NewBuffer(nil), + stdout: &bytes.Buffer{}, + stderr: &bytes.Buffer{}, + credentials: store, + readSecret: func(_ io.Reader, _ io.Writer, _ io.Writer) (string, error) { + t.Fatal("readSecret should not be called when token is provided explicitly") + return "", nil + }, + } + + if err := (&LoginCmd{Token: "arg-token"}).Run(ctx); err != nil { + t.Fatalf("Run() error = %v", err) + } + + if store.wrote.Token != "arg-token" { + t.Fatalf("stored token = %q, want %q", store.wrote.Token, "arg-token") + } +} + +func TestLoginCmdRunIgnoresWhitespaceExplicitToken(t *testing.T) { + store := &fakeCredentialsStore{} + ctx := &context{ + stdin: bytes.NewBuffer(nil), + stdout: &bytes.Buffer{}, + stderr: &bytes.Buffer{}, + token: "env-token", + credentials: store, + readSecret: func(_ io.Reader, _ io.Writer, _ io.Writer) (string, error) { + t.Fatal("readSecret should not be called when trimmed context token is available") + return "", nil + }, + } + + if err := (&LoginCmd{Token: " "}).Run(ctx); err != nil { + t.Fatalf("Run() error = %v", err) + } + + if store.wrote.Token != "env-token" { + t.Fatalf("stored token = %q, want %q", store.wrote.Token, "env-token") + } +} + +func TestLoginCmdRunIgnoresWhitespaceContextToken(t *testing.T) { + store := &fakeCredentialsStore{} + ctx := &context{ + stdin: bytes.NewBuffer(nil), + stdout: &bytes.Buffer{}, + stderr: &bytes.Buffer{}, + token: " ", + credentials: store, + readSecret: func(_ io.Reader, _ io.Writer, _ io.Writer) (string, error) { + return "secret-token", nil + }, + } + + if err := (&LoginCmd{}).Run(ctx); err != nil { + t.Fatalf("Run() error = %v", err) + } + + if store.wrote.Token != "secret-token" { + t.Fatalf("stored token = %q, want %q", store.wrote.Token, "secret-token") + } +} + +func TestListClustersCmdRunWritesClusters(t *testing.T) { + store := &fakeCredentialsStore{creds: credentials{Token: "stored-token"}} + service := &fakeClusterService{ + clusters: []map[string]any{{"name": "alpha"}, {"name": "beta"}}, + } + + var gotValue any + var gotFormat string + ctx := &context{ + stdin: bytes.NewBuffer(nil), + stdout: &bytes.Buffer{}, + stderr: &bytes.Buffer{}, + apiURL: "https://api.dev.kedify.io/v1", + token: "override-token", + client: service, + credentials: store, + writeOutput: func(_ io.Writer, value any, format string) error { + gotValue = value + gotFormat = format + return nil + }, + } + + cmd := &ListClustersCmd{Output: "yaml"} + if err := cmd.Run(ctx); err != nil { + t.Fatalf("Run() error = %v", err) + } + + clusters, ok := gotValue.([]map[string]any) + if !ok || len(clusters) != 2 { + t.Fatalf("got output value = %#v", gotValue) + } + if gotFormat != "yaml" { + t.Fatalf("output format = %q, want %q", gotFormat, "yaml") + } + if service.lastToken != "override-token" { + t.Fatalf("service token = %q, want %q", service.lastToken, "override-token") + } +} + +func TestListClustersCmdRunWritesResultsOnlyToStdout(t *testing.T) { + store := &fakeCredentialsStore{creds: credentials{Token: "stored-token"}} + service := &fakeClusterService{ + clusters: []map[string]any{{"name": "alpha"}}, + } + stdout := &bytes.Buffer{} + stderr := &bytes.Buffer{} + + ctx := &context{ + stdin: bytes.NewBuffer(nil), + stdout: stdout, + stderr: stderr, + apiURL: "https://api.dev.kedify.io/v1", + token: "override-token", + client: service, + credentials: store, + writeOutput: writeOutput, + } + + if err := (&ListClustersCmd{Output: "json"}).Run(ctx); err != nil { + t.Fatalf("Run() error = %v", err) + } + + if stdout.Len() == 0 { + t.Fatal("stdout is empty, want rendered output") + } + if stderr.Len() != 0 { + t.Fatalf("stderr = %q, want empty", stderr.String()) + } +} + +func TestGetClusterCmdRunFindsNamedCluster(t *testing.T) { + store := &fakeCredentialsStore{creds: credentials{Token: "stored-token"}} + service := &fakeClusterService{ + clusters: []map[string]any{ + {"id": "1", "name": "alpha"}, + {"id": "2", "name": "beta"}, + }, + } + + var gotValue any + ctx := &context{ + stdin: bytes.NewBuffer(nil), + stdout: &bytes.Buffer{}, + stderr: &bytes.Buffer{}, + apiURL: "https://api.dev.kedify.io/v1", + token: "override-token", + client: service, + credentials: store, + selectCluster: func(_ io.Reader, _ io.Writer, _ io.Writer, _ []map[string]any) (map[string]any, error) { + t.Fatal("selector should not be called when name is provided") + return nil, nil + }, + writeOutput: func(_ io.Writer, value any, _ string) error { + gotValue = value + return nil + }, + } + + cmd := &GetClusterCmd{Name: "beta", Output: "json"} + if err := cmd.Run(ctx); err != nil { + t.Fatalf("Run() error = %v", err) + } + + cluster, ok := gotValue.(map[string]any) + if !ok { + t.Fatalf("got output value = %#v", gotValue) + } + if cluster["id"] != "2" { + t.Fatalf("selected cluster = %#v", cluster) + } + if service.lastToken != "override-token" { + t.Fatalf("service token = %q, want %q", service.lastToken, "override-token") + } +} + +func TestGetClusterCmdRunWritesResultsOnlyToStdout(t *testing.T) { + store := &fakeCredentialsStore{creds: credentials{Token: "stored-token"}} + service := &fakeClusterService{ + clusters: []map[string]any{ + {"id": "2", "name": "beta"}, + }, + } + stdout := &bytes.Buffer{} + stderr := &bytes.Buffer{} + + ctx := &context{ + stdin: bytes.NewBuffer(nil), + stdout: stdout, + stderr: stderr, + apiURL: "https://api.dev.kedify.io/v1", + token: "override-token", + client: service, + credentials: store, + selectCluster: func(_ io.Reader, _ io.Writer, _ io.Writer, _ []map[string]any) (map[string]any, error) { + t.Fatal("selector should not be called when name is provided") + return nil, nil + }, + writeOutput: writeOutput, + } + + if err := (&GetClusterCmd{Name: "beta", Output: "json"}).Run(ctx); err != nil { + t.Fatalf("Run() error = %v", err) + } + + if stdout.Len() == 0 { + t.Fatal("stdout is empty, want rendered output") + } + if stderr.Len() != 0 { + t.Fatalf("stderr = %q, want empty", stderr.String()) + } +} + +func TestGetClusterCmdRunUsesDedicatedEndpointForUUID(t *testing.T) { + store := &fakeCredentialsStore{creds: credentials{Token: "stored-token"}} + service := &fakeClusterService{ + cluster: map[string]any{ + "id": "fc6af0dc-685b-4055-805d-0d3e0ead1596", + "name": "alpha", + }, + } + + var gotValue any + ctx := &context{ + stdin: bytes.NewBuffer(nil), + stdout: &bytes.Buffer{}, + stderr: &bytes.Buffer{}, + apiURL: "https://api.dev.kedify.io/v1", + token: "override-token", + client: service, + credentials: store, + selectCluster: func(_ io.Reader, _ io.Writer, _ io.Writer, _ []map[string]any) (map[string]any, error) { + t.Fatal("selector should not be called when uuid is provided") + return nil, nil + }, + writeOutput: func(_ io.Writer, value any, _ string) error { + gotValue = value + return nil + }, + } + + id := "fc6af0dc-685b-4055-805d-0d3e0ead1596" + cmd := &GetClusterCmd{Name: id, Output: "json"} + if err := cmd.Run(ctx); err != nil { + t.Fatalf("Run() error = %v", err) + } + + cluster, ok := gotValue.(map[string]any) + if !ok || cluster["id"] != id { + t.Fatalf("got output value = %#v", gotValue) + } + if service.lastID != id { + t.Fatalf("service lastID = %q, want %q", service.lastID, id) + } +} + +func TestGetClusterCmdRunUsesSelectorWhenNameMissing(t *testing.T) { + store := &fakeCredentialsStore{creds: credentials{Token: "stored-token"}} + service := &fakeClusterService{ + clusters: []map[string]any{{"id": "fc6af0dc-685b-4055-805d-0d3e0ead1596", "name": "alpha"}}, + cluster: map[string]any{"id": "fc6af0dc-685b-4055-805d-0d3e0ead1596", "name": "alpha", "agentStatus": "connected"}, + } + selected := map[string]any{"id": "fc6af0dc-685b-4055-805d-0d3e0ead1596", "name": "alpha"} + + var gotValue any + ctx := &context{ + stdin: bytes.NewBuffer(nil), + stdout: &bytes.Buffer{}, + stderr: &bytes.Buffer{}, + apiURL: "https://api.dev.kedify.io/v1", + token: "override-token", + client: service, + credentials: store, + selectCluster: func(_ io.Reader, _ io.Writer, _ io.Writer, clusters []map[string]any) (map[string]any, error) { + if len(clusters) != 1 { + t.Fatalf("selector clusters len = %d, want 1", len(clusters)) + } + return selected, nil + }, + writeOutput: func(_ io.Writer, value any, _ string) error { + gotValue = value + return nil + }, + } + + if err := (&GetClusterCmd{Output: "json"}).Run(ctx); err != nil { + t.Fatalf("Run() error = %v", err) + } + + cluster, ok := gotValue.(map[string]any) + if !ok { + t.Fatalf("got output value = %#v", gotValue) + } + if cluster["id"] != selected["id"] { + t.Fatalf("got output value = %#v, want selected cluster", gotValue) + } + if service.lastToken != "override-token" { + t.Fatalf("service token = %q, want %q", service.lastToken, "override-token") + } + if service.lastID != "fc6af0dc-685b-4055-805d-0d3e0ead1596" { + t.Fatalf("service lastID = %q", service.lastID) + } +} + +func TestFindClusterReturnsErrorWhenMissing(t *testing.T) { + _, err := findCluster([]map[string]any{{"name": "alpha"}}, "beta") + if err == nil { + t.Fatal("expected error, got nil") + } +} + +func TestResolveTokenFallsBackToCredentials(t *testing.T) { + ctx := &context{ + credentials: &fakeCredentialsStore{creds: credentials{Token: "stored-token"}}, + } + + token, err := resolveToken(ctx) + if err != nil { + t.Fatalf("resolveToken() error = %v", err) + } + if token != "stored-token" { + t.Fatalf("token = %q, want %q", token, "stored-token") + } +} + +func TestResolveTokenPrefersContextToken(t *testing.T) { + ctx := &context{ + token: "override-token", + credentials: &fakeCredentialsStore{creds: credentials{Token: "stored-token"}}, + } + + token, err := resolveToken(ctx) + if err != nil { + t.Fatalf("resolveToken() error = %v", err) + } + if token != "override-token" { + t.Fatalf("token = %q, want %q", token, "override-token") + } +} diff --git a/internal/cli/deps.go b/internal/cli/deps.go new file mode 100644 index 0000000..510fffa --- /dev/null +++ b/internal/cli/deps.go @@ -0,0 +1,18 @@ +package cli + +import "github.com/kedify/cli/internal/config" + +type configStore struct{} + +func (configStore) ReadCredentials() (credentials, error) { + creds, err := config.ReadCredentials() + if err != nil { + return credentials{}, err + } + + return credentials{Token: creds.Token}, nil +} + +func (configStore) WriteCredentials(creds credentials) error { + return config.WriteCredentials(config.Credentials{Token: creds.Token}) +} diff --git a/internal/cli/get.go b/internal/cli/get.go new file mode 100644 index 0000000..09d263e --- /dev/null +++ b/internal/cli/get.go @@ -0,0 +1,85 @@ +package cli + +import ( + "fmt" + + "github.com/google/uuid" +) + +type GetClusterCmd struct { + Name string `arg:"" optional:"" name:"name" help:"Cluster name or id."` + Output string `name:"output" short:"o" help:"Output format." enum:"text,json,yaml" default:"text"` +} + +func (c *GetClusterCmd) Run(ctx *context) error { + token, err := resolveToken(ctx) + if err != nil { + return err + } + + var clusters []map[string]any + if c.Name == "" || !isUUID(c.Name) { + clusters, err = ctx.client.ListClusters(ctx.apiURL, token) + if err != nil { + return err + } + } + + var cluster map[string]any + if c.Name != "" { + if isUUID(c.Name) { + cluster, err = ctx.client.GetCluster(ctx.apiURL, token, c.Name) + if err != nil { + return err + } + } else { + cluster, err = findCluster(clusters, c.Name) + if err != nil { + return err + } + } + } else { + selectedCluster, err := ctx.selectCluster(ctx.stdin, ctx.stdout, ctx.stderr, clusters) + if err != nil { + return err + } + cluster = selectedCluster + if id := clusterString(selectedCluster, "id"); isUUID(id) { + cluster, err = ctx.client.GetCluster(ctx.apiURL, token, id) + if err != nil { + return err + } + } + } + + return ctx.writeOutput(ctx.stdout, cluster, c.Output) +} + +func findCluster(clusters []map[string]any, query string) (map[string]any, error) { + for _, cluster := range clusters { + if clusterString(cluster, "name") == query || clusterString(cluster, "id") == query { + return cluster, nil + } + } + + return nil, fmt.Errorf("cluster %q not found", query) +} + +func isUUID(value string) bool { + _, err := uuid.Parse(value) + return err == nil +} + +func clusterString(cluster map[string]any, key string) string { + value, ok := cluster[key] + if !ok { + return "" + } + + text, ok := value.(string) + if !ok { + return "" + } + + return text +} diff --git a/internal/cli/list.go b/internal/cli/list.go new file mode 100644 index 0000000..1747d27 --- /dev/null +++ b/internal/cli/list.go @@ -0,0 +1,15 @@ +package cli + +func (c *ListClustersCmd) Run(ctx *context) error { + token, err := resolveToken(ctx) + if err != nil { + return err + } + + clusters, err := ctx.client.ListClusters(ctx.apiURL, token) + if err != nil { + return err + } + + return ctx.writeOutput(ctx.stdout, clusters, c.Output) +} diff --git a/internal/cli/login.go b/internal/cli/login.go new file mode 100644 index 0000000..9fb1f4f --- /dev/null +++ b/internal/cli/login.go @@ -0,0 +1,31 @@ +package cli + +import ( + "fmt" + "strings" +) + +type LoginCmd struct { + Token string `arg:"" optional:"" name:"token" help:"Token to store without prompting."` +} + +func (c *LoginCmd) Run(ctx *context) error { + token := strings.TrimSpace(c.Token) + if token == "" { + token = strings.TrimSpace(ctx.token) + } + if token == "" { + var err error + token, err = ctx.readSecret(ctx.stdin, ctx.stdout, ctx.stderr) + if err != nil { + return err + } + } + + if err := ctx.credentials.WriteCredentials(credentials{Token: token}); err != nil { + return err + } + + _, err := fmt.Fprintln(ctx.stderr, "Credentials stored.") + return err +} diff --git a/internal/cli/output.go b/internal/cli/output.go new file mode 100644 index 0000000..559b0eb --- /dev/null +++ b/internal/cli/output.go @@ -0,0 +1,11 @@ +package cli + +import ( + "io" + + "github.com/kedify/cli/internal/output" +) + +func writeOutput(w io.Writer, value any, format string) error { + return output.Write(w, value, format) +} diff --git a/internal/cli/run.go b/internal/cli/run.go new file mode 100644 index 0000000..e2c71db --- /dev/null +++ b/internal/cli/run.go @@ -0,0 +1,97 @@ +package cli + +import ( + "fmt" + "io" + + "github.com/alecthomas/kong" + "github.com/kedify/cli/internal/api" + "github.com/kedify/cli/internal/tui" +) + +type CLI struct { + APIURL string `name:"apiurl" help:"Base URL for the Kedify API." default:"https://api.dev.kedify.io/v1" env:"KEDIFY_API_URL"` + Token string `name:"token" help:"Kedify API token." env:"KEDIFY_TOKEN"` + Get GetCmd `cmd:"" help:"Get Kedify resources."` + Login LoginCmd `cmd:"" help:"Read an auth token from stdin and store it locally. Generate a token at https://dashboard.dev.kedify.io/api-keys."` + List ListCmd `cmd:"" help:"List Kedify resources."` +} + +type GetCmd struct { + Cluster GetClusterCmd `cmd:"" help:"Get a cluster by name or id. If no name is provided, an interactive picker is shown."` +} + +type ListCmd struct { + Clusters ListClustersCmd `cmd:"" help:"List clusters."` +} + +type ListClustersCmd struct { + Output string `name:"output" short:"o" help:"Output format." enum:"text,json,yaml" default:"text"` +} + +type credentialsStore interface { + ReadCredentials() (credentials, error) + WriteCredentials(credentials) error +} + +type clusterService interface { + ListClusters(apiURL, token string) ([]map[string]any, error) + GetCluster(apiURL, token, clusterID string) (map[string]any, error) +} + +type credentials struct { + Token string +} + +type context struct { + stdin io.Reader + stdout io.Writer + stderr io.Writer + apiURL string + token string + client clusterService + credentials credentialsStore + readSecret func(io.Reader, io.Writer, io.Writer) (string, error) + selectCluster func(io.Reader, io.Writer, io.Writer, []map[string]any) (map[string]any, error) + writeOutput func(io.Writer, any, string) error +} + +func Run(args []string, stdin io.Reader, stdout, stderr io.Writer) int { + app := &context{ + stdin: stdin, + stdout: stdout, + stderr: stderr, + client: api.NewClient(), + credentials: configStore{}, + readSecret: tui.ReadSecretOrPipe, + selectCluster: tui.SelectClusterOrFail, + writeOutput: writeOutput, + } + + var cli CLI + parser, err := kong.New( + &cli, + kong.Name("kedify"), + kong.Description("Kedify command line interface."), + kong.Writers(stdout, stderr), + ) + if err != nil { + _, _ = fmt.Fprintf(stderr, "kedify: error: %v\n", err) + return 1 + } + + kctx, err := parser.Parse(args) + if err != nil { + _, _ = fmt.Fprintf(stderr, "kedify: error: %v\n", err) + return 1 + } + + app.apiURL = cli.APIURL + app.token = cli.Token + if err := kctx.Run(app); err != nil { + _, _ = fmt.Fprintf(stderr, "kedify: error: %v\n", err) + return 1 + } + + return 0 +} diff --git a/internal/config/config.go b/internal/config/config.go new file mode 100644 index 0000000..35d4039 --- /dev/null +++ b/internal/config/config.go @@ -0,0 +1,143 @@ +package config + +import ( + "encoding/json" + "errors" + "fmt" + "os" + "path/filepath" + "strings" + + "github.com/zalando/go-keyring" +) + +const ( + credentialsDir = ".config/kedify" // #nosec G101 -- filesystem path, not a credential + credentialsFile = "credentials.json" + fileModeDir = 0o700 + fileModeCreds = 0o600 + keyringService = "io.kedify.cli" + keyringUser = "default" +) + +type Credentials struct { + Token string `json:"token"` +} + +var ( + keyringSet = keyring.Set + keyringGet = keyring.Get +) + +func WriteCredentials(creds Credentials) error { + err := keyringSet(keyringService, keyringUser, creds.Token) + if err == nil { + return nil + } + if !isKeyringUnavailable(err) { + return fmt.Errorf("write credentials to keyring: %w", err) + } + + return writeCredentialsFile(creds) +} + +func ReadCredentials() (Credentials, error) { + token, err := keyringGet(keyringService, keyringUser) + switch { + case err == nil: + token = strings.TrimSpace(token) + if token == "" { + return Credentials{}, errors.New("keyring entry does not contain a token") + } + return Credentials{Token: token}, nil + case errors.Is(err, keyring.ErrNotFound), isKeyringUnavailable(err): + return readCredentialsFile() + default: + return Credentials{}, fmt.Errorf("read credentials from keyring: %w", err) + } +} + +func APIURLFromEnv() string { + return strings.TrimSpace(os.Getenv("KEDIFY_API_URL")) +} + +func writeCredentialsFile(creds Credentials) error { + path, err := credentialsPath() + if err != nil { + return err + } + + if err := os.MkdirAll(filepath.Dir(path), fileModeDir); err != nil { + return fmt.Errorf("create credentials directory: %w", err) + } + + data, err := json.MarshalIndent(creds, "", " ") + if err != nil { + return fmt.Errorf("marshal credentials: %w", err) + } + + data = append(data, '\n') + if err := os.WriteFile(path, data, fileModeCreds); err != nil { + return fmt.Errorf("write credentials: %w", err) + } + + return nil +} + +func readCredentialsFile() (Credentials, error) { + path, err := credentialsPath() + if err != nil { + return Credentials{}, err + } + + data, err := os.ReadFile(path) // #nosec G304 -- path is derived from the current user's home directory and fixed credentials location + if err != nil { + if errors.Is(err, os.ErrNotExist) { + return Credentials{}, errors.New("credentials not found, run `kedify login` first") + } + return Credentials{}, fmt.Errorf("read credentials: %w", err) + } + + var creds Credentials + if err := json.Unmarshal(data, &creds); err != nil { + return Credentials{}, fmt.Errorf("parse credentials: %w", err) + } + + if strings.TrimSpace(creds.Token) == "" { + return Credentials{}, errors.New("credentials file does not contain a token") + } + + return creds, nil +} + +func credentialsPath() (string, error) { + homeDir, err := os.UserHomeDir() + if err != nil { + return "", fmt.Errorf("resolve home directory: %w", err) + } + + return filepath.Join(homeDir, credentialsDir, credentialsFile), nil +} + +func isKeyringUnavailable(err error) bool { + if errors.Is(err, keyring.ErrUnsupportedPlatform) { + return true + } + + message := strings.ToLower(err.Error()) + for _, marker := range []string{ + "dbus", + "secret service", + "org.freedesktop.secrets", + "keyring is not available", + "credential manager is not available", + "keychain is not available", + "not supported", + } { + if strings.Contains(message, marker) { + return true + } + } + + return false +} diff --git a/internal/config/config_test.go b/internal/config/config_test.go new file mode 100644 index 0000000..e4f1d4b --- /dev/null +++ b/internal/config/config_test.go @@ -0,0 +1,101 @@ +package config + +import ( + "errors" + "os" + "path/filepath" + "testing" + + "github.com/zalando/go-keyring" +) + +func TestReadCredentialsPrefersKeyring(t *testing.T) { + origGet := keyringGet + t.Cleanup(func() { + keyringGet = origGet + }) + + keyringGet = func(service, user string) (string, error) { + return "keyring-token", nil + } + + creds, err := ReadCredentials() + if err != nil { + t.Fatalf("ReadCredentials() error = %v", err) + } + if creds.Token != "keyring-token" { + t.Fatalf("token = %q, want %q", creds.Token, "keyring-token") + } +} + +func TestReadCredentialsFallsBackToFileWhenKeyringUnavailable(t *testing.T) { + origGet := keyringGet + t.Cleanup(func() { + keyringGet = origGet + }) + + tmpHome := t.TempDir() + t.Setenv("HOME", tmpHome) + + keyringGet = func(service, user string) (string, error) { + return "", keyring.ErrUnsupportedPlatform + } + + filePath := filepath.Join(tmpHome, credentialsDir, credentialsFile) + if err := os.MkdirAll(filepath.Dir(filePath), fileModeDir); err != nil { + t.Fatalf("MkdirAll() error = %v", err) + } + if err := os.WriteFile(filePath, []byte("{\"token\":\"file-token\"}\n"), fileModeCreds); err != nil { + t.Fatalf("WriteFile() error = %v", err) + } + + creds, err := ReadCredentials() + if err != nil { + t.Fatalf("ReadCredentials() error = %v", err) + } + if creds.Token != "file-token" { + t.Fatalf("token = %q, want %q", creds.Token, "file-token") + } +} + +func TestWriteCredentialsFallsBackToFileWhenKeyringUnavailable(t *testing.T) { + origSet := keyringSet + t.Cleanup(func() { + keyringSet = origSet + }) + + tmpHome := t.TempDir() + t.Setenv("HOME", tmpHome) + + keyringSet = func(service, user, password string) error { + return keyring.ErrUnsupportedPlatform + } + + if err := WriteCredentials(Credentials{Token: "file-token"}); err != nil { + t.Fatalf("WriteCredentials() error = %v", err) + } + + creds, err := readCredentialsFile() + if err != nil { + t.Fatalf("readCredentialsFile() error = %v", err) + } + if creds.Token != "file-token" { + t.Fatalf("token = %q, want %q", creds.Token, "file-token") + } +} + +func TestWriteCredentialsReturnsKeyringErrorWhenStoreIsAvailableButFails(t *testing.T) { + origSet := keyringSet + t.Cleanup(func() { + keyringSet = origSet + }) + + keyringSet = func(service, user, password string) error { + return errors.New("permission denied") + } + + err := WriteCredentials(Credentials{Token: "token"}) + if err == nil { + t.Fatal("expected error, got nil") + } +} diff --git a/internal/output/output.go b/internal/output/output.go new file mode 100644 index 0000000..2ed44a8 --- /dev/null +++ b/internal/output/output.go @@ -0,0 +1,213 @@ +package output + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "strings" + "time" + + "gopkg.in/yaml.v3" +) + +func Write(w io.Writer, value any, format string) error { + var ( + data []byte + err error + ) + + switch format { + case "text": + data, err = renderText(value) + case "json": + data, err = json.MarshalIndent(value, "", " ") + case "yaml": + data, err = yaml.Marshal(value) + default: + return fmt.Errorf("unsupported output format %q", format) + } + if err != nil { + return fmt.Errorf("encode %s output: %w", format, err) + } + + if !bytes.HasSuffix(data, []byte("\n")) { + data = append(data, '\n') + } + + n, err := w.Write(data) + if err != nil { + return err + } + if n != len(data) { + return io.ErrShortWrite + } + return nil +} + +func renderText(value any) ([]byte, error) { + switch v := value.(type) { + case []map[string]any: + return renderClusterListText(v), nil + case map[string]any: + return renderClusterText(v), nil + default: + return nil, fmt.Errorf("text output is not supported for %T", value) + } +} + +func renderClusterListText(clusters []map[string]any) []byte { + if len(clusters) == 0 { + return []byte("No clusters found.\n") + } + + rows := make([][]string, 0, len(clusters)+1) + rows = append(rows, []string{"NAME", "ID", "AGENT VERSION", "KEDA VERSION", "AGENT STATUS", "KEDA STATUS", "AGE"}) + + for _, cluster := range clusters { + rows = append(rows, []string{ + fallbackClusterValue(cluster, "name", ""), + clusterTextValue(cluster, "id"), + clusterAgentVersion(cluster), + clusterKEDAVersion(cluster), + clusterTextValue(cluster, "agentStatus"), + clusterTextValue(cluster, "kedaStatus"), + clusterAge(cluster), + }) + } + + return []byte(renderTextTable(rows)) +} + +func renderClusterText(cluster map[string]any) []byte { + rows := [][]string{ + {"NAME", "ID", "AGENT VERSION", "KEDA VERSION", "AGENT STATUS", "KEDA STATUS", "AGE"}, + { + fallbackClusterValue(cluster, "name", ""), + clusterTextValue(cluster, "id"), + clusterAgentVersion(cluster), + clusterKEDAVersion(cluster), + clusterTextValue(cluster, "agentStatus"), + clusterTextValue(cluster, "kedaStatus"), + clusterAge(cluster), + }, + } + + return []byte(renderTextTable(rows)) +} + +func clusterTextValue(cluster map[string]any, key string) string { + value, ok := cluster[key] + if !ok { + return "" + } + + text, ok := value.(string) + if !ok { + return "" + } + + return text +} + +func fallbackClusterValue(cluster map[string]any, key, fallback string) string { + if value := clusterTextValue(cluster, key); value != "" { + return value + } + return fallback +} + +func renderTextTable(rows [][]string) string { + if len(rows) == 0 { + return "\n" + } + + widths := make([]int, len(rows[0])) + for _, row := range rows { + for i, cell := range row { + if len(cell) > widths[i] { + widths[i] = len(cell) + } + } + } + + lines := make([]string, 0, len(rows)) + for _, row := range rows { + padded := make([]string, len(row)) + for i, cell := range row { + padded[i] = padRight(cell, widths[i]) + } + lines = append(lines, strings.TrimRight(strings.Join(padded, " "), " ")) + } + + return strings.Join(lines, "\n") + "\n" +} + +func padRight(value string, width int) string { + if len(value) >= width { + return value + } + return value + strings.Repeat(" ", width-len(value)) +} + +func clusterAge(cluster map[string]any) string { + createdAt := clusterTextValue(cluster, "createdAt") + if createdAt == "" { + return "" + } + + created, err := time.Parse("2006-01-02", createdAt) + if err != nil { + return createdAt + } + + return humanAge(time.Since(created)) +} + +func clusterAgentVersion(cluster map[string]any) string { + agent, ok := cluster["agent"].(map[string]any) + if !ok { + return "" + } + version, _ := agent["version"].(string) + return version +} + +func clusterKEDAVersion(cluster map[string]any) string { + agent, ok := cluster["agent"].(map[string]any) + if !ok { + return "" + } + + kedaConfigs, ok := agent["kedaConfigs"].([]any) + if !ok || len(kedaConfigs) == 0 { + return "" + } + + firstConfig, ok := kedaConfigs[0].(map[string]any) + if !ok { + return "" + } + + version, _ := firstConfig["kedaVersion"].(string) + return version +} + +func humanAge(d time.Duration) string { + if d < time.Minute { + return "0m" + } + if d < time.Hour { + return fmt.Sprintf("%dm", int(d.Minutes())) + } + if d < 24*time.Hour { + return fmt.Sprintf("%dh", int(d.Hours())) + } + if d < 30*24*time.Hour { + return fmt.Sprintf("%dd", int(d.Hours()/24)) + } + if d < 365*24*time.Hour { + return fmt.Sprintf("%dmo", int(d.Hours()/(24*30))) + } + return fmt.Sprintf("%dy", int(d.Hours()/(24*365))) +} diff --git a/internal/output/output_test.go b/internal/output/output_test.go new file mode 100644 index 0000000..0f19871 --- /dev/null +++ b/internal/output/output_test.go @@ -0,0 +1,105 @@ +package output + +import ( + "bytes" + "strings" + "testing" +) + +func TestWriteTextClusterList(t *testing.T) { + var out bytes.Buffer + value := []map[string]any{ + { + "name": "alpha", + "id": "1", + "agentStatus": "connected", + "kedaStatus": "ready", + "createdAt": "2026-06-15", + "agent": map[string]any{ + "version": "v0.6.1", + "kedaConfigs": []any{ + map[string]any{"kedaVersion": "v2.18.0"}, + }, + }, + }, + } + + if err := Write(&out, value, "text"); err != nil { + t.Fatalf("Write() error = %v", err) + } + + got := out.String() + for _, expected := range []string{ + "NAME", + "ID", + "AGENT VERSION", + "KEDA VERSION", + "AGENT STATUS", + "KEDA STATUS", + "AGE", + "alpha", + "1", + "v0.6.1", + "v2.18.0", + "connected", + "ready", + } { + if !strings.Contains(got, expected) { + t.Fatalf("expected %q in output %q", expected, got) + } + } + if strings.Contains(got, "---") { + t.Fatalf("unexpected divider line in output: %q", got) + } + if !strings.Contains(got, "NAME ID AGENT VERSION KEDA VERSION AGENT STATUS KEDA STATUS AGE") { + t.Fatalf("unexpected text output: %q", got) + } +} + +func TestWriteTextSingleCluster(t *testing.T) { + var out bytes.Buffer + value := map[string]any{ + "name": "alpha", + "id": "1", + "agentStatus": "connected", + "kedaStatus": "ready", + "createdAt": "2026-06-16", + "agent": map[string]any{ + "version": "v0.6.1", + "kedaConfigs": []any{ + map[string]any{"kedaVersion": "v2.18.0"}, + }, + }, + } + + if err := Write(&out, value, "text"); err != nil { + t.Fatalf("Write() error = %v", err) + } + + got := out.String() + for _, expected := range []string{ + "NAME", + "ID", + "AGENT VERSION", + "KEDA VERSION", + "AGENT STATUS", + "KEDA STATUS", + "AGE", + "alpha", + "1", + "v0.6.1", + "v2.18.0", + "connected", + "ready", + } { + if !strings.Contains(got, expected) { + t.Fatalf("expected %q in output %q", expected, got) + } + } + if strings.Contains(got, "---") { + t.Fatalf("unexpected divider line in output: %q", got) + } + if !strings.Contains(got, "NAME ID AGENT VERSION KEDA VERSION AGENT STATUS KEDA STATUS AGE") { + t.Fatalf("unexpected padded output: %q", got) + } +} diff --git a/internal/tui/cluster_picker.go b/internal/tui/cluster_picker.go new file mode 100644 index 0000000..3c80642 --- /dev/null +++ b/internal/tui/cluster_picker.go @@ -0,0 +1,161 @@ +package tui + +import ( + "errors" + "fmt" + "io" + "os" + "strings" + + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" +) + +var ( + clusterPickerTitleStyle = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("86")) + clusterPickerHintStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("241")) + clusterPickerSelectedStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("86")).Bold(true) + clusterPickerMetaStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("241")) +) + +type clusterOption struct { + Name string + ID string + Status string + Data map[string]any +} + +type clusterPickerModel struct { + options []clusterOption + cursor int + choice *clusterOption + quit bool +} + +func SelectClusterOrFail(stdin io.Reader, stdout io.Writer, stderr io.Writer, clusters []map[string]any) (map[string]any, error) { + file, ok := stdin.(*os.File) + if !ok || !isInteractive(file) { + return nil, errors.New("cluster name is required when not running interactively") + } + + options := make([]clusterOption, 0, len(clusters)) + for _, cluster := range clusters { + options = append(options, clusterOption{ + Name: clusterName(cluster), + ID: clusterValue(cluster, "id"), + Status: clusterValue(cluster, "agentStatus"), + Data: cluster, + }) + } + + if len(options) == 0 { + return nil, errors.New("no clusters available") + } + + model := clusterPickerModel{options: options} + program := tea.NewProgram(model, tea.WithInput(file), tea.WithOutput(promptOutput(stdout, stderr))) + result, err := program.Run() + if err != nil { + return nil, fmt.Errorf("run cluster picker: %w", err) + } + + finalModel, ok := result.(clusterPickerModel) + if !ok { + return nil, errors.New("unexpected cluster picker state") + } + + if finalModel.quit { + return nil, errors.New("cluster selection canceled") + } + if finalModel.choice == nil { + return nil, errors.New("no cluster selected") + } + + return finalModel.choice.Data, nil +} + +func (m clusterPickerModel) Init() tea.Cmd { + return nil +} + +func (m clusterPickerModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case tea.KeyMsg: + switch msg.String() { + case "ctrl+c", "esc": + m.quit = true + return m, tea.Quit + case "up", "k": + if m.cursor > 0 { + m.cursor-- + } + case "down", "j": + if m.cursor < len(m.options)-1 { + m.cursor++ + } + case "enter": + choice := m.options[m.cursor] + m.choice = &choice + return m, tea.Quit + } + } + + return m, nil +} + +func (m clusterPickerModel) View() string { + var b strings.Builder + b.WriteString(clusterPickerTitleStyle.Render("Select a Cluster")) + b.WriteString("\n") + b.WriteString(clusterPickerHintStyle.Render("Use ↑/↓ or j/k, Enter to select, Esc to cancel.")) + b.WriteString("\n\n") + + for i, option := range m.options { + prefix := " " + lineStyle := lipgloss.NewStyle() + if i == m.cursor { + prefix = "› " + lineStyle = clusterPickerSelectedStyle + } + + b.WriteString(lineStyle.Render(prefix + option.Name)) + if option.Status != "" || option.ID != "" { + metaParts := make([]string, 0, 2) + if option.Status != "" { + metaParts = append(metaParts, option.Status) + } + if option.ID != "" { + metaParts = append(metaParts, option.ID) + } + b.WriteString("\n") + b.WriteString(clusterPickerMetaStyle.Render(" " + strings.Join(metaParts, " • "))) + } + b.WriteString("\n") + } + + return b.String() +} + +func clusterName(cluster map[string]any) string { + if name := clusterValue(cluster, "name"); name != "" { + return name + } + if id := clusterValue(cluster, "id"); id != "" { + return id + } + return "" +} + +func clusterValue(cluster map[string]any, key string) string { + value, ok := cluster[key] + if !ok { + return "" + } + + text, ok := value.(string) + if !ok { + return "" + } + + return text +} diff --git a/internal/tui/cluster_picker_test.go b/internal/tui/cluster_picker_test.go new file mode 100644 index 0000000..b8aad71 --- /dev/null +++ b/internal/tui/cluster_picker_test.go @@ -0,0 +1,30 @@ +package tui + +import ( + "testing" + + tea "github.com/charmbracelet/bubbletea" +) + +func TestClusterPickerEnterSelectsCurrentOption(t *testing.T) { + model := clusterPickerModel{ + options: []clusterOption{ + {Name: "alpha", Data: map[string]any{"name": "alpha"}}, + {Name: "beta", Data: map[string]any{"name": "beta"}}, + }, + cursor: 1, + } + + updated, _ := model.Update(tea.KeyMsg{Type: tea.KeyEnter}) + got := updated.(clusterPickerModel) + if got.choice == nil || got.choice.Name != "beta" { + t.Fatalf("choice = %#v, want beta", got.choice) + } +} + +func TestClusterNameFallsBackToID(t *testing.T) { + got := clusterName(map[string]any{"id": "cluster-1"}) + if got != "cluster-1" { + t.Fatalf("clusterName() = %q, want %q", got, "cluster-1") + } +} diff --git a/internal/tui/login.go b/internal/tui/login.go new file mode 100644 index 0000000..e25e5c5 --- /dev/null +++ b/internal/tui/login.go @@ -0,0 +1,140 @@ +package tui + +import ( + "errors" + "fmt" + "io" + "os" + "strings" + + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" +) + +const apiKeysURL = "https://dashboard.dev.kedify.io/api-keys" // #nosec G101 -- public dashboard URL, not a credential + +var ( + titleStyle = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("86")) + promptStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("252")) + hintStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("241")) + errorStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("196")) +) + +type loginModel struct { + token string + err string + done bool + quit bool +} + +func ReadSecretOrPipe(stdin io.Reader, stdout io.Writer, stderr io.Writer) (string, error) { + if file, ok := stdin.(*os.File); ok && isInteractive(file) { + model := loginModel{} + program := tea.NewProgram(model, tea.WithInput(file), tea.WithOutput(promptOutput(stdout, stderr))) + result, err := program.Run() + if err != nil { + return "", fmt.Errorf("run login prompt: %w", err) + } + + finalModel, ok := result.(loginModel) + if !ok { + return "", errors.New("unexpected login prompt state") + } + + if finalModel.quit { + return "", errors.New("login canceled") + } + if finalModel.token == "" { + return "", errors.New("no token provided") + } + + return finalModel.token, nil + } + + data, err := io.ReadAll(stdin) + if err != nil { + return "", fmt.Errorf("read token from stdin: %w", err) + } + + token := strings.TrimSpace(string(data)) + if token == "" { + return "", fmt.Errorf("no token provided on stdin, generate one at %s", apiKeysURL) + } + + return token, nil +} + +func promptOutput(stdout io.Writer, stderr io.Writer) io.Writer { + if stderr != nil { + return stderr + } + + return stdout +} + +func (m loginModel) Init() tea.Cmd { + return nil +} + +func (m loginModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case tea.KeyMsg: + switch msg.Type { + case tea.KeyCtrlC, tea.KeyEsc: + m.quit = true + return m, tea.Quit + case tea.KeyEnter: + if strings.TrimSpace(m.token) == "" { + m.err = "Please paste a token." + return m, nil + } + m.done = true + return m, tea.Quit + case tea.KeyBackspace, tea.KeyDelete: + if len(m.token) > 0 { + m.token = m.token[:len(m.token)-1] + } + m.err = "" + default: + if msg.Type == tea.KeyRunes { + m.token += string(msg.Runes) + m.err = "" + } + } + } + + return m, nil +} + +func (m loginModel) View() string { + if m.done { + return "\n" + } + + var b strings.Builder + b.WriteString(titleStyle.Render("Kedify Login")) + b.WriteString("\n") + b.WriteString(hintStyle.Render("Generate a token at " + apiKeysURL)) + b.WriteString("\n\n") + b.WriteString(promptStyle.Render("Paste Kedify token: ")) + b.WriteString(strings.Repeat("•", len(m.token))) + b.WriteString("\n") + b.WriteString(hintStyle.Render("Press Enter to save, Esc to cancel.")) + + if m.err != "" { + b.WriteString("\n") + b.WriteString(errorStyle.Render(m.err)) + } + + b.WriteString("\n") + return b.String() +} + +func isInteractive(file *os.File) bool { + info, err := file.Stat() + if err != nil { + return false + } + + return info.Mode()&os.ModeCharDevice != 0 +} diff --git a/internal/tui/login_test.go b/internal/tui/login_test.go new file mode 100644 index 0000000..203eca0 --- /dev/null +++ b/internal/tui/login_test.go @@ -0,0 +1,54 @@ +package tui + +import ( + "bytes" + "strings" + "testing" + + tea "github.com/charmbracelet/bubbletea" +) + +func TestReadSecretOrPipeReadsPipedInput(t *testing.T) { + token, err := ReadSecretOrPipe(strings.NewReader("token-from-pipe\n"), &bytes.Buffer{}, &bytes.Buffer{}) + if err != nil { + t.Fatalf("ReadSecretOrPipe() error = %v", err) + } + if token != "token-from-pipe" { + t.Fatalf("token = %q, want %q", token, "token-from-pipe") + } +} + +func TestLoginModelEnterWithoutTokenShowsError(t *testing.T) { + model := loginModel{} + updated, _ := model.Update(tea.KeyMsg{Type: tea.KeyEnter}) + got := updated.(loginModel) + if got.err == "" { + t.Fatal("expected validation error, got none") + } +} + +func TestLoginModelAcceptsTypedToken(t *testing.T) { + model := loginModel{} + updated, _ := model.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("abc")}) + got := updated.(loginModel) + if got.token != "abc" { + t.Fatalf("token = %q, want %q", got.token, "abc") + } +} + +func TestPromptOutputPrefersStderr(t *testing.T) { + stdout := &bytes.Buffer{} + stderr := &bytes.Buffer{} + + if got := promptOutput(stdout, stderr); got != stderr { + t.Fatal("promptOutput() did not prefer stderr") + } +} + +func TestPromptOutputFallsBackToStdout(t *testing.T) { + stdout := &bytes.Buffer{} + + if got := promptOutput(stdout, nil); got != stdout { + t.Fatal("promptOutput() did not fall back to stdout") + } +}