diff --git a/internal/app/auth/grants.go b/internal/app/auth/grants.go index c5e82ed..35ad777 100644 --- a/internal/app/auth/grants.go +++ b/internal/app/auth/grants.go @@ -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 } diff --git a/internal/app/auth/service.go b/internal/app/auth/service.go index 569d04f..aa95a4e 100644 --- a/internal/app/auth/service.go +++ b/internal/app/auth/service.go @@ -6,6 +6,7 @@ import ( "crypto/rand" "crypto/sha256" "encoding/base64" + "fmt" "github.com/nylas/cli/internal/domain" "github.com/nylas/cli/internal/ports" @@ -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 } @@ -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 } @@ -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 @@ -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) { diff --git a/internal/app/auth/service_test.go b/internal/app/auth/service_test.go index 5293184..eb0037f 100644 --- a/internal/app/auth/service_test.go +++ b/internal/app/auth/service_test.go @@ -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() @@ -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() @@ -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) @@ -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() @@ -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"), diff --git a/internal/cli/auth/config.go b/internal/cli/auth/config.go index b8e15f5..2dafb90 100644 --- a/internal/cli/auth/config.go +++ b/internal/cli/auth/config.go @@ -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") @@ -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) @@ -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 }, } diff --git a/internal/cli/setup/grants.go b/internal/cli/setup/grants.go index aac96eb..aefda79 100644 --- a/internal/cli/setup/grants.go +++ b/internal/cli/setup/grants.go @@ -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" @@ -24,7 +25,11 @@ 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) @@ -32,10 +37,10 @@ func SyncGrants(grantStore ports.GrantStore, apiKey, clientID, region string) (* 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) @@ -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]{ @@ -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 } diff --git a/internal/cli/setup/grants_test.go b/internal/cli/setup/grants_test.go index 6d6a411..560534a 100644 --- a/internal/cli/setup/grants_test.go +++ b/internal/cli/setup/grants_test.go @@ -2,14 +2,18 @@ package setup import ( "context" + "errors" "fmt" "path/filepath" "testing" + "github.com/nylas/cli/internal/adapters/config" "github.com/nylas/cli/internal/adapters/grantcache" "github.com/nylas/cli/internal/domain" ) +var errPersistDefaultGrant = errors.New("persist default grant failed") + type fakeGrantLister struct { grants []domain.Grant params *domain.GrantsQueryParams @@ -21,10 +25,12 @@ func (f *fakeGrantLister) ListAllGrants(_ context.Context, params *domain.Grants } func TestSyncGrantsWithClientFetchesAllGrantsWithoutLimit(t *testing.T) { - grantStore := grantcache.New(filepath.Join(t.TempDir(), "grants.json")) + dir := t.TempDir() + grantStore := grantcache.New(filepath.Join(dir, "grants.json")) + configStore := config.NewFileStore(filepath.Join(dir, "config.yaml")) client := &fakeGrantLister{grants: makeValidSetupGrants(30)} - result, err := syncGrantsWithClient(context.Background(), grantStore, client) + result, err := syncGrantsWithClient(context.Background(), configStore, grantStore, client) if err != nil { t.Fatalf("syncGrantsWithClient failed: %v", err) } @@ -43,6 +49,82 @@ func TestSyncGrantsWithClientFetchesAllGrantsWithoutLimit(t *testing.T) { } } +// TestSyncGrantsWithClientPersistsSingleGrantToBothStores guards the contract +// that a single-grant sync writes the default to grants.json AND mirrors it +// into config.yaml via PersistDefaultGrant. Regressions in either store would +// break the TUI/Air/CLI consistency we standardized on. +func TestSyncGrantsWithClientPersistsSingleGrantToBothStores(t *testing.T) { + dir := t.TempDir() + grantStore := grantcache.New(filepath.Join(dir, "grants.json")) + configStore := config.NewFileStore(filepath.Join(dir, "config.yaml")) + client := &fakeGrantLister{grants: makeValidSetupGrants(1)} + + result, err := syncGrantsWithClient(context.Background(), configStore, grantStore, client) + if err != nil { + t.Fatalf("syncGrantsWithClient failed: %v", err) + } + if result.DefaultGrantID != "grant-00" { + t.Fatalf("DefaultGrantID = %q, want grant-00", result.DefaultGrantID) + } + + gotGrantsJSON, err := grantStore.GetDefaultGrant() + if err != nil { + t.Fatalf("GetDefaultGrant failed: %v", err) + } + if gotGrantsJSON != "grant-00" { + t.Fatalf("grants.json default = %q, want grant-00", gotGrantsJSON) + } + + cfg, err := configStore.Load() + if err != nil { + t.Fatalf("config Load failed: %v", err) + } + if cfg.DefaultGrant != "grant-00" { + t.Fatalf("config.yaml DefaultGrant = %q, want grant-00", cfg.DefaultGrant) + } +} + +func TestSyncGrantsWithClientReturnsErrorWhenSingleGrantDefaultCannotPersist(t *testing.T) { + dir := t.TempDir() + grantStore := grantcache.New(filepath.Join(dir, "grants.json")) + configStore := &failingSetupConfigStore{err: errPersistDefaultGrant} + client := &fakeGrantLister{grants: makeValidSetupGrants(1)} + + result, err := syncGrantsWithClient(context.Background(), configStore, grantStore, client) + if !errors.Is(err, errPersistDefaultGrant) { + t.Fatalf("syncGrantsWithClient err = %v, want %v", err, errPersistDefaultGrant) + } + if result != nil { + t.Fatalf("syncGrantsWithClient result = %#v, want nil on persist failure", result) + } + + if _, err := grantStore.GetDefaultGrant(); err != domain.ErrNoDefaultGrant { + t.Fatalf("GetDefaultGrant err = %v, want ErrNoDefaultGrant", err) + } +} + +// TestSyncGrantsWithClientSkipsDefaultForMultipleGrants ensures that with more +// than one valid grant, neither store has a default set — the caller is +// expected to disambiguate via PromptDefaultGrant. +func TestSyncGrantsWithClientSkipsDefaultForMultipleGrants(t *testing.T) { + dir := t.TempDir() + grantStore := grantcache.New(filepath.Join(dir, "grants.json")) + configStore := config.NewFileStore(filepath.Join(dir, "config.yaml")) + client := &fakeGrantLister{grants: makeValidSetupGrants(3)} + + result, err := syncGrantsWithClient(context.Background(), configStore, grantStore, client) + if err != nil { + t.Fatalf("syncGrantsWithClient failed: %v", err) + } + if result.DefaultGrantID != "" { + t.Fatalf("DefaultGrantID = %q, want empty", result.DefaultGrantID) + } + + if _, err := grantStore.GetDefaultGrant(); err != domain.ErrNoDefaultGrant { + t.Fatalf("GetDefaultGrant err = %v, want ErrNoDefaultGrant", err) + } +} + func makeValidSetupGrants(n int) []domain.Grant { grants := make([]domain.Grant, n) for i := range grants { @@ -55,3 +137,23 @@ func makeValidSetupGrants(n int) []domain.Grant { } return grants } + +type failingSetupConfigStore struct { + err error +} + +func (f *failingSetupConfigStore) Load() (*domain.Config, error) { + return domain.DefaultConfig(), nil +} + +func (f *failingSetupConfigStore) Save(*domain.Config) error { + return f.err +} + +func (f *failingSetupConfigStore) Path() string { + return "/tmp/config.yaml" +} + +func (f *failingSetupConfigStore) Exists() bool { + return true +} diff --git a/internal/cli/setup/wizard.go b/internal/cli/setup/wizard.go index 96ac016..82f80a9 100644 --- a/internal/cli/setup/wizard.go +++ b/internal/cli/setup/wizard.go @@ -427,7 +427,7 @@ func stepGrantSync(status *SetupStatus) { var result *SyncResult err = common.RunWithSpinner("Checking for existing email accounts...", func() error { - result, err = SyncGrants(grantStore, apiKey, clientID, region) + result, err = SyncGrants(configStore, grantStore, apiKey, clientID, region) return err }) if err != nil { @@ -446,14 +446,20 @@ func stepGrantSync(status *SetupStatus) { return } - // Handle default grant selection. + // Handle default grant selection. Both branches persist via + // PersistDefaultGrant inside SyncGrants/PromptDefaultGrant — no separate + // config.yaml write needed here. if result.DefaultGrantID != "" { - // Single grant, auto-set. + // Single grant, auto-set 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. - defaultID, _ := PromptDefaultGrant(grantStore, result.ValidGrants) + // Multiple grants, prompt — PromptDefaultGrant persists the choice. + defaultID, err := PromptDefaultGrant(configStore, grantStore, result.ValidGrants) + if err != nil { + _, _ = common.Yellow.Printf(" Could not set default account: %v\n", err) + return + } if defaultID != "" { result.DefaultGrantID = defaultID for _, g := range result.ValidGrants { @@ -464,17 +470,4 @@ func stepGrantSync(status *SetupStatus) { } } } - - // Update config file with grants. - updateConfigGrants(configStore, cfg, result) -} - -// updateConfigGrants writes the local default grant preference to the config file. -func updateConfigGrants(configStore *config.FileStore, cfg *domain.Config, result *SyncResult) { - if cfg == nil || result == nil { - return - } - cfg.DefaultGrant = result.DefaultGrantID - cfg.Grants = nil - _ = configStore.Save(cfg) } diff --git a/internal/cli/setup/wizard_helpers_test.go b/internal/cli/setup/wizard_helpers_test.go index 6a79d83..4b0494d 100644 --- a/internal/cli/setup/wizard_helpers_test.go +++ b/internal/cli/setup/wizard_helpers_test.go @@ -2,13 +2,7 @@ package setup import ( "errors" - "os" - "path/filepath" - "strings" "testing" - - "github.com/nylas/cli/internal/adapters/config" - "github.com/nylas/cli/internal/domain" ) func TestEnsureSetupCallbackURI_AllowsManualFallbackWhenProvisioningFails(t *testing.T) { @@ -33,33 +27,3 @@ func TestEnsureSetupCallbackURI_RequiresClientID(t *testing.T) { t.Fatal("expected empty client ID to fail") } } - -func TestUpdateConfigGrantsStoresDefaultOnly(t *testing.T) { - configPath := filepath.Join(t.TempDir(), "config.yaml") - configStore := config.NewFileStore(configPath) - cfg := &domain.Config{Region: "us"} - result := &SyncResult{ - DefaultGrantID: "grant-1", - ValidGrants: []domain.Grant{{ - ID: "grant-1", - Email: "user@example.com", - Provider: domain.ProviderGoogle, - }}, - } - - updateConfigGrants(configStore, cfg, result) - - if cfg.DefaultGrant != "grant-1" { - t.Fatalf("DefaultGrant = %q, want grant-1", cfg.DefaultGrant) - } - if len(cfg.Grants) != 0 { - t.Fatalf("config object should not retain grant metadata, got %+v", cfg.Grants) - } - data, err := os.ReadFile(configPath) - if err != nil { - t.Fatalf("failed to read config: %v", err) - } - if strings.Contains(string(data), "grants:") { - t.Fatalf("saved config should not contain grants list:\n%s", string(data)) - } -} diff --git a/internal/cli/tui.go b/internal/cli/tui.go index 8c9a2a3..00e6919 100644 --- a/internal/cli/tui.go +++ b/internal/cli/tui.go @@ -394,10 +394,10 @@ func runTUI(refreshInterval time.Duration, initialView string, theme tui.ThemeNa return common.WrapGetError("grant info", err) } - return runTViewTUI(client, grantStore, grantID, grantInfo, cfg, theme, themeExplicitlySet, refreshInterval, initialView) + return runTViewTUI(client, grantStore, configStore, grantID, grantInfo, cfg, theme, themeExplicitlySet, refreshInterval, initialView) } -func runTViewTUI(client ports.NylasClient, grantStore ports.GrantStore, grantID string, grantInfo *domain.GrantInfo, cfg *domain.Config, theme tui.ThemeName, themeExplicitlySet bool, refreshInterval time.Duration, initialView string) error { +func runTViewTUI(client ports.NylasClient, grantStore ports.GrantStore, configStore ports.ConfigStore, grantID string, grantInfo *domain.GrantInfo, cfg *domain.Config, theme tui.ThemeName, themeExplicitlySet bool, refreshInterval time.Duration, initialView string) error { // Use config theme if no explicit --theme flag was provided if !themeExplicitlySet && cfg.TUITheme != "" { theme = tui.ThemeName(cfg.TUITheme) @@ -416,6 +416,7 @@ func runTViewTUI(client ports.NylasClient, grantStore ports.GrantStore, grantID app := tui.NewApp(tui.Config{ Client: client, GrantStore: grantStore, // Enable grant switching in TUI + ConfigStore: configStore, GrantID: grantID, Email: grantInfo.Email, Provider: string(grantInfo.Provider), diff --git a/internal/tui/app_base.go b/internal/tui/app_base.go index aa0d196..7264c3b 100644 --- a/internal/tui/app_base.go +++ b/internal/tui/app_base.go @@ -12,7 +12,8 @@ import ( // Config holds the TUI configuration. type Config struct { Client ports.NylasClient - GrantStore ports.GrantStore // Optional: enables grant switching in TUI + GrantStore ports.GrantStore // Optional: enables grant switching in TUI + ConfigStore ports.ConfigStore // Optional: when set, default-grant changes mirror to config.yaml GrantID string Email string Provider string diff --git a/internal/tui/app_control.go b/internal/tui/app_control.go index 7c2ff74..13c6b39 100644 --- a/internal/tui/app_control.go +++ b/internal/tui/app_control.go @@ -4,6 +4,8 @@ package tui import ( "fmt" "time" + + authapp "github.com/nylas/cli/internal/app/auth" ) func (a *App) Run() error { @@ -67,8 +69,9 @@ func (a *App) SwitchGrant(grantID, email, provider string) error { return fmt.Errorf("grant switching not available (no grant store)") } - // Set the new default grant - if err := a.config.GrantStore.SetDefaultGrant(grantID); err != nil { + // Set the new default grant. Mirror to config.yaml via PersistDefaultGrant + // so the TUI matches every other write path (auth switch, Air, login flow). + if err := authapp.PersistDefaultGrant(a.config.ConfigStore, a.config.GrantStore, grantID); err != nil { return fmt.Errorf("failed to switch grant: %w", err) } diff --git a/internal/tui/app_control_test.go b/internal/tui/app_control_test.go new file mode 100644 index 0000000..e755a5f --- /dev/null +++ b/internal/tui/app_control_test.go @@ -0,0 +1,65 @@ +package tui + +import ( + "path/filepath" + "testing" + "time" + + "github.com/nylas/cli/internal/adapters/config" + "github.com/nylas/cli/internal/adapters/grantcache" + "github.com/nylas/cli/internal/adapters/nylas" + "github.com/nylas/cli/internal/domain" +) + +func TestAppSwitchGrantPersistsDefaultToConfigStore(t *testing.T) { + dir := t.TempDir() + grantStore := grantcache.New(filepath.Join(dir, "grants.json")) + configStore := config.NewFileStore(filepath.Join(dir, "config.yaml")) + + if err := grantStore.SaveGrant(domain.GrantInfo{ID: "grant-old", Email: "old@example.com", Provider: domain.ProviderGoogle}); err != nil { + t.Fatalf("SaveGrant old failed: %v", err) + } + if err := grantStore.SaveGrant(domain.GrantInfo{ID: "grant-new", Email: "new@example.com", Provider: domain.ProviderMicrosoft}); err != nil { + t.Fatalf("SaveGrant new failed: %v", err) + } + if err := grantStore.SetDefaultGrant("grant-old"); err != nil { + t.Fatalf("SetDefaultGrant failed: %v", err) + } + if err := configStore.Save(&domain.Config{Region: "us", DefaultGrant: "grant-old"}); err != nil { + t.Fatalf("config Save failed: %v", err) + } + + app := NewApp(Config{ + Client: nylas.NewMockClient(), + GrantStore: grantStore, + ConfigStore: configStore, + GrantID: "grant-old", + Email: "old@example.com", + Provider: string(domain.ProviderGoogle), + RefreshInterval: time.Second, + Theme: ThemeK9s, + }) + + if err := app.SwitchGrant("grant-new", "new@example.com", string(domain.ProviderMicrosoft)); err != nil { + t.Fatalf("SwitchGrant failed: %v", err) + } + + defaultGrant, err := grantStore.GetDefaultGrant() + if err != nil { + t.Fatalf("GetDefaultGrant failed: %v", err) + } + if defaultGrant != "grant-new" { + t.Fatalf("grants.json default = %q, want grant-new", defaultGrant) + } + + cfg, err := configStore.Load() + if err != nil { + t.Fatalf("config Load failed: %v", err) + } + if cfg.DefaultGrant != "grant-new" { + t.Fatalf("config.yaml default = %q, want grant-new", cfg.DefaultGrant) + } + if app.config.GrantID != "grant-new" { + t.Fatalf("app grant ID = %q, want grant-new", app.config.GrantID) + } +}