From 0e1e47140863b3e0a2a4717bed4972989db8d368 Mon Sep 17 00:00:00 2001 From: Josef Karasek Date: Tue, 16 Jun 2026 10:30:30 +0200 Subject: [PATCH 01/11] Use libraries bubbletea + lipgloss for `list clusters` Signed-off-by: Josef Karasek --- .gitignore | 1 + README.md | 82 ++++++++++++++++ cmd/kedify/main.go | 11 +++ go.mod | 29 ++++++ go.sum | 47 ++++++++++ internal/api/client.go | 100 ++++++++++++++++++++ internal/cli/login.go | 41 ++++++++ internal/cli/output.go | 11 +++ internal/cli/run.go | 190 ++++++++++++++++++++++++++++++++++++++ internal/config/config.go | 83 +++++++++++++++++ internal/output/output.go | 36 ++++++++ internal/tui/login.go | 132 ++++++++++++++++++++++++++ 12 files changed, 763 insertions(+) create mode 100644 cmd/kedify/main.go create mode 100644 go.mod create mode 100644 go.sum create mode 100644 internal/api/client.go create mode 100644 internal/cli/login.go create mode 100644 internal/cli/output.go create mode 100644 internal/cli/run.go create mode 100644 internal/config/config.go create mode 100644 internal/output/output.go create mode 100644 internal/tui/login.go 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/README.md b/README.md index e69de29..5054029 100644 --- a/README.md +++ b/README.md @@ -0,0 +1,82 @@ +# Kedify CLI + +This repository contains an experimental `kedify` CLI built with `bubbletea` and `lipgloss` for the interactive login flow. + +## Current Features + +- `kedify login` + Reads a Kedify API token and stores it in `~/.config/kedify/credentials.json`. +- 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`. +- `kedify list clusters` + Calls the Kedify API and transparently reads all pages before printing the final cluster list. +- Output formatting + `kedify list clusters` supports `-o` and `--output` with `json` or `yaml`. + +## 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 +``` + +Piped login: + +```bash +printf '%s\n' "$KEDIFY_TOKEN" | ./bin/kedify login +``` + +Credentials are stored in: + +```text +~/.config/kedify/credentials.json +``` + +## Usage + +Show help: + +```bash +./bin/kedify --help +``` + +List clusters as JSON: + +```bash +./bin/kedify list clusters +``` + +List clusters as YAML: + +```bash +./bin/kedify list clusters -o yaml +``` + +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 +``` 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..37b4093 --- /dev/null +++ b/go.mod @@ -0,0 +1,29 @@ +module github.com/kedify/cli + +go 1.25.7 + +require ( + github.com/charmbracelet/bubbletea v1.3.10 + github.com/charmbracelet/lipgloss v1.1.0 + gopkg.in/yaml.v3 v3.0.1 +) + +require ( + github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect + github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect + github.com/charmbracelet/x/ansi v0.10.1 // indirect + github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd // indirect + github.com/charmbracelet/x/term v0.2.1 // indirect + github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect + github.com/lucasb-eyer/go-colorful v1.2.0 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/mattn/go-localereader v0.0.1 // indirect + github.com/mattn/go-runewidth v0.0.16 // 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.36.0 // indirect + golang.org/x/text v0.3.8 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..75a268f --- /dev/null +++ b/go.sum @@ -0,0 +1,47 @@ +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.2.3-0.20250311203215-f60798e515dc h1:4pZI35227imm7yK2bGPcfpFEmuY1gc2YSTShr4iJBfs= +github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc/go.mod h1:X4/0JoqgTIPSFcRA/P6INZzIuyqdFY5rm8tb41s9okk= +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.10.1 h1:rL3Koar5XvX0pHGfovN03f5cxLbCF2YvLeyz7D2jVDQ= +github.com/charmbracelet/x/ansi v0.10.1/go.mod h1:3RQDQ6lDnROptfpWuUVIUG64bD2g2BgntdxH0Ya5TeE= +github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd h1:vy0GVL4jeHEwG5YOXDmi86oYw2yuYUGqz6a8sLwg0X8= +github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs= +github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ= +github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg= +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/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= +github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +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.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc= +github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= +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/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= +github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= +github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= +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= +golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561 h1:MDc5xs78ZrZr3HMQugiXOAkSZtfTpbJLDr/lwfgO53E= +golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561/go.mod h1:cyybsKvd6eL0RnXn6p/Grxp8F5bW7iYuBgsNCOHpMYE= +golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.36.0 h1:KVRy2GtZBrk1cBYA7MKu5bEZFxQk4NIDV6RLVcC8o0k= +golang.org/x/sys v0.36.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/text v0.3.8 h1:nAL+RVCQ9uMn3vJZbV+MRnydTJFPf8qqY42YiA6MrqY= +golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ= +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..92a0990 --- /dev/null +++ b/internal/api/client.go @@ -0,0 +1,100 @@ +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 + } + + page = response.PageInfo.Page + 1 + if page <= 1 { + page++ + } + } + + return allItems, 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) + } + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return clustersResponse{}, fmt.Errorf("read response: %w", 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 +} diff --git a/internal/cli/login.go b/internal/cli/login.go new file mode 100644 index 0000000..0db2f30 --- /dev/null +++ b/internal/cli/login.go @@ -0,0 +1,41 @@ +package cli + +import ( + "fmt" + "io" + + "github.com/kedify/cli/internal/config" + "github.com/kedify/cli/internal/tui" +) + +func runLogin(ctx *context, args []string) error { + if len(args) > 0 { + switch args[0] { + case "--help", "-h", "help": + writeLoginHelp(ctx.stdout) + return nil + default: + return fmt.Errorf("unexpected arguments: %v", args) + } + } + + token, err := tui.ReadSecretOrPipe(ctx.stdin, ctx.stdout, ctx.stderr) + if err != nil { + return err + } + + if err := config.WriteCredentials(config.Credentials{Token: token}); err != nil { + return err + } + + _, err = fmt.Fprintln(ctx.stdout, "Credentials stored in ~/.config/kedify/credentials.json") + return err +} + +func writeLoginHelp(w io.Writer) { + _, _ = fmt.Fprint(w, `Usage: kedify login + +Read an auth token from stdin and store it locally. +Generate a token at https://dashboard.dev.kedify.io/api-keys. +`) +} 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..d9b36c9 --- /dev/null +++ b/internal/cli/run.go @@ -0,0 +1,190 @@ +package cli + +import ( + "errors" + "flag" + "fmt" + "io" + "strings" + + "github.com/kedify/cli/internal/api" + "github.com/kedify/cli/internal/config" +) + +const defaultAPIURL = "https://api.dev.kedify.io/v1" + +var errHelpShown = errors.New("help shown") + +type context struct { + stdin io.Reader + stdout io.Writer + stderr io.Writer + apiURL string + client *api.Client +} + +func Run(args []string, stdin io.Reader, stdout, stderr io.Writer) int { + ctx := &context{ + stdin: stdin, + stdout: stdout, + stderr: stderr, + client: api.NewClient(), + } + + if err := run(ctx, args); err != nil { + if errors.Is(err, errHelpShown) { + return 0 + } + _, _ = fmt.Fprintf(stderr, "kedify: error: %v\n", err) + return 1 + } + + return 0 +} + +func run(ctx *context, args []string) error { + if len(args) == 0 { + writeRootHelp(ctx.stdout) + return nil + } + + apiURL, remaining, err := parseGlobalFlags(args, ctx.stdout) + if err != nil { + return err + } + ctx.apiURL = apiURL + + if len(remaining) == 0 { + writeRootHelp(ctx.stdout) + return nil + } + + switch remaining[0] { + case "help", "--help", "-h": + writeRootHelp(ctx.stdout) + return nil + case "login": + return runLogin(ctx, remaining[1:]) + case "list": + return runList(ctx, remaining[1:]) + default: + return fmt.Errorf("unknown command %q", remaining[0]) + } +} + +func parseGlobalFlags(args []string, stdout io.Writer) (string, []string, error) { + apiURL := defaultAPIURL + + for i := 0; i < len(args); i++ { + arg := args[i] + switch { + case arg == "--help" || arg == "-h": + writeRootHelp(stdout) + return "", nil, errHelpShown + case arg == "--apiurl": + if i+1 >= len(args) { + return "", nil, errors.New("missing value for --apiurl") + } + apiURL = args[i+1] + i++ + case strings.HasPrefix(arg, "--apiurl="): + apiURL = strings.TrimPrefix(arg, "--apiurl=") + case strings.HasPrefix(arg, "-"): + return "", nil, fmt.Errorf("unknown flag %q", arg) + default: + if envURL := config.APIURLFromEnv(); envURL != "" && apiURL == defaultAPIURL { + apiURL = envURL + } + return apiURL, args[i:], nil + } + } + + if envURL := config.APIURLFromEnv(); envURL != "" && apiURL == defaultAPIURL { + apiURL = envURL + } + + return apiURL, nil, nil +} + +func runList(ctx *context, args []string) error { + if len(args) == 0 { + writeListHelp(ctx.stdout) + return nil + } + + switch args[0] { + case "clusters": + return runListClusters(ctx, args[1:]) + case "help", "--help", "-h": + writeListHelp(ctx.stdout) + return nil + default: + return fmt.Errorf("unknown list subcommand %q", args[0]) + } +} + +func runListClusters(ctx *context, args []string) error { + flags := flag.NewFlagSet("clusters", flag.ContinueOnError) + flags.SetOutput(ctx.stderr) + + outputFormat := flags.String("output", "json", "Output format.") + flags.StringVar(outputFormat, "o", "json", "Output format.") + flags.Usage = func() { + writeListClustersHelp(ctx.stdout) + } + + if err := flags.Parse(args); err != nil { + if errors.Is(err, flag.ErrHelp) { + return nil + } + return err + } + + if flags.NArg() > 0 { + return fmt.Errorf("unexpected arguments: %s", strings.Join(flags.Args(), " ")) + } + + creds, err := config.ReadCredentials() + if err != nil { + return err + } + + clusters, err := ctx.client.ListClusters(ctx.apiURL, creds.Token) + if err != nil { + return err + } + + return writeOutput(ctx.stdout, clusters, *outputFormat) +} + +func writeRootHelp(w io.Writer) { + _, _ = fmt.Fprint(w, `Usage: kedify [flags] + +Kedify command line interface. + +Flags: + -h, --help Show help. + --apiurl string Base URL for the Kedify API (default "https://api.dev.kedify.io/v1") + +Commands: + login Read an auth token from stdin and store it locally. + list clusters List clusters. +`) +} + +func writeListHelp(w io.Writer) { + _, _ = fmt.Fprint(w, `Usage: kedify list + +Commands: + clusters List clusters. +`) +} + +func writeListClustersHelp(w io.Writer) { + _, _ = fmt.Fprint(w, `Usage: kedify list clusters [flags] + +Flags: + -h, --help Show help. + -o, --output string Output format (json|yaml) (default "json") +`) +} diff --git a/internal/config/config.go b/internal/config/config.go new file mode 100644 index 0000000..625d19d --- /dev/null +++ b/internal/config/config.go @@ -0,0 +1,83 @@ +package config + +import ( + "encoding/json" + "errors" + "fmt" + "os" + "path/filepath" + "strings" +) + +const ( + credentialsDir = ".config/kedify" + credentialsFile = "credentials.json" + fileModeDir = 0o700 + fileModeCreds = 0o600 +) + +type Credentials struct { + Token string `json:"token"` +} + +func WriteCredentials(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 ReadCredentials() (Credentials, error) { + path, err := credentialsPath() + if err != nil { + return Credentials{}, err + } + + data, err := os.ReadFile(path) + 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 APIURLFromEnv() string { + return strings.TrimSpace(os.Getenv("KEDIFY_API_URL")) +} + +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 +} diff --git a/internal/output/output.go b/internal/output/output.go new file mode 100644 index 0000000..6ba0496 --- /dev/null +++ b/internal/output/output.go @@ -0,0 +1,36 @@ +package output + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + + "gopkg.in/yaml.v3" +) + +func Write(w io.Writer, value any, format string) error { + var ( + data []byte + err error + ) + + switch format { + 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') + } + + _, err = w.Write(data) + return err +} diff --git a/internal/tui/login.go b/internal/tui/login.go new file mode 100644 index 0000000..8e9ea22 --- /dev/null +++ b/internal/tui/login.go @@ -0,0 +1,132 @@ +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" + +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, _ io.Writer) (string, error) { + if file, ok := stdin.(*os.File); ok && isInteractive(file) { + model := loginModel{} + program := tea.NewProgram(model, tea.WithInput(file), tea.WithOutput(stdout)) + 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 (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 +} From 48a266d181922c2dc8ed17fa1a5c078131da0be7 Mon Sep 17 00:00:00 2001 From: Josef Karasek Date: Tue, 16 Jun 2026 10:31:30 +0200 Subject: [PATCH 02/11] update deps Signed-off-by: Josef Karasek --- go.mod | 20 +++++++++++--------- go.sum | 23 +++++++++++++++++++++++ 2 files changed, 34 insertions(+), 9 deletions(-) diff --git a/go.mod b/go.mod index 37b4093..85287b9 100644 --- a/go.mod +++ b/go.mod @@ -10,20 +10,22 @@ require ( require ( github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect - github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect - github.com/charmbracelet/x/ansi v0.10.1 // indirect - github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd // indirect - github.com/charmbracelet/x/term v0.2.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/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect - github.com/lucasb-eyer/go-colorful v1.2.0 // indirect - github.com/mattn/go-isatty v0.0.20 // 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.16 // 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.36.0 // indirect - golang.org/x/text v0.3.8 // 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 index 75a268f..7d84145 100644 --- a/go.sum +++ b/go.sum @@ -4,24 +4,42 @@ github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlv github.com/charmbracelet/bubbletea v1.3.10/go.mod h1:ORQfo0fk8U+po9VaNvnV95UPWA1BitP1E0N6xJPlHr4= github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc h1:4pZI35227imm7yK2bGPcfpFEmuY1gc2YSTShr4iJBfs= github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc/go.mod h1:X4/0JoqgTIPSFcRA/P6INZzIuyqdFY5rm8tb41s9okk= +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.10.1 h1:rL3Koar5XvX0pHGfovN03f5cxLbCF2YvLeyz7D2jVDQ= github.com/charmbracelet/x/ansi v0.10.1/go.mod h1:3RQDQ6lDnROptfpWuUVIUG64bD2g2BgntdxH0Ya5TeE= +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.13-0.20250311204145-2c3ea96c31dd h1:vy0GVL4jeHEwG5YOXDmi86oYw2yuYUGqz6a8sLwg0X8= github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs= +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.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ= github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg= +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/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/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= +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.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +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.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc= github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= +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= @@ -35,12 +53,17 @@ github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavM github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561 h1:MDc5xs78ZrZr3HMQugiXOAkSZtfTpbJLDr/lwfgO53E= golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561/go.mod h1:cyybsKvd6eL0RnXn6p/Grxp8F5bW7iYuBgsNCOHpMYE= +golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI= golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.36.0 h1:KVRy2GtZBrk1cBYA7MKu5bEZFxQk4NIDV6RLVcC8o0k= golang.org/x/sys v0.36.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +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.3.8 h1:nAL+RVCQ9uMn3vJZbV+MRnydTJFPf8qqY42YiA6MrqY= golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ= +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= From 611c210419c65e3561e2bd233b339c1f5b4069ac Mon Sep 17 00:00:00 2001 From: Josef Karasek Date: Tue, 16 Jun 2026 11:20:36 +0200 Subject: [PATCH 03/11] interactive get cluster cmd Signed-off-by: Josef Karasek --- README.md | 16 +++- internal/cli/get.go | 95 ++++++++++++++++++++ internal/cli/run.go | 28 ++++++ internal/tui/cluster_picker.go | 159 +++++++++++++++++++++++++++++++++ 4 files changed, 297 insertions(+), 1 deletion(-) create mode 100644 internal/cli/get.go create mode 100644 internal/tui/cluster_picker.go diff --git a/README.md b/README.md index 5054029..8b7d64e 100644 --- a/README.md +++ b/README.md @@ -12,8 +12,10 @@ This repository contains an experimental `kedify` CLI built with `bubbletea` and You can also provide a token via `stdin`. - `kedify list clusters` Calls the Kedify API and transparently reads all pages before printing the final cluster list. +- `kedify get cluster [name]` + Prints one cluster by name or id, and shows an interactive picker when no name is provided. - Output formatting - `kedify list clusters` supports `-o` and `--output` with `json` or `yaml`. + `kedify list clusters` and `kedify get cluster` support `-o` and `--output` with `json` or `yaml`. ## Build @@ -69,6 +71,18 @@ List clusters as YAML: ./bin/kedify list clusters -o yaml ``` +Get a cluster by name: + +```bash +./bin/kedify get cluster my-cluster +``` + +Pick a cluster interactively: + +```bash +./bin/kedify get cluster +``` + Override the API URL: ```bash diff --git a/internal/cli/get.go b/internal/cli/get.go new file mode 100644 index 0000000..6dc51b5 --- /dev/null +++ b/internal/cli/get.go @@ -0,0 +1,95 @@ +package cli + +import ( + "errors" + "flag" + "fmt" + "io" + "strings" + + "github.com/kedify/cli/internal/config" + "github.com/kedify/cli/internal/tui" +) + +func runGetCluster(ctx *context, args []string) error { + flags := flag.NewFlagSet("cluster", flag.ContinueOnError) + flags.SetOutput(ctx.stderr) + + outputFormat := flags.String("output", "json", "Output format.") + flags.StringVar(outputFormat, "o", "json", "Output format.") + flags.Usage = func() { + writeGetClusterHelp(ctx.stdout) + } + + if err := flags.Parse(args); err != nil { + if errors.Is(err, flag.ErrHelp) { + return nil + } + return err + } + + if flags.NArg() > 1 { + return fmt.Errorf("unexpected arguments: %s", strings.Join(flags.Args(), " ")) + } + + creds, err := config.ReadCredentials() + if err != nil { + return err + } + + clusters, err := ctx.client.ListClusters(ctx.apiURL, creds.Token) + if err != nil { + return err + } + + var cluster map[string]any + if flags.NArg() == 1 { + cluster, err = findCluster(clusters, flags.Arg(0)) + if err != nil { + return err + } + } else { + cluster, err = tui.SelectClusterOrFail(ctx.stdin, ctx.stdout, clusters) + if err != nil { + return err + } + } + + return writeOutput(ctx.stdout, cluster, *outputFormat) +} + +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 clusterString(cluster map[string]any, key string) string { + value, ok := cluster[key] + if !ok { + return "" + } + + text, ok := value.(string) + if !ok { + return "" + } + + return text +} + +func writeGetClusterHelp(w io.Writer) { + _, _ = fmt.Fprint(w, `Usage: kedify get cluster [name] [flags] + +Get a cluster by name or id. +If no name is provided, an interactive picker is shown. + +Flags: + -h, --help Show help. + -o, --output string Output format (json|yaml) (default "json") +`) +} diff --git a/internal/cli/run.go b/internal/cli/run.go index d9b36c9..112c483 100644 --- a/internal/cli/run.go +++ b/internal/cli/run.go @@ -63,6 +63,8 @@ func run(ctx *context, args []string) error { case "help", "--help", "-h": writeRootHelp(ctx.stdout) return nil + case "get": + return runGet(ctx, remaining[1:]) case "login": return runLogin(ctx, remaining[1:]) case "list": @@ -123,6 +125,23 @@ func runList(ctx *context, args []string) error { } } +func runGet(ctx *context, args []string) error { + if len(args) == 0 { + writeGetHelp(ctx.stdout) + return nil + } + + switch args[0] { + case "cluster": + return runGetCluster(ctx, args[1:]) + case "help", "--help", "-h": + writeGetHelp(ctx.stdout) + return nil + default: + return fmt.Errorf("unknown get subcommand %q", args[0]) + } +} + func runListClusters(ctx *context, args []string) error { flags := flag.NewFlagSet("clusters", flag.ContinueOnError) flags.SetOutput(ctx.stderr) @@ -167,11 +186,20 @@ Flags: --apiurl string Base URL for the Kedify API (default "https://api.dev.kedify.io/v1") Commands: + get cluster Get a cluster. login Read an auth token from stdin and store it locally. list clusters List clusters. `) } +func writeGetHelp(w io.Writer) { + _, _ = fmt.Fprint(w, `Usage: kedify get + +Commands: + cluster Get a cluster. +`) +} + func writeListHelp(w io.Writer) { _, _ = fmt.Fprint(w, `Usage: kedify list diff --git a/internal/tui/cluster_picker.go b/internal/tui/cluster_picker.go new file mode 100644 index 0000000..06712c2 --- /dev/null +++ b/internal/tui/cluster_picker.go @@ -0,0 +1,159 @@ +package tui + +import ( + "errors" + "fmt" + "io" + "os" + "strings" + + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" +) + +var ( + selectedStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("86")).Bold(true) + metaStyle = 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, 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(stdout)) + 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(titleStyle.Render("Select a Cluster")) + b.WriteString("\n") + b.WriteString(hintStyle.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 = selectedStyle + } + + 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(metaStyle.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 +} From 38531d5fcbce6b9dd7e61c9bf3d0c8c3b566e420 Mon Sep 17 00:00:00 2001 From: Josef Karasek Date: Tue, 16 Jun 2026 13:12:58 +0200 Subject: [PATCH 04/11] add text output Signed-off-by: Josef Karasek --- README.md | 43 ++++- go.mod | 6 +- go.sum | 45 ++--- internal/api/client_test.go | 66 ++++++++ internal/cli/auth.go | 14 ++ internal/cli/commands_test.go | 253 ++++++++++++++++++++++++++++ internal/cli/deps.go | 18 ++ internal/cli/get.go | 56 ++---- internal/cli/list.go | 15 ++ internal/cli/login.go | 43 ++--- internal/cli/run.go | 252 +++++++-------------------- internal/config/config.go | 70 +++++++- internal/config/config_test.go | 101 +++++++++++ internal/output/output.go | 170 +++++++++++++++++++ internal/output/output_test.go | 102 +++++++++++ internal/tui/cluster_picker_test.go | 30 ++++ internal/tui/login_test.go | 37 ++++ 17 files changed, 1029 insertions(+), 292 deletions(-) create mode 100644 internal/api/client_test.go create mode 100644 internal/cli/auth.go create mode 100644 internal/cli/commands_test.go create mode 100644 internal/cli/deps.go create mode 100644 internal/cli/list.go create mode 100644 internal/config/config_test.go create mode 100644 internal/output/output_test.go create mode 100644 internal/tui/cluster_picker_test.go create mode 100644 internal/tui/login_test.go diff --git a/README.md b/README.md index 8b7d64e..a622cca 100644 --- a/README.md +++ b/README.md @@ -1,21 +1,23 @@ # Kedify CLI -This repository contains an experimental `kedify` CLI built with `bubbletea` and `lipgloss` for the interactive login flow. +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 `~/.config/kedify/credentials.json`. + 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]` 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 `json` or `yaml`. + `kedify list clusters` and `kedify get cluster` support `-o` and `--output` with `text`, `json`, or `yaml`. `text` is the default. ## Build @@ -39,6 +41,18 @@ Interactive login: ./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 @@ -48,7 +62,8 @@ printf '%s\n' "$KEDIFY_TOKEN" | ./bin/kedify login Credentials are stored in: ```text -~/.config/kedify/credentials.json +OS credential store when available +~/.config/kedify/credentials.json as fallback ``` ## Usage @@ -59,7 +74,7 @@ Show help: ./bin/kedify --help ``` -List clusters as JSON: +List clusters in the default human-readable text format: ```bash ./bin/kedify list clusters @@ -77,6 +92,12 @@ Get a cluster by name: ./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 @@ -94,3 +115,15 @@ 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/go.mod b/go.mod index 85287b9..b6ef4d2 100644 --- a/go.mod +++ b/go.mod @@ -1,10 +1,12 @@ module github.com/kedify/cli -go 1.25.7 +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/zalando/go-keyring v0.2.8 gopkg.in/yaml.v3 v3.0.1 ) @@ -16,7 +18,9 @@ require ( 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 diff --git a/go.sum b/go.sum index 7d84145..55a4a9d 100644 --- a/go.sum +++ b/go.sum @@ -1,43 +1,43 @@ +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.2.3-0.20250311203215-f60798e515dc h1:4pZI35227imm7yK2bGPcfpFEmuY1gc2YSTShr4iJBfs= -github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc/go.mod h1:X4/0JoqgTIPSFcRA/P6INZzIuyqdFY5rm8tb41s9okk= 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.10.1 h1:rL3Koar5XvX0pHGfovN03f5cxLbCF2YvLeyz7D2jVDQ= -github.com/charmbracelet/x/ansi v0.10.1/go.mod h1:3RQDQ6lDnROptfpWuUVIUG64bD2g2BgntdxH0Ya5TeE= 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.13-0.20250311204145-2c3ea96c31dd h1:vy0GVL4jeHEwG5YOXDmi86oYw2yuYUGqz6a8sLwg0X8= -github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs= 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.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ= -github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg= 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/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= -github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= +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/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.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= -github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= 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.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc= -github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= 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= @@ -46,22 +46,23 @@ github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELU 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/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= +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= -golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561 h1:MDc5xs78ZrZr3HMQugiXOAkSZtfTpbJLDr/lwfgO53E= -golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561/go.mod h1:cyybsKvd6eL0RnXn6p/Grxp8F5bW7iYuBgsNCOHpMYE= +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.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.36.0 h1:KVRy2GtZBrk1cBYA7MKu5bEZFxQk4NIDV6RLVcC8o0k= -golang.org/x/sys v0.36.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= 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.3.8 h1:nAL+RVCQ9uMn3vJZbV+MRnydTJFPf8qqY42YiA6MrqY= -golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ= 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= diff --git a/internal/api/client_test.go b/internal/api/client_test.go new file mode 100644 index 0000000..60f4be4 --- /dev/null +++ b/internal/api/client_test.go @@ -0,0 +1,66 @@ +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") + } +} diff --git a/internal/cli/auth.go b/internal/cli/auth.go new file mode 100644 index 0000000..9c32766 --- /dev/null +++ b/internal/cli/auth.go @@ -0,0 +1,14 @@ +package cli + +func resolveToken(ctx *context) (string, error) { + if ctx.token != "" { + return ctx.token, nil + } + + creds, err := ctx.credentials.ReadCredentials() + if err != nil { + return "", err + } + + return creds.Token, nil +} diff --git a/internal/cli/commands_test.go b/internal/cli/commands_test.go new file mode 100644 index 0000000..51404eb --- /dev/null +++ b/internal/cli/commands_test.go @@ -0,0 +1,253 @@ +package cli + +import ( + "bytes" + "io" + "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 + err error + lastURL string + lastToken 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 TestLoginCmdRunStoresCredentials(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) { + 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 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 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 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, _ []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 TestGetClusterCmdRunUsesSelectorWhenNameMissing(t *testing.T) { + store := &fakeCredentialsStore{creds: credentials{Token: "stored-token"}} + service := &fakeClusterService{ + clusters: []map[string]any{{"id": "1", "name": "alpha"}}, + } + selected := map[string]any{"id": "1", "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, 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") + } +} + +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 index 6dc51b5..1ee1b39 100644 --- a/internal/cli/get.go +++ b/internal/cli/get.go @@ -1,61 +1,39 @@ package cli import ( - "errors" - "flag" "fmt" - "io" - "strings" - - "github.com/kedify/cli/internal/config" - "github.com/kedify/cli/internal/tui" ) -func runGetCluster(ctx *context, args []string) error { - flags := flag.NewFlagSet("cluster", flag.ContinueOnError) - flags.SetOutput(ctx.stderr) - - outputFormat := flags.String("output", "json", "Output format.") - flags.StringVar(outputFormat, "o", "json", "Output format.") - flags.Usage = func() { - writeGetClusterHelp(ctx.stdout) - } - - if err := flags.Parse(args); err != nil { - if errors.Is(err, flag.ErrHelp) { - return nil - } - return err - } - - if flags.NArg() > 1 { - return fmt.Errorf("unexpected arguments: %s", strings.Join(flags.Args(), " ")) - } +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"` +} - creds, err := config.ReadCredentials() +func (c *GetClusterCmd) Run(ctx *context) error { + token, err := resolveToken(ctx) if err != nil { return err } - clusters, err := ctx.client.ListClusters(ctx.apiURL, creds.Token) + clusters, err := ctx.client.ListClusters(ctx.apiURL, token) if err != nil { return err } var cluster map[string]any - if flags.NArg() == 1 { - cluster, err = findCluster(clusters, flags.Arg(0)) + if c.Name != "" { + cluster, err = findCluster(clusters, c.Name) if err != nil { return err } } else { - cluster, err = tui.SelectClusterOrFail(ctx.stdin, ctx.stdout, clusters) + cluster, err = ctx.selectCluster(ctx.stdin, ctx.stdout, clusters) if err != nil { return err } } - return writeOutput(ctx.stdout, cluster, *outputFormat) + return ctx.writeOutput(ctx.stdout, cluster, c.Output) } func findCluster(clusters []map[string]any, query string) (map[string]any, error) { @@ -81,15 +59,3 @@ func clusterString(cluster map[string]any, key string) string { return text } - -func writeGetClusterHelp(w io.Writer) { - _, _ = fmt.Fprint(w, `Usage: kedify get cluster [name] [flags] - -Get a cluster by name or id. -If no name is provided, an interactive picker is shown. - -Flags: - -h, --help Show help. - -o, --output string Output format (json|yaml) (default "json") -`) -} 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 index 0db2f30..fcc66ba 100644 --- a/internal/cli/login.go +++ b/internal/cli/login.go @@ -2,40 +2,29 @@ package cli import ( "fmt" - "io" - - "github.com/kedify/cli/internal/config" - "github.com/kedify/cli/internal/tui" ) -func runLogin(ctx *context, args []string) error { - if len(args) > 0 { - switch args[0] { - case "--help", "-h", "help": - writeLoginHelp(ctx.stdout) - return nil - default: - return fmt.Errorf("unexpected arguments: %v", args) - } - } +type LoginCmd struct { + Token string `arg:"" optional:"" name:"token" help:"Token to store without prompting."` +} - token, err := tui.ReadSecretOrPipe(ctx.stdin, ctx.stdout, ctx.stderr) - if err != nil { - return err +func (c *LoginCmd) Run(ctx *context) error { + token := c.Token + if token == "" { + token = ctx.token + } + if token == "" { + var err error + token, err = ctx.readSecret(ctx.stdin, ctx.stdout, ctx.stderr) + if err != nil { + return err + } } - if err := config.WriteCredentials(config.Credentials{Token: token}); err != nil { + if err := ctx.credentials.WriteCredentials(credentials{Token: token}); err != nil { return err } - _, err = fmt.Fprintln(ctx.stdout, "Credentials stored in ~/.config/kedify/credentials.json") + _, err := fmt.Fprintln(ctx.stdout, "Credentials stored.") return err } - -func writeLoginHelp(w io.Writer) { - _, _ = fmt.Fprint(w, `Usage: kedify login - -Read an auth token from stdin and store it locally. -Generate a token at https://dashboard.dev.kedify.io/api-keys. -`) -} diff --git a/internal/cli/run.go b/internal/cli/run.go index 112c483..f77ed79 100644 --- a/internal/cli/run.go +++ b/internal/cli/run.go @@ -1,218 +1,96 @@ package cli import ( - "errors" - "flag" "fmt" "io" - "strings" + "github.com/alecthomas/kong" "github.com/kedify/cli/internal/api" - "github.com/kedify/cli/internal/config" + "github.com/kedify/cli/internal/tui" ) -const defaultAPIURL = "https://api.dev.kedify.io/v1" - -var errHelpShown = errors.New("help shown") - -type context struct { - stdin io.Reader - stdout io.Writer - stderr io.Writer - apiURL string - client *api.Client +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."` } -func Run(args []string, stdin io.Reader, stdout, stderr io.Writer) int { - ctx := &context{ - stdin: stdin, - stdout: stdout, - stderr: stderr, - client: api.NewClient(), - } - - if err := run(ctx, args); err != nil { - if errors.Is(err, errHelpShown) { - return 0 - } - _, _ = fmt.Fprintf(stderr, "kedify: error: %v\n", err) - return 1 - } - - return 0 +type GetCmd struct { + Cluster GetClusterCmd `cmd:"" help:"Get a cluster by name or id. If no name is provided, an interactive picker is shown."` } -func run(ctx *context, args []string) error { - if len(args) == 0 { - writeRootHelp(ctx.stdout) - return nil - } - - apiURL, remaining, err := parseGlobalFlags(args, ctx.stdout) - if err != nil { - return err - } - ctx.apiURL = apiURL - - if len(remaining) == 0 { - writeRootHelp(ctx.stdout) - return nil - } - - switch remaining[0] { - case "help", "--help", "-h": - writeRootHelp(ctx.stdout) - return nil - case "get": - return runGet(ctx, remaining[1:]) - case "login": - return runLogin(ctx, remaining[1:]) - case "list": - return runList(ctx, remaining[1:]) - default: - return fmt.Errorf("unknown command %q", remaining[0]) - } +type ListCmd struct { + Clusters ListClustersCmd `cmd:"" help:"List clusters."` } -func parseGlobalFlags(args []string, stdout io.Writer) (string, []string, error) { - apiURL := defaultAPIURL - - for i := 0; i < len(args); i++ { - arg := args[i] - switch { - case arg == "--help" || arg == "-h": - writeRootHelp(stdout) - return "", nil, errHelpShown - case arg == "--apiurl": - if i+1 >= len(args) { - return "", nil, errors.New("missing value for --apiurl") - } - apiURL = args[i+1] - i++ - case strings.HasPrefix(arg, "--apiurl="): - apiURL = strings.TrimPrefix(arg, "--apiurl=") - case strings.HasPrefix(arg, "-"): - return "", nil, fmt.Errorf("unknown flag %q", arg) - default: - if envURL := config.APIURLFromEnv(); envURL != "" && apiURL == defaultAPIURL { - apiURL = envURL - } - return apiURL, args[i:], nil - } - } - - if envURL := config.APIURLFromEnv(); envURL != "" && apiURL == defaultAPIURL { - apiURL = envURL - } - - return apiURL, nil, nil +type ListClustersCmd struct { + Output string `name:"output" short:"o" help:"Output format." enum:"text,json,yaml" default:"text"` } -func runList(ctx *context, args []string) error { - if len(args) == 0 { - writeListHelp(ctx.stdout) - return nil - } - - switch args[0] { - case "clusters": - return runListClusters(ctx, args[1:]) - case "help", "--help", "-h": - writeListHelp(ctx.stdout) - return nil - default: - return fmt.Errorf("unknown list subcommand %q", args[0]) - } +type credentialsStore interface { + ReadCredentials() (credentials, error) + WriteCredentials(credentials) error } -func runGet(ctx *context, args []string) error { - if len(args) == 0 { - writeGetHelp(ctx.stdout) - return nil - } - - switch args[0] { - case "cluster": - return runGetCluster(ctx, args[1:]) - case "help", "--help", "-h": - writeGetHelp(ctx.stdout) - return nil - default: - return fmt.Errorf("unknown get subcommand %q", args[0]) - } +type clusterService interface { + ListClusters(apiURL, token string) ([]map[string]any, error) } -func runListClusters(ctx *context, args []string) error { - flags := flag.NewFlagSet("clusters", flag.ContinueOnError) - flags.SetOutput(ctx.stderr) - - outputFormat := flags.String("output", "json", "Output format.") - flags.StringVar(outputFormat, "o", "json", "Output format.") - flags.Usage = func() { - writeListClustersHelp(ctx.stdout) - } - - if err := flags.Parse(args); err != nil { - if errors.Is(err, flag.ErrHelp) { - return nil - } - return err - } +type credentials struct { + Token string +} - if flags.NArg() > 0 { - return fmt.Errorf("unexpected arguments: %s", strings.Join(flags.Args(), " ")) - } +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, []map[string]any) (map[string]any, error) + writeOutput func(io.Writer, any, string) error +} - creds, err := config.ReadCredentials() +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 { - return err + _, _ = fmt.Fprintf(stderr, "kedify: error: %v\n", err) + return 1 } - clusters, err := ctx.client.ListClusters(ctx.apiURL, creds.Token) + kctx, err := parser.Parse(args) if err != nil { - return err + _, _ = fmt.Fprintf(stderr, "kedify: error: %v\n", err) + return 1 } - return writeOutput(ctx.stdout, clusters, *outputFormat) -} - -func writeRootHelp(w io.Writer) { - _, _ = fmt.Fprint(w, `Usage: kedify [flags] - -Kedify command line interface. - -Flags: - -h, --help Show help. - --apiurl string Base URL for the Kedify API (default "https://api.dev.kedify.io/v1") - -Commands: - get cluster Get a cluster. - login Read an auth token from stdin and store it locally. - list clusters List clusters. -`) -} - -func writeGetHelp(w io.Writer) { - _, _ = fmt.Fprint(w, `Usage: kedify get - -Commands: - cluster Get a cluster. -`) -} - -func writeListHelp(w io.Writer) { - _, _ = fmt.Fprint(w, `Usage: kedify list - -Commands: - clusters List clusters. -`) -} - -func writeListClustersHelp(w io.Writer) { - _, _ = fmt.Fprint(w, `Usage: kedify list clusters [flags] + 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 + } -Flags: - -h, --help Show help. - -o, --output string Output format (json|yaml) (default "json") -`) + return 0 } diff --git a/internal/config/config.go b/internal/config/config.go index 625d19d..151b7e6 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -7,6 +7,8 @@ import ( "os" "path/filepath" "strings" + + "github.com/zalando/go-keyring" ) const ( @@ -14,13 +16,52 @@ const ( 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 @@ -43,7 +84,7 @@ func WriteCredentials(creds Credentials) error { return nil } -func ReadCredentials() (Credentials, error) { +func readCredentialsFile() (Credentials, error) { path, err := credentialsPath() if err != nil { return Credentials{}, err @@ -69,10 +110,6 @@ func ReadCredentials() (Credentials, error) { return creds, nil } -func APIURLFromEnv() string { - return strings.TrimSpace(os.Getenv("KEDIFY_API_URL")) -} - func credentialsPath() (string, error) { homeDir, err := os.UserHomeDir() if err != nil { @@ -81,3 +118,26 @@ func credentialsPath() (string, error) { 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 index 6ba0496..5a154da 100644 --- a/internal/output/output.go +++ b/internal/output/output.go @@ -5,6 +5,8 @@ import ( "encoding/json" "fmt" "io" + "strings" + "time" "gopkg.in/yaml.v3" ) @@ -16,6 +18,8 @@ func Write(w io.Writer, value any, format string) error { ) switch format { + case "text": + data, err = renderText(value) case "json": data, err = json.MarshalIndent(value, "", " ") case "yaml": @@ -34,3 +38,169 @@ func Write(w io.Writer, value any, format string) error { _, err = w.Write(data) return err } + +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", "AGENT VERSION", "KEDA VERSION", "AGENT STATUS", "KEDA STATUS", "AGE"}) + + for _, cluster := range clusters { + rows = append(rows, []string{ + fallbackClusterValue(cluster, "name", ""), + 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..716f953 --- /dev/null +++ b/internal/output/output_test.go @@ -0,0 +1,102 @@ +package output + +import ( + "bytes" + "strings" + "testing" +) + +func TestWriteTextClusterList(t *testing.T) { + var out bytes.Buffer + value := []map[string]any{ + { + "name": "alpha", + "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", + "AGENT VERSION", + "KEDA VERSION", + "AGENT STATUS", + "KEDA STATUS", + "AGE", + "alpha", + "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 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_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_test.go b/internal/tui/login_test.go new file mode 100644 index 0000000..dfaf760 --- /dev/null +++ b/internal/tui/login_test.go @@ -0,0 +1,37 @@ +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") + } +} From 9185247c9fcaac9ec1db8c0b31fbaf86c20a896a Mon Sep 17 00:00:00 2001 From: Josef Karasek Date: Tue, 16 Jun 2026 14:28:40 +0200 Subject: [PATCH 05/11] print version Signed-off-by: Josef Karasek --- internal/api/client.go | 34 ++++++++++++++++++ internal/api/client_test.go | 24 +++++++++++++ internal/cli/commands_test.go | 63 ++++++++++++++++++++++++++++++++-- internal/cli/get.go | 29 +++++++++++++--- internal/cli/run.go | 1 + internal/output/output.go | 3 +- internal/output/output_test.go | 5 ++- 7 files changed, 151 insertions(+), 8 deletions(-) diff --git a/internal/api/client.go b/internal/api/client.go index 92a0990..3d7442e 100644 --- a/internal/api/client.go +++ b/internal/api/client.go @@ -56,6 +56,40 @@ func (c *Client) ListClusters(apiURL, token string) ([]map[string]any, error) { 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) + } + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("read response: %w", 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 { diff --git a/internal/api/client_test.go b/internal/api/client_test.go index 60f4be4..4bb0e62 100644 --- a/internal/api/client_test.go +++ b/internal/api/client_test.go @@ -64,3 +64,27 @@ func TestListClustersReturnsHTTPError(t *testing.T) { 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/commands_test.go b/internal/cli/commands_test.go index 51404eb..5f3d29f 100644 --- a/internal/cli/commands_test.go +++ b/internal/cli/commands_test.go @@ -30,9 +30,11 @@ func (f *fakeCredentialsStore) WriteCredentials(creds credentials) error { 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) { @@ -44,6 +46,16 @@ func (f *fakeClusterService) ListClusters(apiURL, token string) ([]map[string]an 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{} ctx := &context{ @@ -172,12 +184,56 @@ func TestGetClusterCmdRunFindsNamedCluster(t *testing.T) { } } +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, _ []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": "1", "name": "alpha"}}, + 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": "1", "name": "alpha"} + selected := map[string]any{"id": "fc6af0dc-685b-4055-805d-0d3e0ead1596", "name": "alpha"} var gotValue any ctx := &context{ @@ -214,6 +270,9 @@ func TestGetClusterCmdRunUsesSelectorWhenNameMissing(t *testing.T) { 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) { diff --git a/internal/cli/get.go b/internal/cli/get.go index 1ee1b39..74478f9 100644 --- a/internal/cli/get.go +++ b/internal/cli/get.go @@ -2,8 +2,11 @@ package cli import ( "fmt" + "regexp" ) +var uuidPattern = regexp.MustCompile(`(?i)^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$`) + 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"` @@ -22,15 +25,29 @@ func (c *GetClusterCmd) Run(ctx *context) error { var cluster map[string]any if c.Name != "" { - cluster, err = findCluster(clusters, c.Name) - if err != nil { - return err + 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 { - cluster, err = ctx.selectCluster(ctx.stdin, ctx.stdout, clusters) + selectedCluster, err := ctx.selectCluster(ctx.stdin, ctx.stdout, 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) @@ -46,6 +63,10 @@ func findCluster(clusters []map[string]any, query string) (map[string]any, error return nil, fmt.Errorf("cluster %q not found", query) } +func isUUID(value string) bool { + return uuidPattern.MatchString(value) +} + func clusterString(cluster map[string]any, key string) string { value, ok := cluster[key] if !ok { diff --git a/internal/cli/run.go b/internal/cli/run.go index f77ed79..03572e1 100644 --- a/internal/cli/run.go +++ b/internal/cli/run.go @@ -36,6 +36,7 @@ type credentialsStore interface { type clusterService interface { ListClusters(apiURL, token string) ([]map[string]any, error) + GetCluster(apiURL, token, clusterID string) (map[string]any, error) } type credentials struct { diff --git a/internal/output/output.go b/internal/output/output.go index 5a154da..08d7e83 100644 --- a/internal/output/output.go +++ b/internal/output/output.go @@ -56,11 +56,12 @@ func renderClusterListText(clusters []map[string]any) []byte { } rows := make([][]string, 0, len(clusters)+1) - rows = append(rows, []string{"NAME", "AGENT VERSION", "KEDA VERSION", "AGENT STATUS", "KEDA STATUS", "AGE"}) + 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"), diff --git a/internal/output/output_test.go b/internal/output/output_test.go index 716f953..0f19871 100644 --- a/internal/output/output_test.go +++ b/internal/output/output_test.go @@ -11,6 +11,7 @@ func TestWriteTextClusterList(t *testing.T) { value := []map[string]any{ { "name": "alpha", + "id": "1", "agentStatus": "connected", "kedaStatus": "ready", "createdAt": "2026-06-15", @@ -30,12 +31,14 @@ func TestWriteTextClusterList(t *testing.T) { 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", @@ -48,7 +51,7 @@ func TestWriteTextClusterList(t *testing.T) { if strings.Contains(got, "---") { t.Fatalf("unexpected divider line in output: %q", got) } - if !strings.Contains(got, "NAME AGENT VERSION KEDA VERSION AGENT STATUS KEDA STATUS AGE") { + if !strings.Contains(got, "NAME ID AGENT VERSION KEDA VERSION AGENT STATUS KEDA STATUS AGE") { t.Fatalf("unexpected text output: %q", got) } } From ab0842bd998154d5da52c447be53a240b1c4abfd Mon Sep 17 00:00:00 2001 From: Josef Karasek Date: Tue, 16 Jun 2026 15:30:24 +0200 Subject: [PATCH 06/11] fix lint Signed-off-by: Josef Karasek --- AGENTS.md | 7 +++++ Makefile | 14 +++++++++- internal/api/client.go | 24 ++++++++++++---- internal/cli/commands_test.go | 51 ++++++++++++++++++++++++++++++++-- internal/cli/get.go | 2 +- internal/cli/login.go | 5 ++-- internal/cli/run.go | 2 +- internal/output/output.go | 10 +++++-- internal/tui/cluster_picker.go | 4 +-- internal/tui/login.go | 12 ++++++-- internal/tui/login_test.go | 17 ++++++++++++ 11 files changed, 128 insertions(+), 20 deletions(-) create mode 100644 AGENTS.md 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/internal/api/client.go b/internal/api/client.go index 3d7442e..03b452d 100644 --- a/internal/api/client.go +++ b/internal/api/client.go @@ -71,11 +71,10 @@ func (c *Client) GetCluster(apiURL, token, clusterID string) (map[string]any, er if err != nil { return nil, fmt.Errorf("request cluster %s: %w", clusterID, err) } - defer resp.Body.Close() - body, err := io.ReadAll(resp.Body) + body, err := readResponseBody(resp) if err != nil { - return nil, fmt.Errorf("read response: %w", err) + return nil, err } if resp.StatusCode < 200 || resp.StatusCode >= 300 { @@ -114,11 +113,10 @@ func (c *Client) listClustersPage(apiURL, token string, page int) (clustersRespo if err != nil { return clustersResponse{}, fmt.Errorf("request clusters page %d: %w", page, err) } - defer resp.Body.Close() - body, err := io.ReadAll(resp.Body) + body, err := readResponseBody(resp) if err != nil { - return clustersResponse{}, fmt.Errorf("read response: %w", err) + return clustersResponse{}, err } if resp.StatusCode < 200 || resp.StatusCode >= 300 { @@ -132,3 +130,17 @@ func (c *Client) listClustersPage(apiURL, token string, page int) (clustersRespo 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/cli/commands_test.go b/internal/cli/commands_test.go index 5f3d29f..40e2a64 100644 --- a/internal/cli/commands_test.go +++ b/internal/cli/commands_test.go @@ -99,6 +99,51 @@ func TestLoginCmdRunUsesExplicitToken(t *testing.T) { } } +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{ @@ -157,7 +202,7 @@ func TestGetClusterCmdRunFindsNamedCluster(t *testing.T) { token: "override-token", client: service, credentials: store, - selectCluster: func(_ io.Reader, _ io.Writer, _ []map[string]any) (map[string]any, error) { + 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 }, @@ -202,7 +247,7 @@ func TestGetClusterCmdRunUsesDedicatedEndpointForUUID(t *testing.T) { token: "override-token", client: service, credentials: store, - selectCluster: func(_ io.Reader, _ io.Writer, _ []map[string]any) (map[string]any, error) { + 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 }, @@ -244,7 +289,7 @@ func TestGetClusterCmdRunUsesSelectorWhenNameMissing(t *testing.T) { token: "override-token", client: service, credentials: store, - selectCluster: func(_ io.Reader, _ io.Writer, clusters []map[string]any) (map[string]any, error) { + 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)) } diff --git a/internal/cli/get.go b/internal/cli/get.go index 74478f9..1f99aa2 100644 --- a/internal/cli/get.go +++ b/internal/cli/get.go @@ -37,7 +37,7 @@ func (c *GetClusterCmd) Run(ctx *context) error { } } } else { - selectedCluster, err := ctx.selectCluster(ctx.stdin, ctx.stdout, clusters) + selectedCluster, err := ctx.selectCluster(ctx.stdin, ctx.stdout, ctx.stderr, clusters) if err != nil { return err } diff --git a/internal/cli/login.go b/internal/cli/login.go index fcc66ba..dd66379 100644 --- a/internal/cli/login.go +++ b/internal/cli/login.go @@ -2,6 +2,7 @@ package cli import ( "fmt" + "strings" ) type LoginCmd struct { @@ -9,9 +10,9 @@ type LoginCmd struct { } func (c *LoginCmd) Run(ctx *context) error { - token := c.Token + token := strings.TrimSpace(c.Token) if token == "" { - token = ctx.token + token = strings.TrimSpace(ctx.token) } if token == "" { var err error diff --git a/internal/cli/run.go b/internal/cli/run.go index 03572e1..e2c71db 100644 --- a/internal/cli/run.go +++ b/internal/cli/run.go @@ -52,7 +52,7 @@ type context struct { client clusterService credentials credentialsStore readSecret func(io.Reader, io.Writer, io.Writer) (string, error) - selectCluster func(io.Reader, io.Writer, []map[string]any) (map[string]any, error) + selectCluster func(io.Reader, io.Writer, io.Writer, []map[string]any) (map[string]any, error) writeOutput func(io.Writer, any, string) error } diff --git a/internal/output/output.go b/internal/output/output.go index 08d7e83..2ed44a8 100644 --- a/internal/output/output.go +++ b/internal/output/output.go @@ -35,8 +35,14 @@ func Write(w io.Writer, value any, format string) error { data = append(data, '\n') } - _, err = w.Write(data) - return err + 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) { diff --git a/internal/tui/cluster_picker.go b/internal/tui/cluster_picker.go index 06712c2..22d82f5 100644 --- a/internal/tui/cluster_picker.go +++ b/internal/tui/cluster_picker.go @@ -30,7 +30,7 @@ type clusterPickerModel struct { quit bool } -func SelectClusterOrFail(stdin io.Reader, stdout io.Writer, clusters []map[string]any) (map[string]any, error) { +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") @@ -51,7 +51,7 @@ func SelectClusterOrFail(stdin io.Reader, stdout io.Writer, clusters []map[strin } model := clusterPickerModel{options: options} - program := tea.NewProgram(model, tea.WithInput(file), tea.WithOutput(stdout)) + 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) diff --git a/internal/tui/login.go b/internal/tui/login.go index 8e9ea22..fe97006 100644 --- a/internal/tui/login.go +++ b/internal/tui/login.go @@ -27,10 +27,10 @@ type loginModel struct { quit bool } -func ReadSecretOrPipe(stdin io.Reader, stdout io.Writer, _ io.Writer) (string, error) { +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(stdout)) + 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) @@ -64,6 +64,14 @@ func ReadSecretOrPipe(stdin io.Reader, stdout io.Writer, _ io.Writer) (string, e 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 } diff --git a/internal/tui/login_test.go b/internal/tui/login_test.go index dfaf760..203eca0 100644 --- a/internal/tui/login_test.go +++ b/internal/tui/login_test.go @@ -35,3 +35,20 @@ func TestLoginModelAcceptsTypedToken(t *testing.T) { 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") + } +} From ad808a2419a77c6e12d7131229e6477ef4b3ab4e Mon Sep 17 00:00:00 2001 From: Josef Karasek Date: Tue, 16 Jun 2026 15:48:43 +0200 Subject: [PATCH 07/11] fix Signed-off-by: Josef Karasek --- internal/api/client.go | 7 ++++--- internal/cli/auth.go | 8 +++++--- internal/cli/login.go | 2 +- internal/config/config.go | 4 ++-- internal/tui/login.go | 2 +- 5 files changed, 13 insertions(+), 10 deletions(-) diff --git a/internal/api/client.go b/internal/api/client.go index 03b452d..9b97bce 100644 --- a/internal/api/client.go +++ b/internal/api/client.go @@ -47,10 +47,11 @@ func (c *Client) ListClusters(apiURL, token string) ([]map[string]any, error) { break } - page = response.PageInfo.Page + 1 - if page <= 1 { - page++ + nextPage := response.PageInfo.Page + 1 + if nextPage <= page { + nextPage = page + 1 } + page = nextPage } return allItems, nil diff --git a/internal/cli/auth.go b/internal/cli/auth.go index 9c32766..1d13c7c 100644 --- a/internal/cli/auth.go +++ b/internal/cli/auth.go @@ -1,8 +1,10 @@ package cli +import "strings" + func resolveToken(ctx *context) (string, error) { - if ctx.token != "" { - return ctx.token, nil + if token := strings.TrimSpace(ctx.token); token != "" { + return token, nil } creds, err := ctx.credentials.ReadCredentials() @@ -10,5 +12,5 @@ func resolveToken(ctx *context) (string, error) { return "", err } - return creds.Token, nil + return strings.TrimSpace(creds.Token), nil } diff --git a/internal/cli/login.go b/internal/cli/login.go index dd66379..9fb1f4f 100644 --- a/internal/cli/login.go +++ b/internal/cli/login.go @@ -26,6 +26,6 @@ func (c *LoginCmd) Run(ctx *context) error { return err } - _, err := fmt.Fprintln(ctx.stdout, "Credentials stored.") + _, err := fmt.Fprintln(ctx.stderr, "Credentials stored.") return err } diff --git a/internal/config/config.go b/internal/config/config.go index 151b7e6..35d4039 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -12,7 +12,7 @@ import ( ) const ( - credentialsDir = ".config/kedify" + credentialsDir = ".config/kedify" // #nosec G101 -- filesystem path, not a credential credentialsFile = "credentials.json" fileModeDir = 0o700 fileModeCreds = 0o600 @@ -90,7 +90,7 @@ func readCredentialsFile() (Credentials, error) { return Credentials{}, err } - data, err := os.ReadFile(path) + 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") diff --git a/internal/tui/login.go b/internal/tui/login.go index fe97006..e25e5c5 100644 --- a/internal/tui/login.go +++ b/internal/tui/login.go @@ -11,7 +11,7 @@ import ( "github.com/charmbracelet/lipgloss" ) -const apiKeysURL = "https://dashboard.dev.kedify.io/api-keys" +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")) From 8413d56e7df260879a08f5b5b95163391c675e38 Mon Sep 17 00:00:00 2001 From: Josef Karasek Date: Tue, 16 Jun 2026 15:56:11 +0200 Subject: [PATCH 08/11] fix Signed-off-by: Josef Karasek --- internal/cli/commands_test.go | 81 ++++++++++++++++++++++++++++++++++- 1 file changed, 79 insertions(+), 2 deletions(-) diff --git a/internal/cli/commands_test.go b/internal/cli/commands_test.go index 40e2a64..4b6daa2 100644 --- a/internal/cli/commands_test.go +++ b/internal/cli/commands_test.go @@ -3,6 +3,7 @@ package cli import ( "bytes" "io" + "strings" "testing" ) @@ -58,10 +59,12 @@ func (f *fakeClusterService) GetCluster(apiURL, token, clusterID string) (map[st func TestLoginCmdRunStoresCredentials(t *testing.T) { store := &fakeCredentialsStore{} + stdout := &bytes.Buffer{} + stderr := &bytes.Buffer{} ctx := &context{ stdin: bytes.NewBuffer(nil), - stdout: &bytes.Buffer{}, - stderr: &bytes.Buffer{}, + stdout: stdout, + stderr: stderr, credentials: store, readSecret: func(_ io.Reader, _ io.Writer, _ io.Writer) (string, error) { return "secret-token", nil @@ -75,6 +78,12 @@ func TestLoginCmdRunStoresCredentials(t *testing.T) { 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) { @@ -184,6 +193,37 @@ func TestListClustersCmdRunWritesClusters(t *testing.T) { } } +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{ @@ -229,6 +269,43 @@ func TestGetClusterCmdRunFindsNamedCluster(t *testing.T) { } } +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{ From 2a09385e38e021ba3ba257c8636df8c14638a690 Mon Sep 17 00:00:00 2001 From: Josef Karasek Date: Tue, 16 Jun 2026 16:03:55 +0200 Subject: [PATCH 09/11] simplify uuid Signed-off-by: Josef Karasek --- go.mod | 1 + go.sum | 2 ++ internal/cli/get.go | 8 ++++---- 3 files changed, 7 insertions(+), 4 deletions(-) diff --git a/go.mod b/go.mod index b6ef4d2..a1964bf 100644 --- a/go.mod +++ b/go.mod @@ -6,6 +6,7 @@ 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 ) diff --git a/go.sum b/go.sum index 55a4a9d..a99c701 100644 --- a/go.sum +++ b/go.sum @@ -30,6 +30,8 @@ github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6 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= diff --git a/internal/cli/get.go b/internal/cli/get.go index 1f99aa2..15c1f54 100644 --- a/internal/cli/get.go +++ b/internal/cli/get.go @@ -2,10 +2,9 @@ package cli import ( "fmt" - "regexp" -) -var uuidPattern = regexp.MustCompile(`(?i)^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$`) + "github.com/google/uuid" +) type GetClusterCmd struct { Name string `arg:"" optional:"" name:"name" help:"Cluster name or id."` @@ -64,7 +63,8 @@ func findCluster(clusters []map[string]any, query string) (map[string]any, error } func isUUID(value string) bool { - return uuidPattern.MatchString(value) + _, err := uuid.Parse(value) + return err == nil } func clusterString(cluster map[string]any, key string) string { From 8b0813cddd05025369075f898801ee07eed907ff Mon Sep 17 00:00:00 2001 From: Josef Karasek Date: Tue, 16 Jun 2026 16:20:14 +0200 Subject: [PATCH 10/11] fix Signed-off-by: Josef Karasek --- README.md | 2 +- internal/cli/get.go | 9 ++++++--- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index a622cca..2e1ef8c 100644 --- a/README.md +++ b/README.md @@ -14,7 +14,7 @@ This repository contains an experimental `kedify` CLI built with `kong` for comm 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]` +- `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. diff --git a/internal/cli/get.go b/internal/cli/get.go index 15c1f54..09d263e 100644 --- a/internal/cli/get.go +++ b/internal/cli/get.go @@ -17,9 +17,12 @@ func (c *GetClusterCmd) Run(ctx *context) error { return err } - clusters, err := ctx.client.ListClusters(ctx.apiURL, token) - 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 From b3234f2529bb0fab4346ae67e4947d621a0ea979 Mon Sep 17 00:00:00 2001 From: Josef Karasek Date: Tue, 16 Jun 2026 17:10:53 +0200 Subject: [PATCH 11/11] fix Signed-off-by: Josef Karasek --- internal/tui/cluster_picker.go | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/internal/tui/cluster_picker.go b/internal/tui/cluster_picker.go index 22d82f5..3c80642 100644 --- a/internal/tui/cluster_picker.go +++ b/internal/tui/cluster_picker.go @@ -12,8 +12,10 @@ import ( ) var ( - selectedStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("86")).Bold(true) - metaStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("241")) + 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 { @@ -103,9 +105,9 @@ func (m clusterPickerModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { func (m clusterPickerModel) View() string { var b strings.Builder - b.WriteString(titleStyle.Render("Select a Cluster")) + b.WriteString(clusterPickerTitleStyle.Render("Select a Cluster")) b.WriteString("\n") - b.WriteString(hintStyle.Render("Use ↑/↓ or j/k, Enter to select, Esc to cancel.")) + b.WriteString(clusterPickerHintStyle.Render("Use ↑/↓ or j/k, Enter to select, Esc to cancel.")) b.WriteString("\n\n") for i, option := range m.options { @@ -113,7 +115,7 @@ func (m clusterPickerModel) View() string { lineStyle := lipgloss.NewStyle() if i == m.cursor { prefix = "› " - lineStyle = selectedStyle + lineStyle = clusterPickerSelectedStyle } b.WriteString(lineStyle.Render(prefix + option.Name)) @@ -126,7 +128,7 @@ func (m clusterPickerModel) View() string { metaParts = append(metaParts, option.ID) } b.WriteString("\n") - b.WriteString(metaStyle.Render(" " + strings.Join(metaParts, " • "))) + b.WriteString(clusterPickerMetaStyle.Render(" " + strings.Join(metaParts, " • "))) } b.WriteString("\n") }