Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .env.template
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
LOOPS_API_KEY=your-key
3 changes: 2 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,8 @@ go.work
go.work.sum

# env file
.env
.env*
!.env.template

# Editor/IDE
# .idea/
Expand Down
2 changes: 2 additions & 0 deletions Taskfile.yml
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ version: "3"

tasks:
default:
dotenv:
- .env.{{.ENV}}
cmds:
- go run ./... {{.CLI_ARGS}}

Expand Down
33 changes: 33 additions & 0 deletions cmd/api_key.go
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think in general I prefer [noun] [verb] command patterns for this kind of thing. So like on auth I'd probably do auth login, auth logout, and auth status

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

login was added in #1. can make a linear for it though, wont address it here. i was debating originally whether to have the top level be auth or not. 👍

Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
package cmd

import (
"fmt"

"github.com/loops-so/cli/internal/api"
"github.com/loops-so/cli/internal/config"
"github.com/spf13/cobra"
)

var apiKeyCmd = &cobra.Command{
Use: "api-key",
Short: "Validate your API key",
RunE: func(cmd *cobra.Command, args []string) error {
cfg, err := config.Load()
if err != nil {
return err
}

client := api.NewClient(cfg.EndpointURL, cfg.APIKey)
result, err := client.GetAPIKey()
if err != nil {
return err
}

fmt.Printf("Valid API key for team: %s\n", result.TeamName)
return nil
},
}

func init() {
rootCmd.AddCommand(apiKeyCmd)
}
9 changes: 8 additions & 1 deletion cmd/login.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import (
"os"
"strings"

"github.com/loops-so/cli/internal/api"
"github.com/loops-so/cli/internal/config"
"github.com/spf13/cobra"
"golang.org/x/term"
Expand All @@ -26,11 +27,17 @@ var loginCmd = &cobra.Command{
return fmt.Errorf("API key cannot be empty")
}

client := api.NewClient(config.EndpointURL(), apiKey)
result, err := client.GetAPIKey()
if err != nil {
return fmt.Errorf("API key verification failed: %w", err)
}

if err := config.Save(apiKey); err != nil {
return err
}

fmt.Println("API key saved.")
fmt.Printf("API key saved. Authenticated as team: %s\n", result.TeamName)
return nil
},
}
Expand Down
2 changes: 1 addition & 1 deletion go.mod
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
module github.com/loops-so/cli

go 1.25.0
go 1.26.1

require (
github.com/spf13/cobra v1.10.2
Expand Down
39 changes: 39 additions & 0 deletions internal/api/api_key.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
package api

import (
"encoding/json"
"fmt"
"net/http"
)

type APIKeyResponse struct {
TeamName string `json:"teamName"`
}

func (c *Client) GetAPIKey() (*APIKeyResponse, error) {
req, err := c.newRequest(http.MethodGet, "/api-key")
if err != nil {
return nil, err
}

resp, err := c.httpClient.Do(req)
if err != nil {
return nil, fmt.Errorf("request failed: %w", err)
}
defer resp.Body.Close()

if resp.StatusCode == http.StatusUnauthorized {
return nil, fmt.Errorf("invalid API key")
}

if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("unexpected status: %d", resp.StatusCode)
}

var result APIKeyResponse
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return nil, fmt.Errorf("failed to decode response: %w", err)
}

return &result, nil
}
84 changes: 84 additions & 0 deletions internal/api/api_key_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
package api

import (
"net/http"
"net/http/httptest"
"testing"
)

func TestGetAPIKey(t *testing.T) {
tests := []struct {
name string
statusCode int
body string
wantErr string
wantTeam string
}{
{
name: "success",
statusCode: http.StatusOK,
body: `{"success":true,"teamName":"Acme"}`,
wantTeam: "Acme",
},
{
name: "unauthorized",
statusCode: http.StatusUnauthorized,
body: `{"success":false,"error":"Invalid API key"}`,
wantErr: "invalid API key",
},
{
name: "unexpected status",
statusCode: http.StatusInternalServerError,
body: ``,
wantErr: "unexpected status: 500",
},
{
name: "invalid json",
statusCode: http.StatusOK,
body: `not json`,
wantErr: "failed to decode response",
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(tt.statusCode)
w.Write([]byte(tt.body))
}))
defer server.Close()

