From 2530dd867ae9bb1fb2049bd21e18ad7a3444f637 Mon Sep 17 00:00:00 2001 From: Nate Meyer <672246+notnmeyer@users.noreply.github.com> Date: Fri, 20 Mar 2026 21:13:40 -0700 Subject: [PATCH 01/12] add config file for tracking key names --- go.mod | 2 +- internal/config/config.go | 118 +++++++++++++++++++++++++++----- internal/config/config_test.go | 119 +++++++++++++++++++++++++++------ 3 files changed, 202 insertions(+), 37 deletions(-) diff --git a/go.mod b/go.mod index 83426e4..9af60d0 100644 --- a/go.mod +++ b/go.mod @@ -6,6 +6,7 @@ require ( github.com/spf13/cobra v1.10.2 github.com/zalando/go-keyring v0.2.6 golang.org/x/term v0.41.0 + gopkg.in/yaml.v2 v2.4.0 ) require ( @@ -33,7 +34,6 @@ require ( golang.org/x/tools v0.29.0 // indirect golang.org/x/vuln v1.1.4 // indirect gopkg.in/ini.v1 v1.66.2 // indirect - gopkg.in/yaml.v2 v2.4.0 // indirect ) tool ( diff --git a/internal/config/config.go b/internal/config/config.go index 86eaaa3..ff3d479 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -4,14 +4,16 @@ import ( "errors" "fmt" "os" + "path/filepath" + "slices" "github.com/zalando/go-keyring" + "gopkg.in/yaml.v2" ) const ( DefaultEndpointURL = "https://app.loops.so/api/v1" keyringService = "loops" - keyringUser = "api-key" ) type Config struct { @@ -19,6 +21,60 @@ type Config struct { EndpointURL string } +type PersistentConfig struct { + ActiveTeam string `yaml:"activeTeam"` + Teams []string `yaml:"teams"` +} + +func configFilePath() (string, error) { + if dir := os.Getenv("LOOPS_CONFIG_DIR"); dir != "" { + return filepath.Join(dir, "config.yml"), nil + } + dir, err := os.UserConfigDir() + if err != nil { + return "", fmt.Errorf("could not determine config directory: %w", err) + } + return filepath.Join(dir, "loops", "config.yml"), nil +} + +func LoadPersistentConfig() (*PersistentConfig, error) { + path, err := configFilePath() + if err != nil { + return nil, err + } + data, err := os.ReadFile(path) + if errors.Is(err, os.ErrNotExist) { + return &PersistentConfig{}, nil + } + if err != nil { + return nil, fmt.Errorf("could not read config file: %w", err) + } + var pc PersistentConfig + if err := yaml.Unmarshal(data, &pc); err != nil { + return nil, fmt.Errorf("could not parse config file: %w", err) + } + return &pc, nil +} + +func SavePersistentConfig(pc *PersistentConfig) error { + path, err := configFilePath() + if err != nil { + return err + } + if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil { + return fmt.Errorf("could not create config directory: %w", err) + } + data, err := yaml.Marshal(pc) + if err != nil { + return fmt.Errorf("could not marshal config: %w", err) + } + tmp := path + ".tmp" + if err := os.WriteFile(tmp, data, 0o600); err != nil { + return fmt.Errorf("could not write config file: %w", err) + } + return os.Rename(tmp, path) +} + func EndpointURL() string { if v := os.Getenv("LOOPS_ENDPOINT_URL"); v != "" { return v @@ -31,31 +87,63 @@ func Load() (*Config, error) { EndpointURL: EndpointURL(), } - apiKey, err := keyring.Get(keyringService, keyringUser) - if err != nil && !errors.Is(err, keyring.ErrNotFound) { - return nil, fmt.Errorf("could not read keyring: %w", err) - } - cfg.APIKey = apiKey - if v := os.Getenv("LOOPS_API_KEY"); v != "" { cfg.APIKey = v + return cfg, nil + } + + pc, err := LoadPersistentConfig() + if err != nil { + return nil, err + } + + if pc.ActiveTeam != "" { + key, err := keyring.Get(keyringService, "key:"+pc.ActiveTeam) + if err != nil && !errors.Is(err, keyring.ErrNotFound) { + return nil, fmt.Errorf("could not read keyring: %w", err) + } + cfg.APIKey = key } if cfg.APIKey == "" { - return nil, errors.New("LOOPS_API_KEY is not set and no stored API credentials were found") + return nil, errors.New("no active team set — run `loops auth login --name ` to authenticate") } return cfg, nil } -func Save(apiKey string) error { - return keyring.Set(keyringService, keyringUser, apiKey) +func Save(apiKey, name string) error { + if name == "" { + return errors.New("a name is required — use --name to give this key a name") + } + if err := keyring.Set(keyringService, "key:"+name, apiKey); err != nil { + return fmt.Errorf("could not save to keyring: %w", err) + } + pc, err := LoadPersistentConfig() + if err != nil { + return err + } + if !slices.Contains(pc.Teams, name) { + pc.Teams = append(pc.Teams, name) + } + pc.ActiveTeam = name + return SavePersistentConfig(pc) } -func Delete() error { - err := keyring.Delete(keyringService, keyringUser) - if errors.Is(err, keyring.ErrNotFound) { - return nil +func Delete(name string) error { + if name == "" { + return errors.New("a name is required — use --name to specify which key to remove") + } + if err := keyring.Delete(keyringService, "key:"+name); err != nil && !errors.Is(err, keyring.ErrNotFound) { + return fmt.Errorf("could not delete key %q from keyring: %w", name, err) + } + pc, err := LoadPersistentConfig() + if err != nil { + return err + } + pc.Teams = slices.DeleteFunc(pc.Teams, func(t string) bool { return t == name }) + if pc.ActiveTeam == name { + pc.ActiveTeam = "" } - return err + return SavePersistentConfig(pc) } diff --git a/internal/config/config_test.go b/internal/config/config_test.go index a1d56c8..723adcc 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -9,10 +9,11 @@ import ( func setup(t *testing.T) { t.Helper() keyring.MockInit() + t.Setenv("LOOPS_CONFIG_DIR", t.TempDir()) } func TestLoad(t *testing.T) { - t.Run("errors when no api key is set", func(t *testing.T) { + t.Run("errors when no credentials are set", func(t *testing.T) { setup(t) t.Setenv("LOOPS_API_KEY", "") t.Setenv("LOOPS_ENDPOINT_URL", "") @@ -23,12 +24,15 @@ func TestLoad(t *testing.T) { } }) - t.Run("reads api key from keyring", func(t *testing.T) { + t.Run("reads api key from active team", func(t *testing.T) { setup(t) - keyring.Set(keyringService, keyringUser, "keyring-key") t.Setenv("LOOPS_API_KEY", "") t.Setenv("LOOPS_ENDPOINT_URL", "") + if err := Save("keyring-key", "myteam"); err != nil { + t.Fatalf("Save: %v", err) + } + cfg, err := Load() if err != nil { t.Fatalf("unexpected error: %v", err) @@ -40,7 +44,9 @@ func TestLoad(t *testing.T) { t.Run("env var overrides keyring api key", func(t *testing.T) { setup(t) - keyring.Set(keyringService, keyringUser, "keyring-key") + if err := Save("keyring-key", "myteam"); err != nil { + t.Fatalf("Save: %v", err) + } t.Setenv("LOOPS_API_KEY", "env-key") t.Setenv("LOOPS_ENDPOINT_URL", "") @@ -83,14 +89,21 @@ func TestLoad(t *testing.T) { } func TestSave(t *testing.T) { - t.Run("stores api key in keyring", func(t *testing.T) { + t.Run("errors when name is empty", func(t *testing.T) { setup(t) + if err := Save("my-key", ""); err == nil { + t.Fatal("expected error, got nil") + } + }) - if err := Save("my-key"); err != nil { + t.Run("stores api key in keyring under team name", func(t *testing.T) { + setup(t) + + if err := Save("my-key", "acme"); err != nil { t.Fatalf("unexpected error: %v", err) } - got, err := keyring.Get(keyringService, keyringUser) + got, err := keyring.Get(keyringService, "key:acme") if err != nil { t.Fatalf("could not read keyring: %v", err) } @@ -99,43 +112,107 @@ func TestSave(t *testing.T) { } }) - t.Run("overwrites existing api key", func(t *testing.T) { + t.Run("writes config file with active team", func(t *testing.T) { setup(t) - keyring.Set(keyringService, keyringUser, "old-key") - if err := Save("new-key"); err != nil { + if err := Save("my-key", "acme"); err != nil { t.Fatalf("unexpected error: %v", err) } - got, err := keyring.Get(keyringService, keyringUser) + pc, err := LoadPersistentConfig() if err != nil { - t.Fatalf("could not read keyring: %v", err) + t.Fatalf("LoadPersistentConfig: %v", err) + } + if pc.ActiveTeam != "acme" { + t.Errorf("activeTeam: got %q, want %q", pc.ActiveTeam, "acme") + } + if len(pc.Teams) != 1 || pc.Teams[0] != "acme" { + t.Errorf("teams: got %v, want [acme]", pc.Teams) + } + }) + + t.Run("appends new team without duplicates", func(t *testing.T) { + setup(t) + Save("key1", "acme") + Save("key2", "work") + Save("key3", "acme") // re-save same name + + pc, err := LoadPersistentConfig() + if err != nil { + t.Fatalf("LoadPersistentConfig: %v", err) + } + if len(pc.Teams) != 2 { + t.Errorf("teams: got %v, want [acme work]", pc.Teams) + } + }) + + t.Run("sets active team to the saved team", func(t *testing.T) { + setup(t) + Save("key1", "acme") + Save("key2", "work") + + pc, err := LoadPersistentConfig() + if err != nil { + t.Fatalf("LoadPersistentConfig: %v", err) } - if got != "new-key" { - t.Errorf("got %q, want %q", got, "new-key") + if pc.ActiveTeam != "work" { + t.Errorf("activeTeam: got %q, want %q", pc.ActiveTeam, "work") } }) } func TestDelete(t *testing.T) { - t.Run("removes api key from keyring", func(t *testing.T) { + t.Run("errors when name is empty", func(t *testing.T) { setup(t) - keyring.Set(keyringService, keyringUser, "my-key") + if err := Delete(""); err == nil { + t.Fatal("expected error, got nil") + } + }) - if err := Delete(); err != nil { + t.Run("removes the named key from keyring and teams list", func(t *testing.T) { + setup(t) + Save("key1", "acme") + Save("key2", "work") + + if err := Delete("acme"); err != nil { t.Fatalf("unexpected error: %v", err) } - _, err := keyring.Get(keyringService, keyringUser) + _, err := keyring.Get(keyringService, "key:acme") if err != keyring.ErrNotFound { - t.Errorf("expected ErrNotFound, got %v", err) + t.Errorf("expected ErrNotFound for acme, got %v", err) + } + + pc, err := LoadPersistentConfig() + if err != nil { + t.Fatalf("LoadPersistentConfig: %v", err) + } + if len(pc.Teams) != 1 || pc.Teams[0] != "work" { + t.Errorf("teams: got %v, want [work]", pc.Teams) } }) - t.Run("no error when no credentials stored", func(t *testing.T) { + t.Run("clears activeTeam when the active team is deleted", func(t *testing.T) { setup(t) + Save("key1", "acme") + Save("key2", "work") // work is now active + + if err := Delete("work"); err != nil { + t.Fatalf("unexpected error: %v", err) + } + + pc, err := LoadPersistentConfig() + if err != nil { + t.Fatalf("LoadPersistentConfig: %v", err) + } + if pc.ActiveTeam != "" { + t.Errorf("activeTeam: got %q, want empty", pc.ActiveTeam) + } + }) - if err := Delete(); err != nil { + t.Run("no error when key not in keyring", func(t *testing.T) { + setup(t) + if err := Delete("nonexistent"); err != nil { t.Fatalf("unexpected error: %v", err) } }) From e36b40de56bd94510891b975f3a9d13616b2ec57 Mon Sep 17 00:00:00 2001 From: Nate Meyer <672246+notnmeyer@users.noreply.github.com> Date: Fri, 20 Mar 2026 21:25:53 -0700 Subject: [PATCH 02/12] update auth commands to use the new storage --- cmd/auth_login.go | 19 +++++++++++++++---- cmd/auth_login_test.go | 12 ++++++++++-- cmd/auth_logout.go | 15 +++++++++++---- cmd/auth_logout_test.go | 13 +++++++++++-- cmd/auth_status.go | 29 ++++++++++++++++++++--------- cmd/auth_status_test.go | 14 ++++++++------ cmd/helpers_test.go | 2 ++ 7 files changed, 77 insertions(+), 27 deletions(-) diff --git a/cmd/auth_login.go b/cmd/auth_login.go index cf51587..c3cc160 100644 --- a/cmd/auth_login.go +++ b/cmd/auth_login.go @@ -1,6 +1,7 @@ package cmd import ( + "errors" "fmt" "os" "strings" @@ -11,10 +12,16 @@ import ( "golang.org/x/term" ) +var loginName string + var loginCmd = &cobra.Command{ Use: "login", Short: "Authenticate with your Loops API key", RunE: func(cmd *cobra.Command, args []string) error { + if loginName == "" { + return errors.New("use --name to give this key a name (e.g. loops auth login --name loops-prod)") + } + fmt.Fprint(os.Stderr, "Enter your API key: ") raw, err := term.ReadPassword(int(os.Stdin.Fd())) fmt.Fprintln(os.Stderr) @@ -27,7 +34,7 @@ var loginCmd = &cobra.Command{ return fmt.Errorf("API key cannot be empty") } - result, err := runAuthLogin(apiKey) + result, err := runAuthLogin(apiKey, loginName) if err != nil { return err } @@ -35,22 +42,26 @@ var loginCmd = &cobra.Command{ if isJSONOutput() { return printJSON(cmd.OutOrStdout(), Result{Success: true, Message: fmt.Sprintf("Authenticated as team: %s", result.TeamName)}) } - fmt.Fprintf(cmd.OutOrStdout(), "API key saved. Authenticated as team: %s\n", result.TeamName) + fmt.Fprintf(cmd.OutOrStdout(), "API key saved as %q. Authenticated as team: %s\n", loginName, result.TeamName) return nil }, } -func runAuthLogin(apiKey string) (*api.APIKeyResponse, error) { +func runAuthLogin(apiKey, name string) (*api.APIKeyResponse, error) { + if name == "" { + return nil, errors.New("use --name to give this key a name") + } result, err := api.NewClient(config.EndpointURL(), apiKey).GetAPIKey() if err != nil { return nil, fmt.Errorf("API key verification failed: %w", err) } - if err := config.Save(apiKey); err != nil { + if err := config.Save(apiKey, name); err != nil { return nil, err } return result, nil } func init() { + loginCmd.Flags().StringVarP(&loginName, "name", "n", "", "Name for this API key (e.g. loops-prod)") authCmd.AddCommand(loginCmd) } diff --git a/cmd/auth_login_test.go b/cmd/auth_login_test.go index f0ab282..846f57f 100644 --- a/cmd/auth_login_test.go +++ b/cmd/auth_login_test.go @@ -11,7 +11,7 @@ import ( func TestRunAuthLogin(t *testing.T) { t.Run("saves key and returns team name", func(t *testing.T) { serveJSON(t, http.StatusOK, `{"teamName":"Acme"}`) - result, err := runAuthLogin("test-key") + result, err := runAuthLogin("test-key", "acme") if err != nil { t.Fatalf("unexpected error: %v", err) } @@ -23,7 +23,15 @@ func TestRunAuthLogin(t *testing.T) { t.Run("returns error on api failure", func(t *testing.T) { serveJSON(t, http.StatusUnauthorized, `{"error":"Invalid API key"}`) - _, err := runAuthLogin("bad-key") + _, err := runAuthLogin("bad-key", "acme") + if err == nil { + t.Fatal("expected error, got nil") + } + }) + + t.Run("returns error when name is empty", func(t *testing.T) { + serveJSON(t, http.StatusOK, `{"teamName":"Acme"}`) + _, err := runAuthLogin("test-key", "") if err == nil { t.Fatal("expected error, got nil") } diff --git a/cmd/auth_logout.go b/cmd/auth_logout.go index 44ec51a..b9cbbdd 100644 --- a/cmd/auth_logout.go +++ b/cmd/auth_logout.go @@ -1,31 +1,38 @@ package cmd import ( + "errors" "fmt" "github.com/loops-so/cli/internal/config" "github.com/spf13/cobra" ) +var logoutName string + var logoutCmd = &cobra.Command{ Use: "logout", Short: "Remove stored Loops credentials", RunE: func(cmd *cobra.Command, args []string) error { - if err := runAuthLogout(); err != nil { + if logoutName == "" { + return errors.New("use --name to specify which key to remove (e.g. loops auth logout --name loops-prod)") + } + if err := runAuthLogout(logoutName); err != nil { return err } if isJSONOutput() { return printJSON(cmd.OutOrStdout(), Result{Success: true}) } - fmt.Fprintln(cmd.OutOrStdout(), "Logged out.") + fmt.Fprintf(cmd.OutOrStdout(), "Logged out of %q.\n", logoutName) return nil }, } -func runAuthLogout() error { - return config.Delete() +func runAuthLogout(name string) error { + return config.Delete(name) } func init() { + logoutCmd.Flags().StringVarP(&logoutName, "name", "n", "", "Name of the API key to remove") authCmd.AddCommand(logoutCmd) } diff --git a/cmd/auth_logout_test.go b/cmd/auth_logout_test.go index f307330..9921cd2 100644 --- a/cmd/auth_logout_test.go +++ b/cmd/auth_logout_test.go @@ -1,14 +1,23 @@ package cmd import ( + "net/http" "testing" ) func TestRunAuthLogout(t *testing.T) { t.Run("succeeds", func(t *testing.T) { - mockKeyring(t) - if err := runAuthLogout(); err != nil { + serveJSON(t, http.StatusOK, `{}`) + runAuthLogin("test-key", "acme") + if err := runAuthLogout("acme"); err != nil { t.Fatalf("unexpected error: %v", err) } }) + + t.Run("returns error when name is empty", func(t *testing.T) { + serveJSON(t, http.StatusOK, `{}`) + if err := runAuthLogout(""); err == nil { + t.Fatal("expected error, got nil") + } + }) } diff --git a/cmd/auth_status.go b/cmd/auth_status.go index 175c17c..03a74aa 100644 --- a/cmd/auth_status.go +++ b/cmd/auth_status.go @@ -13,7 +13,7 @@ var statusCmd = &cobra.Command{ Use: "status", Short: "Print the resolved configuration", RunE: func(cmd *cobra.Command, args []string) error { - cfg, keyResp, err := runAuthStatus() + cfg, keyResp, pc, err := runAuthStatus() if err != nil { return err } @@ -22,29 +22,40 @@ var statusCmd = &cobra.Command{ if isJSONOutput() { return printJSON(cmd.OutOrStdout(), struct { + ActiveKey string `json:"activeKey"` APIKey string `json:"apiKey"` EndpointURL string `json:"endpointUrl"` TeamName string `json:"teamName"` - }{masked, cfg.EndpointURL, keyResp.TeamName}) + }{pc.ActiveTeam, masked, cfg.EndpointURL, keyResp.TeamName}) } - fmt.Fprintf(cmd.OutOrStdout(), "API Key: %s\n", masked) - fmt.Fprintf(cmd.OutOrStdout(), "Endpoint: %s\n", cfg.EndpointURL) - fmt.Fprintf(cmd.OutOrStdout(), "Team: %s\n", keyResp.TeamName) + activeKey := pc.ActiveTeam + if activeKey == "" { + activeKey = "(none)" + } + + fmt.Fprintf(cmd.OutOrStdout(), "Active Key: %s\n", activeKey) + fmt.Fprintf(cmd.OutOrStdout(), "API Key: %s\n", masked) + fmt.Fprintf(cmd.OutOrStdout(), "Team: %s\n", keyResp.TeamName) + fmt.Fprintf(cmd.OutOrStdout(), "Endpoint: %s\n", cfg.EndpointURL) return nil }, } -func runAuthStatus() (*config.Config, *api.APIKeyResponse, error) { +func runAuthStatus() (*config.Config, *api.APIKeyResponse, *config.PersistentConfig, error) { cfg, err := config.Load() if err != nil { - return nil, nil, err + return nil, nil, nil, err } keyResp, err := api.NewClient(cfg.EndpointURL, cfg.APIKey).GetAPIKey() if err != nil { - return nil, nil, fmt.Errorf("API key verification failed: %w", err) + return nil, nil, nil, fmt.Errorf("API key verification failed: %w", err) + } + pc, err := config.LoadPersistentConfig() + if err != nil { + return nil, nil, nil, err } - return cfg, keyResp, nil + return cfg, keyResp, pc, nil } func maskKey(key string) string { diff --git a/cmd/auth_status_test.go b/cmd/auth_status_test.go index 0298472..44a8b3a 100644 --- a/cmd/auth_status_test.go +++ b/cmd/auth_status_test.go @@ -3,14 +3,12 @@ package cmd import ( "net/http" "testing" - - "github.com/zalando/go-keyring" ) func TestRunAuthStatus(t *testing.T) { t.Run("returns config and team name", func(t *testing.T) { serveJSON(t, http.StatusOK, `{"teamName":"Acme"}`) - cfg, keyResp, err := runAuthStatus() + cfg, keyResp, pc, err := runAuthStatus() if err != nil { t.Fatalf("unexpected error: %v", err) } @@ -23,11 +21,15 @@ func TestRunAuthStatus(t *testing.T) { if keyResp.TeamName != "Acme" { t.Errorf("got team %q, want %q", keyResp.TeamName, "Acme") } + if pc == nil { + t.Error("expected PersistentConfig to be set") + } }) t.Run("returns error when no key set", func(t *testing.T) { - keyring.MockInit() - _, _, err := runAuthStatus() + mockKeyring(t) + t.Setenv("LOOPS_API_KEY", "") + _, _, _, err := runAuthStatus() if err == nil { t.Fatal("expected error, got nil") } @@ -35,7 +37,7 @@ func TestRunAuthStatus(t *testing.T) { t.Run("returns error on api failure", func(t *testing.T) { serveJSON(t, http.StatusUnauthorized, `{"error":"Invalid API key"}`) - _, _, err := runAuthStatus() + _, _, _, err := runAuthStatus() if err == nil { t.Fatal("expected error, got nil") } diff --git a/cmd/helpers_test.go b/cmd/helpers_test.go index 69a579f..b1453d8 100644 --- a/cmd/helpers_test.go +++ b/cmd/helpers_test.go @@ -21,11 +21,13 @@ func cfg(t *testing.T) *config.Config { func mockKeyring(t *testing.T) { t.Helper() keyring.MockInit() + t.Setenv("LOOPS_CONFIG_DIR", t.TempDir()) } func serveJSON(t *testing.T, status int, body string) { t.Helper() keyring.MockInit() + t.Setenv("LOOPS_CONFIG_DIR", t.TempDir()) srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(status) w.Write([]byte(body)) From bd18a8b90a086c0e2d43d19a7909c55720c03290 Mon Sep 17 00:00:00 2001 From: Nate Meyer <672246+notnmeyer@users.noreply.github.com> Date: Fri, 20 Mar 2026 21:29:16 -0700 Subject: [PATCH 03/12] couple config tests --- internal/config/config_test.go | 33 +++++++++++++++++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 723adcc..9a652d0 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -24,6 +24,22 @@ func TestLoad(t *testing.T) { } }) + t.Run("errors when activeTeam is set but key not in keyring", func(t *testing.T) { + setup(t) + t.Setenv("LOOPS_API_KEY", "") + t.Setenv("LOOPS_ENDPOINT_URL", "") + + if err := Save("some-key", "acme"); err != nil { + t.Fatalf("Save: %v", err) + } + keyring.Delete(keyringService, "key:acme") + + _, err := Load() + if err == nil { + t.Fatal("expected error, got nil") + } + }) + t.Run("reads api key from active team", func(t *testing.T) { setup(t) t.Setenv("LOOPS_API_KEY", "") @@ -131,6 +147,23 @@ func TestSave(t *testing.T) { } }) + t.Run("overwrites existing key value in keyring", func(t *testing.T) { + setup(t) + Save("old-key", "acme") + + if err := Save("new-key", "acme"); err != nil { + t.Fatalf("unexpected error: %v", err) + } + + got, err := keyring.Get(keyringService, "key:acme") + if err != nil { + t.Fatalf("could not read keyring: %v", err) + } + if got != "new-key" { + t.Errorf("got %q, want %q", got, "new-key") + } + }) + t.Run("appends new team without duplicates", func(t *testing.T) { setup(t) Save("key1", "acme") From 625de8e9510027c592a3d0bdf416980b1b6c8387 Mon Sep 17 00:00:00 2001 From: Nate Meyer <672246+notnmeyer@users.noreply.github.com> Date: Fri, 20 Mar 2026 21:34:46 -0700 Subject: [PATCH 04/12] auth list command --- cmd/auth_list.go | 52 ++++++++++++++++++++++++++++++++++ cmd/auth_list_test.go | 40 ++++++++++++++++++++++++++ internal/config/config.go | 21 ++++++++++++++ internal/config/config_test.go | 51 +++++++++++++++++++++++++++++++++ 4 files changed, 164 insertions(+) create mode 100644 cmd/auth_list.go create mode 100644 cmd/auth_list_test.go diff --git a/cmd/auth_list.go b/cmd/auth_list.go new file mode 100644 index 0000000..bdc9a0b --- /dev/null +++ b/cmd/auth_list.go @@ -0,0 +1,52 @@ +package cmd + +import ( + "fmt" + "text/tabwriter" + + "github.com/loops-so/cli/internal/config" + "github.com/spf13/cobra" +) + +var authListCmd = &cobra.Command{ + Use: "list", + Short: "List stored API keys", + RunE: func(cmd *cobra.Command, args []string) error { + entries, err := runAuthList() + if err != nil { + return err + } + + if isJSONOutput() { + type jsonEntry struct { + Name string `json:"name"` + APIKey string `json:"apiKey"` + } + out := make([]jsonEntry, len(entries)) + for i, e := range entries { + out[i] = jsonEntry{e.Name, maskKey(e.APIKey)} + } + return printJSON(cmd.OutOrStdout(), out) + } + + if len(entries) == 0 { + fmt.Fprintln(cmd.OutOrStdout(), "No keys stored.") + return nil + } + + w := tabwriter.NewWriter(cmd.OutOrStdout(), 0, 0, 3, ' ', 0) + fmt.Fprintln(w, "NAME\tAPI KEY") + for _, e := range entries { + fmt.Fprintf(w, "%s\t%s\n", e.Name, maskKey(e.APIKey)) + } + return w.Flush() + }, +} + +func runAuthList() ([]config.KeyEntry, error) { + return config.ListKeys() +} + +func init() { + authCmd.AddCommand(authListCmd) +} diff --git a/cmd/auth_list_test.go b/cmd/auth_list_test.go new file mode 100644 index 0000000..39d9485 --- /dev/null +++ b/cmd/auth_list_test.go @@ -0,0 +1,40 @@ +package cmd + +import ( + "testing" + + "github.com/loops-so/cli/internal/config" +) + +func TestRunAuthList(t *testing.T) { + t.Run("returns empty list when no keys stored", func(t *testing.T) { + mockKeyring(t) + entries, err := runAuthList() + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(entries) != 0 { + t.Errorf("got %d entries, want 0", len(entries)) + } + }) + + t.Run("returns stored keys", func(t *testing.T) { + mockKeyring(t) + config.Save("key-abc1234", "acme") + config.Save("key-xyz5678", "work") + + entries, err := runAuthList() + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(entries) != 2 { + t.Fatalf("got %d entries, want 2", len(entries)) + } + if entries[0].Name != "acme" || entries[0].APIKey != "key-abc1234" { + t.Errorf("entry 0: got {%q, %q}, want {acme, key-abc1234}", entries[0].Name, entries[0].APIKey) + } + if entries[1].Name != "work" || entries[1].APIKey != "key-xyz5678" { + t.Errorf("entry 1: got {%q, %q}, want {work, key-xyz5678}", entries[1].Name, entries[1].APIKey) + } + }) +} diff --git a/internal/config/config.go b/internal/config/config.go index ff3d479..ede40c5 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -130,6 +130,27 @@ func Save(apiKey, name string) error { return SavePersistentConfig(pc) } +type KeyEntry struct { + Name string + APIKey string +} + +func ListKeys() ([]KeyEntry, error) { + pc, err := LoadPersistentConfig() + if err != nil { + return nil, err + } + entries := make([]KeyEntry, 0, len(pc.Teams)) + for _, name := range pc.Teams { + key, err := keyring.Get(keyringService, "key:"+name) + if err != nil && !errors.Is(err, keyring.ErrNotFound) { + return nil, fmt.Errorf("could not read key %q: %w", name, err) + } + entries = append(entries, KeyEntry{Name: name, APIKey: key}) + } + return entries, nil +} + func Delete(name string) error { if name == "" { return errors.New("a name is required — use --name to specify which key to remove") diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 9a652d0..d9bbeed 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -194,6 +194,57 @@ func TestSave(t *testing.T) { }) } +func TestListKeys(t *testing.T) { + t.Run("returns empty slice when no teams configured", func(t *testing.T) { + setup(t) + + entries, err := ListKeys() + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(entries) != 0 { + t.Errorf("got %d entries, want 0", len(entries)) + } + }) + + t.Run("returns keys in config order", func(t *testing.T) { + setup(t) + Save("key-a", "acme") + Save("key-b", "work") + + entries, err := ListKeys() + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(entries) != 2 { + t.Fatalf("got %d entries, want 2", len(entries)) + } + if entries[0].Name != "acme" || entries[0].APIKey != "key-a" { + t.Errorf("entry 0: got {%q, %q}, want {acme, key-a}", entries[0].Name, entries[0].APIKey) + } + if entries[1].Name != "work" || entries[1].APIKey != "key-b" { + t.Errorf("entry 1: got {%q, %q}, want {work, key-b}", entries[1].Name, entries[1].APIKey) + } + }) + + t.Run("returns empty api key when keyring entry missing", func(t *testing.T) { + setup(t) + Save("key-a", "acme") + keyring.Delete(keyringService, "key:acme") + + entries, err := ListKeys() + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(entries) != 1 { + t.Fatalf("got %d entries, want 1", len(entries)) + } + if entries[0].APIKey != "" { + t.Errorf("got %q, want empty string", entries[0].APIKey) + } + }) +} + func TestDelete(t *testing.T) { t.Run("errors when name is empty", func(t *testing.T) { setup(t) From a74447856d28970ec178e7b944da5cd3cfbbf8c7 Mon Sep 17 00:00:00 2001 From: Nate Meyer <672246+notnmeyer@users.noreply.github.com> Date: Fri, 20 Mar 2026 21:43:01 -0700 Subject: [PATCH 05/12] global --team flag --- cmd/api_key.go | 2 +- cmd/auth_status.go | 2 +- cmd/contact_properties.go | 4 ++-- cmd/events.go | 2 +- cmd/helpers_test.go | 2 +- cmd/lists.go | 2 +- cmd/root.go | 7 +++++++ cmd/transactional.go | 4 ++-- internal/config/config.go | 16 ++++++++------ internal/config/config_test.go | 38 ++++++++++++++++++++++++++++------ 10 files changed, 58 insertions(+), 21 deletions(-) diff --git a/cmd/api_key.go b/cmd/api_key.go index d7b987a..d6c3632 100644 --- a/cmd/api_key.go +++ b/cmd/api_key.go @@ -16,7 +16,7 @@ var apiKeyCmd = &cobra.Command{ Use: "api-key", Short: "Validate your API key", RunE: func(cmd *cobra.Command, args []string) error { - cfg, err := config.Load() + cfg, err := loadConfig() if err != nil { return err } diff --git a/cmd/auth_status.go b/cmd/auth_status.go index 03a74aa..cb659a1 100644 --- a/cmd/auth_status.go +++ b/cmd/auth_status.go @@ -43,7 +43,7 @@ var statusCmd = &cobra.Command{ } func runAuthStatus() (*config.Config, *api.APIKeyResponse, *config.PersistentConfig, error) { - cfg, err := config.Load() + cfg, err := loadConfig() if err != nil { return nil, nil, nil, err } diff --git a/cmd/contact_properties.go b/cmd/contact_properties.go index 35e3ae0..3fd8860 100644 --- a/cmd/contact_properties.go +++ b/cmd/contact_properties.go @@ -27,7 +27,7 @@ var contactPropertiesListCmd = &cobra.Command{ RunE: func(cmd *cobra.Command, args []string) error { customOnly, _ := cmd.Flags().GetBool("custom") - cfg, err := config.Load() + cfg, err := loadConfig() if err != nil { return err } @@ -67,7 +67,7 @@ var contactPropertiesCreateCmd = &cobra.Command{ name, _ := cmd.Flags().GetString("name") propType, _ := cmd.Flags().GetString("type") - cfg, err := config.Load() + cfg, err := loadConfig() if err != nil { return err } diff --git a/cmd/events.go b/cmd/events.go index f523905..d3c9bea 100644 --- a/cmd/events.go +++ b/cmd/events.go @@ -50,7 +50,7 @@ var eventsSendCmd = &cobra.Command{ } func eventsSendRunE(cmd *cobra.Command, args []string) error { - cfg, err := config.Load() + cfg, err := loadConfig() if err != nil { return err } diff --git a/cmd/helpers_test.go b/cmd/helpers_test.go index b1453d8..391cc9b 100644 --- a/cmd/helpers_test.go +++ b/cmd/helpers_test.go @@ -11,7 +11,7 @@ import ( func cfg(t *testing.T) *config.Config { t.Helper() - c, err := config.Load() + c, err := config.Load("") if err != nil { t.Fatalf("config.Load: %v", err) } diff --git a/cmd/lists.go b/cmd/lists.go index 943db5a..d401cd7 100644 --- a/cmd/lists.go +++ b/cmd/lists.go @@ -21,7 +21,7 @@ var listsListCmd = &cobra.Command{ Use: "list", Short: "List mailing lists", RunE: func(cmd *cobra.Command, args []string) error { - cfg, err := config.Load() + cfg, err := loadConfig() if err != nil { return err } diff --git a/cmd/root.go b/cmd/root.go index c3b2f31..c898685 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -7,10 +7,16 @@ import ( "fmt" "os" + "github.com/loops-so/cli/internal/config" "github.com/spf13/cobra" ) var outputFormat outputFlag = "text" +var teamFlag string + +func loadConfig() (*config.Config, error) { + return config.Load(teamFlag) +} var rootCmd = &cobra.Command{ Use: "loops", @@ -59,4 +65,5 @@ func init() { // Cobra also supports local flags, which will only run // when this action is called directly. rootCmd.PersistentFlags().VarP(&outputFormat, "output", "o", "Output format (text, json)") + rootCmd.PersistentFlags().StringVarP(&teamFlag, "team", "t", "", "Team key name to use") } diff --git a/cmd/transactional.go b/cmd/transactional.go index 4cb2e80..f9ffe5f 100644 --- a/cmd/transactional.go +++ b/cmd/transactional.go @@ -90,7 +90,7 @@ var transactionalListCmd = &cobra.Command{ Use: "list", Short: "List published transactional emails", RunE: func(cmd *cobra.Command, args []string) error { - cfg, err := config.Load() + cfg, err := loadConfig() if err != nil { return err } @@ -125,7 +125,7 @@ var transactionalListCmd = &cobra.Command{ } func transactionalSendRunE(cmd *cobra.Command, args []string) error { - cfg, err := config.Load() + cfg, err := loadConfig() if err != nil { return err } diff --git a/internal/config/config.go b/internal/config/config.go index ede40c5..d678769 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -82,7 +82,7 @@ func EndpointURL() string { return DefaultEndpointURL } -func Load() (*Config, error) { +func Load(teamOverride string) (*Config, error) { cfg := &Config{ EndpointURL: EndpointURL(), } @@ -92,13 +92,17 @@ func Load() (*Config, error) { return cfg, nil } - pc, err := LoadPersistentConfig() - if err != nil { - return nil, err + team := teamOverride + if team == "" { + pc, err := LoadPersistentConfig() + if err != nil { + return nil, err + } + team = pc.ActiveTeam } - if pc.ActiveTeam != "" { - key, err := keyring.Get(keyringService, "key:"+pc.ActiveTeam) + if team != "" { + key, err := keyring.Get(keyringService, "key:"+team) if err != nil && !errors.Is(err, keyring.ErrNotFound) { return nil, fmt.Errorf("could not read keyring: %w", err) } diff --git a/internal/config/config_test.go b/internal/config/config_test.go index d9bbeed..15593f2 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -18,7 +18,33 @@ func TestLoad(t *testing.T) { t.Setenv("LOOPS_API_KEY", "") t.Setenv("LOOPS_ENDPOINT_URL", "") - _, err := Load() + _, err := Load("") + if err == nil { + t.Fatal("expected error, got nil") + } + }) + + t.Run("uses team override when provided", func(t *testing.T) { + setup(t) + t.Setenv("LOOPS_API_KEY", "") + t.Setenv("LOOPS_ENDPOINT_URL", "") + Save("other-key", "other") + keyring.Set(keyringService, "key:acme", "acme-key") + + cfg, err := Load("acme") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if cfg.APIKey != "acme-key" { + t.Errorf("got %q, want %q", cfg.APIKey, "acme-key") + } + }) + + t.Run("errors when team override key not in keyring", func(t *testing.T) { + setup(t) + t.Setenv("LOOPS_API_KEY", "") + + _, err := Load("nonexistent") if err == nil { t.Fatal("expected error, got nil") } @@ -34,7 +60,7 @@ func TestLoad(t *testing.T) { } keyring.Delete(keyringService, "key:acme") - _, err := Load() + _, err := Load("") if err == nil { t.Fatal("expected error, got nil") } @@ -49,7 +75,7 @@ func TestLoad(t *testing.T) { t.Fatalf("Save: %v", err) } - cfg, err := Load() + cfg, err := Load("") if err != nil { t.Fatalf("unexpected error: %v", err) } @@ -66,7 +92,7 @@ func TestLoad(t *testing.T) { t.Setenv("LOOPS_API_KEY", "env-key") t.Setenv("LOOPS_ENDPOINT_URL", "") - cfg, err := Load() + cfg, err := Load("") if err != nil { t.Fatalf("unexpected error: %v", err) } @@ -80,7 +106,7 @@ func TestLoad(t *testing.T) { t.Setenv("LOOPS_API_KEY", "some-key") t.Setenv("LOOPS_ENDPOINT_URL", "") - cfg, err := Load() + cfg, err := Load("") if err != nil { t.Fatalf("unexpected error: %v", err) } @@ -94,7 +120,7 @@ func TestLoad(t *testing.T) { t.Setenv("LOOPS_API_KEY", "some-key") t.Setenv("LOOPS_ENDPOINT_URL", "https://custom.example.com/api") - cfg, err := Load() + cfg, err := Load("") if err != nil { t.Fatalf("unexpected error: %v", err) } From 7333455f338e06c309fec229af3bbb99dc63000c Mon Sep 17 00:00:00 2001 From: Nate Meyer <672246+notnmeyer@users.noreply.github.com> Date: Fri, 20 Mar 2026 21:57:35 -0700 Subject: [PATCH 06/12] add auth use command for switching teams, show active team on auth list --- cmd/auth_list.go | 23 +++++++++++--- cmd/auth_list_test.go | 22 +++++++++++-- cmd/auth_use.go | 58 ++++++++++++++++++++++++++++++++++ cmd/auth_use_test.go | 52 ++++++++++++++++++++++++++++++ internal/config/config.go | 12 +++++++ internal/config/config_test.go | 45 ++++++++++++++++++++++++++ 6 files changed, 205 insertions(+), 7 deletions(-) create mode 100644 cmd/auth_use.go create mode 100644 cmd/auth_use_test.go diff --git a/cmd/auth_list.go b/cmd/auth_list.go index bdc9a0b..6ac7709 100644 --- a/cmd/auth_list.go +++ b/cmd/auth_list.go @@ -12,7 +12,7 @@ var authListCmd = &cobra.Command{ Use: "list", Short: "List stored API keys", RunE: func(cmd *cobra.Command, args []string) error { - entries, err := runAuthList() + entries, activeTeam, err := runAuthList() if err != nil { return err } @@ -21,10 +21,11 @@ var authListCmd = &cobra.Command{ type jsonEntry struct { Name string `json:"name"` APIKey string `json:"apiKey"` + Active bool `json:"active"` } out := make([]jsonEntry, len(entries)) for i, e := range entries { - out[i] = jsonEntry{e.Name, maskKey(e.APIKey)} + out[i] = jsonEntry{e.Name, maskKey(e.APIKey), e.Name == activeTeam} } return printJSON(cmd.OutOrStdout(), out) } @@ -37,14 +38,26 @@ var authListCmd = &cobra.Command{ w := tabwriter.NewWriter(cmd.OutOrStdout(), 0, 0, 3, ' ', 0) fmt.Fprintln(w, "NAME\tAPI KEY") for _, e := range entries { - fmt.Fprintf(w, "%s\t%s\n", e.Name, maskKey(e.APIKey)) + name := e.Name + if e.Name == activeTeam { + name += " (active)" + } + fmt.Fprintf(w, "%s\t%s\n", name, maskKey(e.APIKey)) } return w.Flush() }, } -func runAuthList() ([]config.KeyEntry, error) { - return config.ListKeys() +func runAuthList() ([]config.KeyEntry, string, error) { + entries, err := config.ListKeys() + if err != nil { + return nil, "", err + } + pc, err := config.LoadPersistentConfig() + if err != nil { + return nil, "", err + } + return entries, pc.ActiveTeam, nil } func init() { diff --git a/cmd/auth_list_test.go b/cmd/auth_list_test.go index 39d9485..88ee97a 100644 --- a/cmd/auth_list_test.go +++ b/cmd/auth_list_test.go @@ -9,13 +9,16 @@ import ( func TestRunAuthList(t *testing.T) { t.Run("returns empty list when no keys stored", func(t *testing.T) { mockKeyring(t) - entries, err := runAuthList() + entries, activeTeam, err := runAuthList() if err != nil { t.Fatalf("unexpected error: %v", err) } if len(entries) != 0 { t.Errorf("got %d entries, want 0", len(entries)) } + if activeTeam != "" { + t.Errorf("got activeTeam %q, want empty", activeTeam) + } }) t.Run("returns stored keys", func(t *testing.T) { @@ -23,7 +26,7 @@ func TestRunAuthList(t *testing.T) { config.Save("key-abc1234", "acme") config.Save("key-xyz5678", "work") - entries, err := runAuthList() + entries, _, err := runAuthList() if err != nil { t.Fatalf("unexpected error: %v", err) } @@ -37,4 +40,19 @@ func TestRunAuthList(t *testing.T) { t.Errorf("entry 1: got {%q, %q}, want {work, key-xyz5678}", entries[1].Name, entries[1].APIKey) } }) + + t.Run("returns active team", func(t *testing.T) { + mockKeyring(t) + config.Save("key-abc1234", "acme") + config.Save("key-xyz5678", "work") + config.SetActiveTeam("acme") + + _, activeTeam, err := runAuthList() + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if activeTeam != "acme" { + t.Errorf("got activeTeam %q, want %q", activeTeam, "acme") + } + }) } diff --git a/cmd/auth_use.go b/cmd/auth_use.go new file mode 100644 index 0000000..7a7b615 --- /dev/null +++ b/cmd/auth_use.go @@ -0,0 +1,58 @@ +package cmd + +import ( + "errors" + "fmt" + + "github.com/loops-so/cli/internal/config" + "github.com/spf13/cobra" +) + +var clearActive bool + +var authUseCmd = &cobra.Command{ + Use: "use [name]", + Short: "Set or clear the active API key", + Args: cobra.MaximumNArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + if clearActive && len(args) > 0 { + return errors.New("cannot use --clear with a key name") + } + if !clearActive && len(args) == 0 { + return errors.New("provide a key name or --clear") + } + + name := "" + if len(args) > 0 { + name = args[0] + } + + if err := runAuthUse(name); err != nil { + return err + } + + if isJSONOutput() { + msg := fmt.Sprintf("Active team set to: %s", name) + if name == "" { + msg = "Active team cleared" + } + return printJSON(cmd.OutOrStdout(), Result{Success: true, Message: msg}) + } + + if name == "" { + fmt.Fprintln(cmd.OutOrStdout(), "Active team cleared.") + } else { + fmt.Fprintf(cmd.OutOrStdout(), "Active team set to %q.\n", name) + } + return nil + }, +} + +func runAuthUse(name string) error { + return config.SetActiveTeam(name) +} + +func init() { + authUseCmd.Flags().BoolVar(&clearActive, "clear", false, "Clear the active team") + authCmd.AddCommand(authUseCmd) +} diff --git a/cmd/auth_use_test.go b/cmd/auth_use_test.go new file mode 100644 index 0000000..163488f --- /dev/null +++ b/cmd/auth_use_test.go @@ -0,0 +1,52 @@ +package cmd + +import ( + "testing" + + "github.com/loops-so/cli/internal/config" +) + +func TestRunAuthUse(t *testing.T) { + t.Run("sets active team", func(t *testing.T) { + mockKeyring(t) + config.Save("key1", "acme") + config.Save("key2", "work") + + if err := runAuthUse("acme"); err != nil { + t.Fatalf("unexpected error: %v", err) + } + + pc, err := config.LoadPersistentConfig() + if err != nil { + t.Fatalf("LoadPersistentConfig: %v", err) + } + if pc.ActiveTeam != "acme" { + t.Errorf("got %q, want %q", pc.ActiveTeam, "acme") + } + }) + + t.Run("returns error when name not in teams list", func(t *testing.T) { + mockKeyring(t) + + if err := runAuthUse("nonexistent"); err == nil { + t.Fatal("expected error, got nil") + } + }) + + t.Run("clears active team", func(t *testing.T) { + mockKeyring(t) + config.Save("key1", "acme") + + if err := runAuthUse(""); err != nil { + t.Fatalf("unexpected error: %v", err) + } + + pc, err := config.LoadPersistentConfig() + if err != nil { + t.Fatalf("LoadPersistentConfig: %v", err) + } + if pc.ActiveTeam != "" { + t.Errorf("got %q, want empty", pc.ActiveTeam) + } + }) +} diff --git a/internal/config/config.go b/internal/config/config.go index d678769..139d9f9 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -134,6 +134,18 @@ func Save(apiKey, name string) error { return SavePersistentConfig(pc) } +func SetActiveTeam(name string) error { + pc, err := LoadPersistentConfig() + if err != nil { + return err + } + if name != "" && !slices.Contains(pc.Teams, name) { + return fmt.Errorf("no key named %q — run `loops auth list` to see available keys", name) + } + pc.ActiveTeam = name + return SavePersistentConfig(pc) +} + type KeyEntry struct { Name string APIKey string diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 15593f2..dae180c 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -220,6 +220,51 @@ func TestSave(t *testing.T) { }) } +func TestSetActiveTeam(t *testing.T) { + t.Run("sets active team", func(t *testing.T) { + setup(t) + Save("key1", "acme") + Save("key2", "work") // work is now active + + if err := SetActiveTeam("acme"); err != nil { + t.Fatalf("unexpected error: %v", err) + } + + pc, err := LoadPersistentConfig() + if err != nil { + t.Fatalf("LoadPersistentConfig: %v", err) + } + if pc.ActiveTeam != "acme" { + t.Errorf("got %q, want %q", pc.ActiveTeam, "acme") + } + }) + + t.Run("errors when name not in teams list", func(t *testing.T) { + setup(t) + + if err := SetActiveTeam("nonexistent"); err == nil { + t.Fatal("expected error, got nil") + } + }) + + t.Run("clears active team when name is empty", func(t *testing.T) { + setup(t) + Save("key1", "acme") + + if err := SetActiveTeam(""); err != nil { + t.Fatalf("unexpected error: %v", err) + } + + pc, err := LoadPersistentConfig() + if err != nil { + t.Fatalf("LoadPersistentConfig: %v", err) + } + if pc.ActiveTeam != "" { + t.Errorf("got %q, want empty", pc.ActiveTeam) + } + }) +} + func TestListKeys(t *testing.T) { t.Run("returns empty slice when no teams configured", func(t *testing.T) { setup(t) From b08a84d0dc71b197de4a27e87f9b7611452f7573 Mon Sep 17 00:00:00 2001 From: Nate Meyer <672246+notnmeyer@users.noreply.github.com> Date: Fri, 20 Mar 2026 22:27:10 -0700 Subject: [PATCH 07/12] trying to separate concerns between config resoulion and cred storage --- internal/config/config.go | 126 ---------------- internal/config/config_test.go | 249 -------------------------------- internal/config/store.go | 135 +++++++++++++++++ internal/config/store_test.go | 256 +++++++++++++++++++++++++++++++++ 4 files changed, 391 insertions(+), 375 deletions(-) create mode 100644 internal/config/store.go create mode 100644 internal/config/store_test.go diff --git a/internal/config/config.go b/internal/config/config.go index 139d9f9..f8a809d 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -4,11 +4,8 @@ import ( "errors" "fmt" "os" - "path/filepath" - "slices" "github.com/zalando/go-keyring" - "gopkg.in/yaml.v2" ) const ( @@ -21,60 +18,6 @@ type Config struct { EndpointURL string } -type PersistentConfig struct { - ActiveTeam string `yaml:"activeTeam"` - Teams []string `yaml:"teams"` -} - -func configFilePath() (string, error) { - if dir := os.Getenv("LOOPS_CONFIG_DIR"); dir != "" { - return filepath.Join(dir, "config.yml"), nil - } - dir, err := os.UserConfigDir() - if err != nil { - return "", fmt.Errorf("could not determine config directory: %w", err) - } - return filepath.Join(dir, "loops", "config.yml"), nil -} - -func LoadPersistentConfig() (*PersistentConfig, error) { - path, err := configFilePath() - if err != nil { - return nil, err - } - data, err := os.ReadFile(path) - if errors.Is(err, os.ErrNotExist) { - return &PersistentConfig{}, nil - } - if err != nil { - return nil, fmt.Errorf("could not read config file: %w", err) - } - var pc PersistentConfig - if err := yaml.Unmarshal(data, &pc); err != nil { - return nil, fmt.Errorf("could not parse config file: %w", err) - } - return &pc, nil -} - -func SavePersistentConfig(pc *PersistentConfig) error { - path, err := configFilePath() - if err != nil { - return err - } - if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil { - return fmt.Errorf("could not create config directory: %w", err) - } - data, err := yaml.Marshal(pc) - if err != nil { - return fmt.Errorf("could not marshal config: %w", err) - } - tmp := path + ".tmp" - if err := os.WriteFile(tmp, data, 0o600); err != nil { - return fmt.Errorf("could not write config file: %w", err) - } - return os.Rename(tmp, path) -} - func EndpointURL() string { if v := os.Getenv("LOOPS_ENDPOINT_URL"); v != "" { return v @@ -115,72 +58,3 @@ func Load(teamOverride string) (*Config, error) { return cfg, nil } - -func Save(apiKey, name string) error { - if name == "" { - return errors.New("a name is required — use --name to give this key a name") - } - if err := keyring.Set(keyringService, "key:"+name, apiKey); err != nil { - return fmt.Errorf("could not save to keyring: %w", err) - } - pc, err := LoadPersistentConfig() - if err != nil { - return err - } - if !slices.Contains(pc.Teams, name) { - pc.Teams = append(pc.Teams, name) - } - pc.ActiveTeam = name - return SavePersistentConfig(pc) -} - -func SetActiveTeam(name string) error { - pc, err := LoadPersistentConfig() - if err != nil { - return err - } - if name != "" && !slices.Contains(pc.Teams, name) { - return fmt.Errorf("no key named %q — run `loops auth list` to see available keys", name) - } - pc.ActiveTeam = name - return SavePersistentConfig(pc) -} - -type KeyEntry struct { - Name string - APIKey string -} - -func ListKeys() ([]KeyEntry, error) { - pc, err := LoadPersistentConfig() - if err != nil { - return nil, err - } - entries := make([]KeyEntry, 0, len(pc.Teams)) - for _, name := range pc.Teams { - key, err := keyring.Get(keyringService, "key:"+name) - if err != nil && !errors.Is(err, keyring.ErrNotFound) { - return nil, fmt.Errorf("could not read key %q: %w", name, err) - } - entries = append(entries, KeyEntry{Name: name, APIKey: key}) - } - return entries, nil -} - -func Delete(name string) error { - if name == "" { - return errors.New("a name is required — use --name to specify which key to remove") - } - if err := keyring.Delete(keyringService, "key:"+name); err != nil && !errors.Is(err, keyring.ErrNotFound) { - return fmt.Errorf("could not delete key %q from keyring: %w", name, err) - } - pc, err := LoadPersistentConfig() - if err != nil { - return err - } - pc.Teams = slices.DeleteFunc(pc.Teams, func(t string) bool { return t == name }) - if pc.ActiveTeam == name { - pc.ActiveTeam = "" - } - return SavePersistentConfig(pc) -} diff --git a/internal/config/config_test.go b/internal/config/config_test.go index dae180c..f9c668f 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -6,12 +6,6 @@ import ( "github.com/zalando/go-keyring" ) -func setup(t *testing.T) { - t.Helper() - keyring.MockInit() - t.Setenv("LOOPS_CONFIG_DIR", t.TempDir()) -} - func TestLoad(t *testing.T) { t.Run("errors when no credentials are set", func(t *testing.T) { setup(t) @@ -129,246 +123,3 @@ func TestLoad(t *testing.T) { } }) } - -func TestSave(t *testing.T) { - t.Run("errors when name is empty", func(t *testing.T) { - setup(t) - if err := Save("my-key", ""); err == nil { - t.Fatal("expected error, got nil") - } - }) - - t.Run("stores api key in keyring under team name", func(t *testing.T) { - setup(t) - - if err := Save("my-key", "acme"); err != nil { - t.Fatalf("unexpected error: %v", err) - } - - got, err := keyring.Get(keyringService, "key:acme") - if err != nil { - t.Fatalf("could not read keyring: %v", err) - } - if got != "my-key" { - t.Errorf("got %q, want %q", got, "my-key") - } - }) - - t.Run("writes config file with active team", func(t *testing.T) { - setup(t) - - if err := Save("my-key", "acme"); err != nil { - t.Fatalf("unexpected error: %v", err) - } - - pc, err := LoadPersistentConfig() - if err != nil { - t.Fatalf("LoadPersistentConfig: %v", err) - } - if pc.ActiveTeam != "acme" { - t.Errorf("activeTeam: got %q, want %q", pc.ActiveTeam, "acme") - } - if len(pc.Teams) != 1 || pc.Teams[0] != "acme" { - t.Errorf("teams: got %v, want [acme]", pc.Teams) - } - }) - - t.Run("overwrites existing key value in keyring", func(t *testing.T) { - setup(t) - Save("old-key", "acme") - - if err := Save("new-key", "acme"); err != nil { - t.Fatalf("unexpected error: %v", err) - } - - got, err := keyring.Get(keyringService, "key:acme") - if err != nil { - t.Fatalf("could not read keyring: %v", err) - } - if got != "new-key" { - t.Errorf("got %q, want %q", got, "new-key") - } - }) - - t.Run("appends new team without duplicates", func(t *testing.T) { - setup(t) - Save("key1", "acme") - Save("key2", "work") - Save("key3", "acme") // re-save same name - - pc, err := LoadPersistentConfig() - if err != nil { - t.Fatalf("LoadPersistentConfig: %v", err) - } - if len(pc.Teams) != 2 { - t.Errorf("teams: got %v, want [acme work]", pc.Teams) - } - }) - - t.Run("sets active team to the saved team", func(t *testing.T) { - setup(t) - Save("key1", "acme") - Save("key2", "work") - - pc, err := LoadPersistentConfig() - if err != nil { - t.Fatalf("LoadPersistentConfig: %v", err) - } - if pc.ActiveTeam != "work" { - t.Errorf("activeTeam: got %q, want %q", pc.ActiveTeam, "work") - } - }) -} - -func TestSetActiveTeam(t *testing.T) { - t.Run("sets active team", func(t *testing.T) { - setup(t) - Save("key1", "acme") - Save("key2", "work") // work is now active - - if err := SetActiveTeam("acme"); err != nil { - t.Fatalf("unexpected error: %v", err) - } - - pc, err := LoadPersistentConfig() - if err != nil { - t.Fatalf("LoadPersistentConfig: %v", err) - } - if pc.ActiveTeam != "acme" { - t.Errorf("got %q, want %q", pc.ActiveTeam, "acme") - } - }) - - t.Run("errors when name not in teams list", func(t *testing.T) { - setup(t) - - if err := SetActiveTeam("nonexistent"); err == nil { - t.Fatal("expected error, got nil") - } - }) - - t.Run("clears active team when name is empty", func(t *testing.T) { - setup(t) - Save("key1", "acme") - - if err := SetActiveTeam(""); err != nil { - t.Fatalf("unexpected error: %v", err) - } - - pc, err := LoadPersistentConfig() - if err != nil { - t.Fatalf("LoadPersistentConfig: %v", err) - } - if pc.ActiveTeam != "" { - t.Errorf("got %q, want empty", pc.ActiveTeam) - } - }) -} - -func TestListKeys(t *testing.T) { - t.Run("returns empty slice when no teams configured", func(t *testing.T) { - setup(t) - - entries, err := ListKeys() - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - if len(entries) != 0 { - t.Errorf("got %d entries, want 0", len(entries)) - } - }) - - t.Run("returns keys in config order", func(t *testing.T) { - setup(t) - Save("key-a", "acme") - Save("key-b", "work") - - entries, err := ListKeys() - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - if len(entries) != 2 { - t.Fatalf("got %d entries, want 2", len(entries)) - } - if entries[0].Name != "acme" || entries[0].APIKey != "key-a" { - t.Errorf("entry 0: got {%q, %q}, want {acme, key-a}", entries[0].Name, entries[0].APIKey) - } - if entries[1].Name != "work" || entries[1].APIKey != "key-b" { - t.Errorf("entry 1: got {%q, %q}, want {work, key-b}", entries[1].Name, entries[1].APIKey) - } - }) - - t.Run("returns empty api key when keyring entry missing", func(t *testing.T) { - setup(t) - Save("key-a", "acme") - keyring.Delete(keyringService, "key:acme") - - entries, err := ListKeys() - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - if len(entries) != 1 { - t.Fatalf("got %d entries, want 1", len(entries)) - } - if entries[0].APIKey != "" { - t.Errorf("got %q, want empty string", entries[0].APIKey) - } - }) -} - -func TestDelete(t *testing.T) { - t.Run("errors when name is empty", func(t *testing.T) { - setup(t) - if err := Delete(""); err == nil { - t.Fatal("expected error, got nil") - } - }) - - t.Run("removes the named key from keyring and teams list", func(t *testing.T) { - setup(t) - Save("key1", "acme") - Save("key2", "work") - - if err := Delete("acme"); err != nil { - t.Fatalf("unexpected error: %v", err) - } - - _, err := keyring.Get(keyringService, "key:acme") - if err != keyring.ErrNotFound { - t.Errorf("expected ErrNotFound for acme, got %v", err) - } - - pc, err := LoadPersistentConfig() - if err != nil { - t.Fatalf("LoadPersistentConfig: %v", err) - } - if len(pc.Teams) != 1 || pc.Teams[0] != "work" { - t.Errorf("teams: got %v, want [work]", pc.Teams) - } - }) - - t.Run("clears activeTeam when the active team is deleted", func(t *testing.T) { - setup(t) - Save("key1", "acme") - Save("key2", "work") // work is now active - - if err := Delete("work"); err != nil { - t.Fatalf("unexpected error: %v", err) - } - - pc, err := LoadPersistentConfig() - if err != nil { - t.Fatalf("LoadPersistentConfig: %v", err) - } - if pc.ActiveTeam != "" { - t.Errorf("activeTeam: got %q, want empty", pc.ActiveTeam) - } - }) - - t.Run("no error when key not in keyring", func(t *testing.T) { - setup(t) - if err := Delete("nonexistent"); err != nil { - t.Fatalf("unexpected error: %v", err) - } - }) -} diff --git a/internal/config/store.go b/internal/config/store.go new file mode 100644 index 0000000..cc4aa00 --- /dev/null +++ b/internal/config/store.go @@ -0,0 +1,135 @@ +package config + +import ( + "errors" + "fmt" + "os" + "path/filepath" + "slices" + + "github.com/zalando/go-keyring" + "gopkg.in/yaml.v2" +) + +type PersistentConfig struct { + ActiveTeam string `yaml:"activeTeam"` + Teams []string `yaml:"teams"` +} + +type KeyEntry struct { + Name string + APIKey string +} + +func configFilePath() (string, error) { + if dir := os.Getenv("LOOPS_CONFIG_DIR"); dir != "" { + return filepath.Join(dir, "config.yml"), nil + } + dir, err := os.UserConfigDir() + if err != nil { + return "", fmt.Errorf("could not determine config directory: %w", err) + } + return filepath.Join(dir, "loops", "config.yml"), nil +} + +func LoadPersistentConfig() (*PersistentConfig, error) { + path, err := configFilePath() + if err != nil { + return nil, err + } + data, err := os.ReadFile(path) + if errors.Is(err, os.ErrNotExist) { + return &PersistentConfig{}, nil + } + if err != nil { + return nil, fmt.Errorf("could not read config file: %w", err) + } + var pc PersistentConfig + if err := yaml.Unmarshal(data, &pc); err != nil { + return nil, fmt.Errorf("could not parse config file: %w", err) + } + return &pc, nil +} + +func SavePersistentConfig(pc *PersistentConfig) error { + path, err := configFilePath() + if err != nil { + return err + } + if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil { + return fmt.Errorf("could not create config directory: %w", err) + } + data, err := yaml.Marshal(pc) + if err != nil { + return fmt.Errorf("could not marshal config: %w", err) + } + tmp := path + ".tmp" + if err := os.WriteFile(tmp, data, 0o600); err != nil { + return fmt.Errorf("could not write config file: %w", err) + } + return os.Rename(tmp, path) +} + +func Save(apiKey, name string) error { + if name == "" { + return errors.New("a name is required — use --name to give this key a name") + } + if err := keyring.Set(keyringService, "key:"+name, apiKey); err != nil { + return fmt.Errorf("could not save to keyring: %w", err) + } + pc, err := LoadPersistentConfig() + if err != nil { + return err + } + if !slices.Contains(pc.Teams, name) { + pc.Teams = append(pc.Teams, name) + } + pc.ActiveTeam = name + return SavePersistentConfig(pc) +} + +func SetActiveTeam(name string) error { + pc, err := LoadPersistentConfig() + if err != nil { + return err + } + if name != "" && !slices.Contains(pc.Teams, name) { + return fmt.Errorf("no key named %q — run `loops auth list` to see available keys", name) + } + pc.ActiveTeam = name + return SavePersistentConfig(pc) +} + +func ListKeys() ([]KeyEntry, error) { + pc, err := LoadPersistentConfig() + if err != nil { + return nil, err + } + entries := make([]KeyEntry, 0, len(pc.Teams)) + for _, name := range pc.Teams { + key, err := keyring.Get(keyringService, "key:"+name) + if err != nil && !errors.Is(err, keyring.ErrNotFound) { + return nil, fmt.Errorf("could not read key %q: %w", name, err) + } + entries = append(entries, KeyEntry{Name: name, APIKey: key}) + } + return entries, nil +} + +func Delete(name string) error { + if name == "" { + return errors.New("a name is required — use --name to specify which key to remove") + } + if err := keyring.Delete(keyringService, "key:"+name); err != nil && !errors.Is(err, keyring.ErrNotFound) { + return fmt.Errorf("could not delete key %q from keyring: %w", name, err) + } + pc, err := LoadPersistentConfig() + if err != nil { + return err + } + pc.Teams = slices.DeleteFunc(pc.Teams, func(t string) bool { return t == name }) + if pc.ActiveTeam == name { + pc.ActiveTeam = "" + } + return SavePersistentConfig(pc) +} diff --git a/internal/config/store_test.go b/internal/config/store_test.go new file mode 100644 index 0000000..fb09630 --- /dev/null +++ b/internal/config/store_test.go @@ -0,0 +1,256 @@ +package config + +import ( + "testing" + + "github.com/zalando/go-keyring" +) + +func setup(t *testing.T) { + t.Helper() + keyring.MockInit() + t.Setenv("LOOPS_CONFIG_DIR", t.TempDir()) +} + +func TestSave(t *testing.T) { + t.Run("errors when name is empty", func(t *testing.T) { + setup(t) + if err := Save("my-key", ""); err == nil { + t.Fatal("expected error, got nil") + } + }) + + t.Run("stores api key in keyring under team name", func(t *testing.T) { + setup(t) + + if err := Save("my-key", "acme"); err != nil { + t.Fatalf("unexpected error: %v", err) + } + + got, err := keyring.Get(keyringService, "key:acme") + if err != nil { + t.Fatalf("could not read keyring: %v", err) + } + if got != "my-key" { + t.Errorf("got %q, want %q", got, "my-key") + } + }) + + t.Run("writes config file with active team", func(t *testing.T) { + setup(t) + + if err := Save("my-key", "acme"); err != nil { + t.Fatalf("unexpected error: %v", err) + } + + pc, err := LoadPersistentConfig() + if err != nil { + t.Fatalf("LoadPersistentConfig: %v", err) + } + if pc.ActiveTeam != "acme" { + t.Errorf("activeTeam: got %q, want %q", pc.ActiveTeam, "acme") + } + if len(pc.Teams) != 1 || pc.Teams[0] != "acme" { + t.Errorf("teams: got %v, want [acme]", pc.Teams) + } + }) + + t.Run("overwrites existing key value in keyring", func(t *testing.T) { + setup(t) + Save("old-key", "acme") + + if err := Save("new-key", "acme"); err != nil { + t.Fatalf("unexpected error: %v", err) + } + + got, err := keyring.Get(keyringService, "key:acme") + if err != nil { + t.Fatalf("could not read keyring: %v", err) + } + if got != "new-key" { + t.Errorf("got %q, want %q", got, "new-key") + } + }) + + t.Run("appends new team without duplicates", func(t *testing.T) { + setup(t) + Save("key1", "acme") + Save("key2", "work") + Save("key3", "acme") // re-save same name + + pc, err := LoadPersistentConfig() + if err != nil { + t.Fatalf("LoadPersistentConfig: %v", err) + } + if len(pc.Teams) != 2 { + t.Errorf("teams: got %v, want [acme work]", pc.Teams) + } + }) + + t.Run("sets active team to the saved team", func(t *testing.T) { + setup(t) + Save("key1", "acme") + Save("key2", "work") + + pc, err := LoadPersistentConfig() + if err != nil { + t.Fatalf("LoadPersistentConfig: %v", err) + } + if pc.ActiveTeam != "work" { + t.Errorf("activeTeam: got %q, want %q", pc.ActiveTeam, "work") + } + }) +} + +func TestSetActiveTeam(t *testing.T) { + t.Run("sets active team", func(t *testing.T) { + setup(t) + Save("key1", "acme") + Save("key2", "work") // work is now active + + if err := SetActiveTeam("acme"); err != nil { + t.Fatalf("unexpected error: %v", err) + } + + pc, err := LoadPersistentConfig() + if err != nil { + t.Fatalf("LoadPersistentConfig: %v", err) + } + if pc.ActiveTeam != "acme" { + t.Errorf("got %q, want %q", pc.ActiveTeam, "acme") + } + }) + + t.Run("errors when name not in teams list", func(t *testing.T) { + setup(t) + + if err := SetActiveTeam("nonexistent"); err == nil { + t.Fatal("expected error, got nil") + } + }) + + t.Run("clears active team when name is empty", func(t *testing.T) { + setup(t) + Save("key1", "acme") + + if err := SetActiveTeam(""); err != nil { + t.Fatalf("unexpected error: %v", err) + } + + pc, err := LoadPersistentConfig() + if err != nil { + t.Fatalf("LoadPersistentConfig: %v", err) + } + if pc.ActiveTeam != "" { + t.Errorf("got %q, want empty", pc.ActiveTeam) + } + }) +} + +func TestListKeys(t *testing.T) { + t.Run("returns empty slice when no teams configured", func(t *testing.T) { + setup(t) + + entries, err := ListKeys() + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(entries) != 0 { + t.Errorf("got %d entries, want 0", len(entries)) + } + }) + + t.Run("returns keys in config order", func(t *testing.T) { + setup(t) + Save("key-a", "acme") + Save("key-b", "work") + + entries, err := ListKeys() + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(entries) != 2 { + t.Fatalf("got %d entries, want 2", len(entries)) + } + if entries[0].Name != "acme" || entries[0].APIKey != "key-a" { + t.Errorf("entry 0: got {%q, %q}, want {acme, key-a}", entries[0].Name, entries[0].APIKey) + } + if entries[1].Name != "work" || entries[1].APIKey != "key-b" { + t.Errorf("entry 1: got {%q, %q}, want {work, key-b}", entries[1].Name, entries[1].APIKey) + } + }) + + t.Run("returns empty api key when keyring entry missing", func(t *testing.T) { + setup(t) + Save("key-a", "acme") + keyring.Delete(keyringService, "key:acme") + + entries, err := ListKeys() + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(entries) != 1 { + t.Fatalf("got %d entries, want 1", len(entries)) + } + if entries[0].APIKey != "" { + t.Errorf("got %q, want empty string", entries[0].APIKey) + } + }) +} + +func TestDelete(t *testing.T) { + t.Run("errors when name is empty", func(t *testing.T) { + setup(t) + if err := Delete(""); err == nil { + t.Fatal("expected error, got nil") + } + }) + + t.Run("removes the named key from keyring and teams list", func(t *testing.T) { + setup(t) + Save("key1", "acme") + Save("key2", "work") + + if err := Delete("acme"); err != nil { + t.Fatalf("unexpected error: %v", err) + } + + _, err := keyring.Get(keyringService, "key:acme") + if err != keyring.ErrNotFound { + t.Errorf("expected ErrNotFound for acme, got %v", err) + } + + pc, err := LoadPersistentConfig() + if err != nil { + t.Fatalf("LoadPersistentConfig: %v", err) + } + if len(pc.Teams) != 1 || pc.Teams[0] != "work" { + t.Errorf("teams: got %v, want [work]", pc.Teams) + } + }) + + t.Run("clears activeTeam when the active team is deleted", func(t *testing.T) { + setup(t) + Save("key1", "acme") + Save("key2", "work") // work is now active + + if err := Delete("work"); err != nil { + t.Fatalf("unexpected error: %v", err) + } + + pc, err := LoadPersistentConfig() + if err != nil { + t.Fatalf("LoadPersistentConfig: %v", err) + } + if pc.ActiveTeam != "" { + t.Errorf("activeTeam: got %q, want empty", pc.ActiveTeam) + } + }) + + t.Run("no error when key not in keyring", func(t *testing.T) { + setup(t) + if err := Delete("nonexistent"); err != nil { + t.Fatalf("unexpected error: %v", err) + } + }) +} From 1ca13c070ac3c3bbe1e39a7fec566a20e3b8ecee Mon Sep 17 00:00:00 2001 From: Nate Meyer <672246+notnmeyer@users.noreply.github.com> Date: Fri, 20 Mar 2026 22:31:03 -0700 Subject: [PATCH 08/12] use our tablewriter helper for consistency --- cmd/auth_list.go | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/cmd/auth_list.go b/cmd/auth_list.go index 6ac7709..04966aa 100644 --- a/cmd/auth_list.go +++ b/cmd/auth_list.go @@ -2,7 +2,6 @@ package cmd import ( "fmt" - "text/tabwriter" "github.com/loops-so/cli/internal/config" "github.com/spf13/cobra" @@ -35,7 +34,7 @@ var authListCmd = &cobra.Command{ return nil } - w := tabwriter.NewWriter(cmd.OutOrStdout(), 0, 0, 3, ' ', 0) + w := newTableWriter(cmd.OutOrStdout()) fmt.Fprintln(w, "NAME\tAPI KEY") for _, e := range entries { name := e.Name From c05ed6ab22645290a3511a2d92057088f835defd Mon Sep 17 00:00:00 2001 From: Nate Meyer <672246+notnmeyer@users.noreply.github.com> Date: Fri, 20 Mar 2026 22:33:06 -0700 Subject: [PATCH 09/12] use better example names --- cmd/auth_login.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/cmd/auth_login.go b/cmd/auth_login.go index c3cc160..c7992a9 100644 --- a/cmd/auth_login.go +++ b/cmd/auth_login.go @@ -19,7 +19,7 @@ var loginCmd = &cobra.Command{ Short: "Authenticate with your Loops API key", RunE: func(cmd *cobra.Command, args []string) error { if loginName == "" { - return errors.New("use --name to give this key a name (e.g. loops auth login --name loops-prod)") + return errors.New("use --name to give this key a name (e.g. loops auth login --name my-team)") } fmt.Fprint(os.Stderr, "Enter your API key: ") @@ -62,6 +62,6 @@ func runAuthLogin(apiKey, name string) (*api.APIKeyResponse, error) { } func init() { - loginCmd.Flags().StringVarP(&loginName, "name", "n", "", "Name for this API key (e.g. loops-prod)") + loginCmd.Flags().StringVarP(&loginName, "name", "n", "", "Name for this API key (e.g. my-team)") authCmd.AddCommand(loginCmd) } From 22b5af0af01049eaccb76bf3756645101bd35cab Mon Sep 17 00:00:00 2001 From: Nate Meyer <672246+notnmeyer@users.noreply.github.com> Date: Fri, 20 Mar 2026 22:36:46 -0700 Subject: [PATCH 10/12] fix this test --- cmd/auth_logout_test.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cmd/auth_logout_test.go b/cmd/auth_logout_test.go index 9921cd2..4872bac 100644 --- a/cmd/auth_logout_test.go +++ b/cmd/auth_logout_test.go @@ -15,7 +15,7 @@ func TestRunAuthLogout(t *testing.T) { }) t.Run("returns error when name is empty", func(t *testing.T) { - serveJSON(t, http.StatusOK, `{}`) + mockKeyring(t) if err := runAuthLogout(""); err == nil { t.Fatal("expected error, got nil") } From bcd08b1744290d952cd5c27ff7679a7b2a4e9fd8 Mon Sep 17 00:00:00 2001 From: Nate Meyer <672246+notnmeyer@users.noreply.github.com> Date: Mon, 23 Mar 2026 07:11:30 -0700 Subject: [PATCH 11/12] use a saved key when it is not active if it is the only one --- internal/config/config.go | 5 +++++ internal/config/config_test.go | 17 +++++++++++++++++ 2 files changed, 22 insertions(+) diff --git a/internal/config/config.go b/internal/config/config.go index f8a809d..ce429ff 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -42,6 +42,11 @@ func Load(teamOverride string) (*Config, error) { return nil, err } team = pc.ActiveTeam + + // if there is only one key, use it even if its not active + if team == "" && len(pc.Teams) == 1 { + team = pc.Teams[0] + } } if team != "" { diff --git a/internal/config/config_test.go b/internal/config/config_test.go index f9c668f..8b51257 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -60,6 +60,23 @@ func TestLoad(t *testing.T) { } }) + t.Run("uses sole key when no active team is set", func(t *testing.T) { + setup(t) + t.Setenv("LOOPS_API_KEY", "") + t.Setenv("LOOPS_ENDPOINT_URL", "") + + keyring.Set(keyringService, "key:acme", "acme-key") + SavePersistentConfig(&PersistentConfig{Teams: []string{"acme"}}) + + cfg, err := Load("") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if cfg.APIKey != "acme-key" { + t.Errorf("got %q, want %q", cfg.APIKey, "acme-key") + } + }) + t.Run("reads api key from active team", func(t *testing.T) { setup(t) t.Setenv("LOOPS_API_KEY", "") From b78b488e498aaa4e44c306f63cae9a652678f77e Mon Sep 17 00:00:00 2001 From: Nate Meyer <672246+notnmeyer@users.noreply.github.com> Date: Mon, 23 Mar 2026 07:20:54 -0700 Subject: [PATCH 12/12] auth list use "active" column --- cmd/auth_list.go | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/cmd/auth_list.go b/cmd/auth_list.go index 04966aa..61fff61 100644 --- a/cmd/auth_list.go +++ b/cmd/auth_list.go @@ -35,13 +35,13 @@ var authListCmd = &cobra.Command{ } w := newTableWriter(cmd.OutOrStdout()) - fmt.Fprintln(w, "NAME\tAPI KEY") + fmt.Fprintln(w, "NAME\tAPI KEY\tACTIVE") for _, e := range entries { - name := e.Name + active := "" if e.Name == activeTeam { - name += " (active)" + active = "*" } - fmt.Fprintf(w, "%s\t%s\n", name, maskKey(e.APIKey)) + fmt.Fprintf(w, "%s\t%s\t%s\n", e.Name, maskKey(e.APIKey), active) } return w.Flush() },