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
4 changes: 4 additions & 0 deletions internal/app/auth/grants.go
Original file line number Diff line number Diff line change
Expand Up @@ -182,6 +182,10 @@ func PersistDefaultGrant(config ports.ConfigStore, grantStore ports.GrantStore,
}

cfgToSave.DefaultGrant = grantID
// Drop the legacy in-memory grants slice — grant metadata lives in
// the grant cache now, and config.Grants is a transient field that
// shouldn't be re-persisted from older config snapshots.
cfgToSave.Grants = nil
if err := config.Save(cfgToSave); err != nil {
return err
}
Expand Down
79 changes: 47 additions & 32 deletions internal/app/auth/service.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import (
"crypto/rand"
"crypto/sha256"
"encoding/base64"
"fmt"

"github.com/nylas/cli/internal/domain"
"github.com/nylas/cli/internal/ports"
Expand Down Expand Up @@ -92,11 +93,19 @@ func (s *Service) Login(ctx context.Context, provider domain.Provider) (*domain.
return nil, err
}

// Set as default if no default exists.
if _, err := s.grantStore.GetDefaultGrant(); err == domain.ErrNoDefaultGrant {
_ = s.grantStore.SetDefaultGrant(grant.ID)
// PersistDefaultGrant writes both the grant cache and config.yaml so every
// reader (CLI, Air, UI, TUI) observes the same value. New grants become
// default only when no default exists; otherwise we mirror the existing
// default into config.yaml defensively.
defaultGrant, err := s.grantStore.GetDefaultGrant()
if err == domain.ErrNoDefaultGrant {
defaultGrant = grant.ID
} else if err != nil {
return nil, fmt.Errorf("failed to read default grant: %w", err)
}
if err := PersistDefaultGrant(s.config, s.grantStore, defaultGrant); err != nil {
return nil, fmt.Errorf("failed to persist default grant: %w", err)
}
s.syncConfigWithGrantStore()

return grant, nil
}
Expand All @@ -119,7 +128,9 @@ func (s *Service) Logout(ctx context.Context) error {
}

// Auto-switch to another grant if available
s.autoSwitchDefault()
if err := s.autoSwitchDefault(); err != nil {
return fmt.Errorf("failed to update default grant: %w", err)
}

return nil
}
Expand All @@ -142,9 +153,13 @@ func (s *Service) LogoutGrant(ctx context.Context, grantID string) error {

// Auto-switch to another grant if we deleted the default
if isDefault {
s.autoSwitchDefault()
if err := s.autoSwitchDefault(); err != nil {
return fmt.Errorf("failed to update default grant: %w", err)
}
} else {
s.syncConfigWithGrantStore()
if err := s.syncConfigWithGrantStore(); err != nil {
return fmt.Errorf("failed to sync default grant: %w", err)
}
}

return nil
Expand All @@ -160,49 +175,49 @@ func (s *Service) RemoveLocalGrant(grantID string) error {
}

if isDefault {
s.autoSwitchDefault()
if err := s.autoSwitchDefault(); err != nil {
return fmt.Errorf("failed to update default grant: %w", err)
}
} else {
s.syncConfigWithGrantStore()
if err := s.syncConfigWithGrantStore(); err != nil {
return fmt.Errorf("failed to sync default grant: %w", err)
}
}

return nil
}