client := NewClient(server.URL, "test-key")
result, err := client.GetAPIKey()

if tt.wantErr != "" {
if err == nil {
t.Fatalf("expected error containing %q, got nil", tt.wantErr)
}
if !contains(err.Error(), tt.wantErr) {
t.Errorf("error = %q, want it to contain %q", err.Error(), tt.wantErr)
}
return
}

if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if result.TeamName != tt.wantTeam {
t.Errorf("TeamName = %q, want %q", result.TeamName, tt.wantTeam)
}
})
}
}

func contains(s, substr string) bool {
return len(s) >= len(substr) && (s == substr || len(substr) == 0 ||
func() bool {
for i := 0; i <= len(s)-len(substr); i++ {
if s[i:i+len(substr)] == substr {
return true
}
}
return false
}())
}
31 changes: 31 additions & 0 deletions internal/api/client.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
package api

import (
"fmt"
"net/http"
"time"
)

type Client struct {
baseURL string
apiKey string
httpClient *http.Client
}

func NewClient(baseURL, apiKey string) *Client {
return &Client{
baseURL: baseURL,
apiKey: apiKey,
httpClient: &http.Client{Timeout: 5 * time.Second},
}
}

func (c *Client) newRequest(method, path string) (*http.Request, error) {
url := fmt.Sprintf("%s%s", c.baseURL, path)
req, err := http.NewRequest(method, url, nil)
if err != nil {
return nil, err
}
req.Header.Set("Authorization", "Bearer "+c.apiKey)
return req, nil
}
50 changes: 50 additions & 0 deletions internal/api/client_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
package api

import (
"net/http"
"testing"
)

func TestNewRequest(t *testing.T) {
client := NewClient("https://example.com/api/v1", "test-key")

tests := []struct {
name string
method string
path string
}{
{"GET", http.MethodGet, "/api-key"},
{"POST", http.MethodPost, "/some-resource"},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
req, err := client.newRequest(tt.method, tt.path)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}

if req.Method != tt.method {
t.Errorf("method = %q, want %q", req.Method, tt.method)
}

wantURL := "https://example.com/api/v1" + tt.path
if req.URL.String() != wantURL {
t.Errorf("url = %q, want %q", req.URL.String(), wantURL)
}

wantAuth := "Bearer test-key"
if got := req.Header.Get("Authorization"); got != wantAuth {
t.Errorf("Authorization = %q, want %q", got, wantAuth)
}
})
}
}

func TestNewRequest_InvalidURL(t *testing.T) {
client := NewClient("://bad-url", "test-key")
_, err := client.newRequest(http.MethodGet, "/path")
if err == nil {
t.Error("expected error for invalid URL, got nil")
}
}
12 changes: 8 additions & 4 deletions internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,9 +19,16 @@ type Config struct {
EndpointURL string
}

func EndpointURL() string {
if v := os.Getenv("LOOPS_ENDPOINT_URL"); v != "" {
return v
}
return DefaultEndpointURL
}

func Load() (*Config, error) {
cfg := &Config{
EndpointURL: DefaultEndpointURL,
EndpointURL: EndpointURL(),
}

apiKey, err := keyring.Get(keyringService, keyringUser)
Expand All @@ -33,9 +40,6 @@ func Load() (*Config, error) {
if v := os.Getenv("LOOPS_API_KEY"); v != "" {
cfg.APIKey = v
}
if v := os.Getenv("LOOPS_ENDPOINT_URL"); v != "" {
cfg.EndpointURL = v
}

if cfg.APIKey == "" {
return nil, errors.New("LOOPS_API_KEY is not set and no stored API credentials were found")
Expand Down
Loading