// autoSwitchDefault sets a new default grant from remaining grants.
func (s *Service) autoSwitchDefault() {
// autoSwitchDefault sets a new default grant from remaining grants. Both
// branches route through PersistDefaultGrant so config.yaml stays in sync
// with the grant cache.
func (s *Service) autoSwitchDefault() error {
grants, err := s.grantStore.ListGrants()
if err != nil {
return
return err
}
if len(grants) == 0 {
// No remaining grants - clear the default
_ = s.grantStore.ClearGrants()
s.syncConfigWithGrantStore()
return
// No remaining grants — clear the cache file and the mirrored default.
if err := s.grantStore.ClearGrants(); err != nil {
return err
}
return PersistDefaultGrant(s.config, s.grantStore, "")
}
// Set the first remaining grant as default
if err := s.grantStore.SetDefaultGrant(grants[0].ID); err != nil {
return
}
s.syncConfigWithGrantStore()
return PersistDefaultGrant(s.config, s.grantStore, grants[0].ID)
}

func (s *Service) syncConfigWithGrantStore() {
// syncConfigWithGrantStore mirrors the grant cache's current default into
// config.yaml. Used as a defensive sync after operations that mutate the
// grant cache without changing the default (e.g. removing a non-default
// grant) — the cached value is read and re-persisted via PersistDefaultGrant
// so both stores stay in lockstep.
func (s *Service) syncConfigWithGrantStore() error {
defaultGrant, err := s.grantStore.GetDefaultGrant()
if err == domain.ErrNoDefaultGrant {
defaultGrant = ""
} else if err != nil {
return
}

cfg, err := s.config.Load()
if err != nil {
return
return err
}

cfg.Grants = nil
cfg.DefaultGrant = defaultGrant
_ = s.config.Save(cfg)
return PersistDefaultGrant(s.config, s.grantStore, defaultGrant)
}

func generateOAuthState() (string, error) {
Expand Down
71 changes: 67 additions & 4 deletions internal/app/auth/service_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -134,7 +134,7 @@ func TestService_autoSwitchDefault(t *testing.T) {
}

// Call autoSwitchDefault with no grants
svc.autoSwitchDefault()
require.NoError(t, svc.autoSwitchDefault())

// Default should be cleared
_, err := grantStore.GetDefaultGrant()
Expand All @@ -157,7 +157,7 @@ func TestService_autoSwitchDefault(t *testing.T) {
}

// Call autoSwitchDefault
svc.autoSwitchDefault()
require.NoError(t, svc.autoSwitchDefault())

// A default should be set (one of the remaining grants)
defaultID, err := grantStore.GetDefaultGrant()
Expand All @@ -180,7 +180,7 @@ func TestService_syncConfigWithGrantStoreStoresOnlyDefaultGrant(t *testing.T) {
config: configStore,
}

svc.syncConfigWithGrantStore()
require.NoError(t, svc.syncConfigWithGrantStore())

assert.Equal(t, "grant-2", configStore.config.DefaultGrant)
assert.Empty(t, configStore.config.Grants)
Expand Down Expand Up @@ -230,7 +230,7 @@ func TestService_FirstGrantBecomesDefault(t *testing.T) {

// Logout - delete grant and auto-switch (simulating Logout behavior)
require.NoError(t, grantStore.DeleteGrant("grant-1"))
svc.autoSwitchDefault()
require.NoError(t, svc.autoSwitchDefault())

// Verify default is cleared (no grants remain)
_, err = grantStore.GetDefaultGrant()
Expand Down Expand Up @@ -396,6 +396,69 @@ func TestService_Login(t *testing.T) {
assert.Empty(t, configStore.config.Grants)
})

t.Run("default persistence failure returns error", func(t *testing.T) {
client := nylas.NewMockClient()
client.ExchangeCodeFunc = func(ctx context.Context, code, redirectURI, codeVerifier string) (*domain.Grant, error) {
return &domain.Grant{
ID: "grant-123",
Email: "user@example.com",
Provider: domain.ProviderGoogle,
}, nil
}

grantStore := newMockGrantStore()
configStore := &failingSaveConfigStore{config: &domain.Config{Region: "us"}}
server := &mockOAuthServer{
redirectURI: "http://localhost:8080/callback",
code: "auth-code-123",
}
browser := &mockBrowser{}

svc := NewService(client, grantStore, configStore, server, browser)

grant, err := svc.Login(context.Background(), domain.ProviderGoogle)

require.Error(t, err)
assert.Nil(t, grant)
assert.ErrorIs(t, err, domain.ErrInvalidInput)
_, defaultErr := grantStore.GetDefaultGrant()
assert.ErrorIs(t, defaultErr, domain.ErrNoDefaultGrant)
})

t.Run("existing default is mirrored to config after login", func(t *testing.T) {
client := nylas.NewMockClient()
client.ExchangeCodeFunc = func(ctx context.Context, code, redirectURI, codeVerifier string) (*domain.Grant, error) {
return &domain.Grant{
ID: "grant-2",
Email: "second@example.com",
Provider: domain.ProviderMicrosoft,
}, nil
}

grantStore := newMockGrantStore()
require.NoError(t, grantStore.SaveGrant(domain.GrantInfo{ID: "grant-1", Email: "first@example.com", Provider: domain.ProviderGoogle}))
require.NoError(t, grantStore.SetDefaultGrant("grant-1"))
configStore := newMockConfigStore()
configStore.config.DefaultGrant = "stale-config-default"
server := &mockOAuthServer{
redirectURI: "http://localhost:8080/callback",
code: "auth-code-123",
}
browser := &mockBrowser{}

svc := NewService(client, grantStore, configStore, server, browser)

grant, err := svc.Login(context.Background(), domain.ProviderMicrosoft)

require.NoError(t, err)
require.NotNil(t, grant)
defaultID, err := grantStore.GetDefaultGrant()
require.NoError(t, err)
assert.Equal(t, "grant-1", defaultID)
assert.Equal(t, "grant-1", configStore.config.DefaultGrant)
assert.Empty(t, configStore.config.Grants)
})

t.Run("server start failure returns error", func(t *testing.T) {
server := &mockOAuthServer{
startErr: errors.New("server start failed"),
Expand Down
25 changes: 12 additions & 13 deletions internal/cli/auth/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -177,9 +177,9 @@ The CLI only requires your API Key - Client ID is auto-detected.`,
return nil
}

result, err := setup.SyncGrants(grantStore, apiKey, clientID, region)
result, err := setup.SyncGrants(configStore, grantStore, apiKey, clientID, region)
if err != nil {
_, _ = common.Yellow.Printf(" Could not fetch grants: %v\n", err)
_, _ = common.Yellow.Printf(" Could not sync grants: %v\n", err)
fmt.Println()
fmt.Println("Next steps:")
fmt.Println(" nylas auth login Authenticate with your email provider")
Expand All @@ -194,15 +194,21 @@ The CLI only requires your API Key - Client ID is auto-detected.`,
return nil
}

// Set default grant
// Set default grant. Both branches persist via PersistDefaultGrant
// inside SyncGrants/PromptDefaultGrant — no separate config.yaml
// write needed here.
defaultGrantID := result.DefaultGrantID
if defaultGrantID != "" {
// Single grant, auto-selected
// Single grant, auto-selected by SyncGrants.
fmt.Println()
_, _ = common.Green.Printf("✓ Set %s as default account\n", result.ValidGrants[0].Email)
} else if len(result.ValidGrants) > 1 {
// Multiple grants, prompt
defaultGrantID, _ = setup.PromptDefaultGrant(grantStore, result.ValidGrants)
// Multiple grants — PromptDefaultGrant persists the choice.
var promptErr error
defaultGrantID, promptErr = setup.PromptDefaultGrant(configStore, grantStore, result.ValidGrants)
if promptErr != nil {
return promptErr
}
for _, g := range result.ValidGrants {
if g.ID == defaultGrantID {
_, _ = common.Green.Printf("✓ Set %s as default account\n", g.Email)
Expand All @@ -214,13 +220,6 @@ The CLI only requires your API Key - Client ID is auto-detected.`,
fmt.Println()
fmt.Printf("Added %d grant(s). Run 'nylas auth list' to see all accounts.\n", len(result.ValidGrants))

// Update config file with the local default grant preference.
cfg.DefaultGrant = defaultGrantID
cfg.Grants = nil
if err := configStore.Save(cfg); err != nil {
_, _ = common.Yellow.Printf(" Warning: Could not update config file: %v\n", err)
}

return nil
},
}
Expand Down
26 changes: 19 additions & 7 deletions internal/cli/setup/grants.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import (
"fmt"

nylasadapter "github.com/nylas/cli/internal/adapters/nylas"
authapp "github.com/nylas/cli/internal/app/auth"
"github.com/nylas/cli/internal/cli/common"
"github.com/nylas/cli/internal/domain"
"github.com/nylas/cli/internal/ports"
Expand All @@ -24,18 +25,22 @@ type SyncResult struct {
// It returns the list of valid grants and the chosen default grant ID.
// The caller is responsible for setting the default if multiple grants exist
// (use PromptDefaultGrant for interactive selection).
func SyncGrants(grantStore ports.GrantStore, apiKey, clientID, region string) (*SyncResult, error) {
//
// When exactly one valid grant is returned, it is auto-selected as the default
// and persisted to both the grant cache and config.yaml via PersistDefaultGrant
// so every reader observes the same value.
func SyncGrants(configStore ports.ConfigStore, grantStore ports.GrantStore, apiKey, clientID, region string) (*SyncResult, error) {
client := nylasadapter.NewHTTPClient()
client.SetRegion(region)
client.SetCredentials(clientID, "", apiKey)

ctx, cancel := common.CreateContext()
defer cancel()

return syncGrantsWithClient(ctx, grantStore, client)
return syncGrantsWithClient(ctx, configStore, grantStore, client)
}

func syncGrantsWithClient(ctx context.Context, grantStore ports.GrantStore, client grantLister) (*SyncResult, error) {
func syncGrantsWithClient(ctx context.Context, configStore ports.ConfigStore, grantStore ports.GrantStore, client grantLister) (*SyncResult, error) {
grants, err := client.ListAllGrants(ctx, nil)
if err != nil {
return nil, fmt.Errorf("could not fetch grants: %w", err)
Expand Down Expand Up @@ -67,17 +72,22 @@ func syncGrantsWithClient(ctx context.Context, grantStore ports.GrantStore, clie
ValidGrants: validGrants,
}

// Auto-set default if there's exactly one valid grant.
// Auto-set default if there's exactly one valid grant. Route through
// PersistDefaultGrant so config.yaml stays in sync with the grant cache.
if len(validGrants) == 1 {
result.DefaultGrantID = validGrants[0].ID
_ = grantStore.SetDefaultGrant(result.DefaultGrantID)
if err := authapp.PersistDefaultGrant(configStore, grantStore, result.DefaultGrantID); err != nil {
return nil, fmt.Errorf("could not persist default grant: %w", err)
}
}

return result, nil
}

// PromptDefaultGrant presents an interactive menu for the user to select a default grant.
func PromptDefaultGrant(grantStore ports.GrantStore, grants []domain.Grant) (string, error) {
// The chosen grant is persisted to both the grant cache and config.yaml via
// PersistDefaultGrant.
func PromptDefaultGrant(configStore ports.ConfigStore, grantStore ports.GrantStore, grants []domain.Grant) (string, error) {
opts := make([]common.SelectOption[string], len(grants))
for i, grant := range grants {
opts[i] = common.SelectOption[string]{
Expand All @@ -91,6 +101,8 @@ func PromptDefaultGrant(grantStore ports.GrantStore, grants []domain.Grant) (str
chosen = grants[0].ID
}

_ = grantStore.SetDefaultGrant(chosen)
if err := authapp.PersistDefaultGrant(configStore, grantStore, chosen); err != nil {
return "", fmt.Errorf("could not persist default grant: %w", err)
}
return chosen, nil
}
Loading
Loading