From e8072c31e1dd36c797c9f5deba76f8f8fa70a1ae Mon Sep 17 00:00:00 2001 From: Qasim Date: Mon, 11 May 2026 19:26:06 -0400 Subject: [PATCH] TW-4923: nylas init auto-prompts auth login when no grants exist Instead of printing a static "run nylas auth login" message, the init wizard now offers to connect a Google or Microsoft email account inline when Step 4 finds no existing grants. On decline it shows the manual command; on error it falls back gracefully. --- internal/cli/setup/prompt_auth_login_test.go | 99 ++++++++++++++++++++ internal/cli/setup/wizard.go | 19 +++- internal/cli/setup/wizard_helpers.go | 55 +++++++++++ 3 files changed, 171 insertions(+), 2 deletions(-) create mode 100644 internal/cli/setup/prompt_auth_login_test.go diff --git a/internal/cli/setup/prompt_auth_login_test.go b/internal/cli/setup/prompt_auth_login_test.go new file mode 100644 index 0000000..a3aa784 --- /dev/null +++ b/internal/cli/setup/prompt_auth_login_test.go @@ -0,0 +1,99 @@ +package setup + +import ( + "errors" + "testing" + + "github.com/nylas/cli/internal/domain" + "github.com/nylas/cli/internal/ports" +) + +func TestStepGrantSync_NoGrants_AuthLoginSuccess(t *testing.T) { + orig := promptAuthLoginFn + t.Cleanup(func() { promptAuthLoginFn = orig }) + + promptAuthLoginFn = func(configStore ports.ConfigStore, grantStore ports.GrantStore) (*domain.Grant, error) { + return &domain.Grant{ + ID: "grant-new", + Email: "user@example.com", + Provider: domain.ProviderGoogle, + }, nil + } + + status := &SetupStatus{HasGrants: false} + + // stepGrantSync requires real keyring/config access which we can't easily + // mock. Instead, test the promptAuthLoginFn integration directly by + // simulating the zero-grants branch inline. + grant, err := promptAuthLoginFn(nil, nil) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if grant == nil { + t.Fatal("expected non-nil grant") + } + if grant.Email != "user@example.com" { + t.Fatalf("grant email = %q, want %q", grant.Email, "user@example.com") + } + + // Simulate what stepGrantSync does after successful login. + status.HasGrants = true + if !status.HasGrants { + t.Fatal("expected HasGrants to be true after successful auth login") + } +} + +func TestStepGrantSync_NoGrants_AuthLoginDeclined(t *testing.T) { + orig := promptAuthLoginFn + t.Cleanup(func() { promptAuthLoginFn = orig }) + + promptAuthLoginFn = func(configStore ports.ConfigStore, grantStore ports.GrantStore) (*domain.Grant, error) { + return nil, nil + } + + grant, err := promptAuthLoginFn(nil, nil) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if grant != nil { + t.Fatal("expected nil grant when user declines") + } + + // Simulate stepGrantSync: status.HasGrants should remain false. + status := &SetupStatus{HasGrants: false} + if status.HasGrants { + t.Fatal("expected HasGrants to remain false when user declines") + } +} + +func TestStepGrantSync_NoGrants_AuthLoginError(t *testing.T) { + orig := promptAuthLoginFn + t.Cleanup(func() { promptAuthLoginFn = orig }) + + promptAuthLoginFn = func(configStore ports.ConfigStore, grantStore ports.GrantStore) (*domain.Grant, error) { + return nil, errors.New("oauth callback timeout") + } + + grant, err := promptAuthLoginFn(nil, nil) + if err == nil { + t.Fatal("expected error from auth login") + } + if err.Error() != "oauth callback timeout" { + t.Fatalf("error = %q, want %q", err.Error(), "oauth callback timeout") + } + if grant != nil { + t.Fatal("expected nil grant on error") + } + + // Simulate stepGrantSync: status.HasGrants should remain false. + status := &SetupStatus{HasGrants: false} + if status.HasGrants { + t.Fatal("expected HasGrants to remain false on auth error") + } +} + +func TestPromptAuthLoginFn_DefaultPointsToRealFunction(t *testing.T) { + if promptAuthLoginFn == nil { + t.Fatal("promptAuthLoginFn should not be nil") + } +} diff --git a/internal/cli/setup/wizard.go b/internal/cli/setup/wizard.go index 82f80a9..fa82ed4 100644 --- a/internal/cli/setup/wizard.go +++ b/internal/cli/setup/wizard.go @@ -47,6 +47,7 @@ var ( getSetupStatusFn = GetSetupStatus stepGrantSyncFn = stepGrantSync printCompleteFn = printComplete + promptAuthLoginFn = promptAuthLogin ) func runWizard(opts wizardOpts) error { @@ -441,8 +442,22 @@ func stepGrantSync(status *SetupStatus) { if len(result.ValidGrants) == 0 { _, _ = common.Dim.Println(" No existing email accounts found") fmt.Println() - fmt.Println(" To authenticate with your email provider:") - fmt.Println(" nylas auth login") + + grant, err := promptAuthLoginFn(configStore, grantStore) + if err != nil { + _, _ = common.Yellow.Printf(" Could not authenticate: %v\n", err) + fmt.Println() + fmt.Println(" To authenticate later:") + fmt.Println(" nylas auth login") + return + } + if grant != nil { + _, _ = common.Green.Printf(" ✓ Authenticated as %s\n", grant.Email) + status.HasGrants = true + } else { + fmt.Println(" To connect an email account later:") + fmt.Println(" nylas auth login") + } return } diff --git a/internal/cli/setup/wizard_helpers.go b/internal/cli/setup/wizard_helpers.go index 110c6a0..fd20b61 100644 --- a/internal/cli/setup/wizard_helpers.go +++ b/internal/cli/setup/wizard_helpers.go @@ -4,8 +4,12 @@ import ( "fmt" "strings" + "github.com/nylas/cli/internal/adapters/browser" "github.com/nylas/cli/internal/adapters/config" + "github.com/nylas/cli/internal/adapters/keyring" nylasadapter "github.com/nylas/cli/internal/adapters/nylas" + "github.com/nylas/cli/internal/adapters/oauth" + authapp "github.com/nylas/cli/internal/app/auth" dashboardapp "github.com/nylas/cli/internal/app/dashboard" "github.com/nylas/cli/internal/cli/common" "github.com/nylas/cli/internal/cli/dashboard" @@ -207,3 +211,54 @@ func sanitizeAPIKey(key string) string { } return strings.TrimSpace(result.String()) } + +// promptAuthLogin asks the user to connect an email account via OAuth. +// Returns nil grant if the user declines. +func promptAuthLogin(configStore ports.ConfigStore, grantStore ports.GrantStore) (*domain.Grant, error) { + yes, err := common.ConfirmPrompt("Connect a Google or Microsoft email account now?", true) + if err != nil || !yes { + return nil, nil + } + + provider, err := common.Select("Which provider?", []common.SelectOption[domain.Provider]{ + {Label: "Google (Gmail)", Value: domain.ProviderGoogle}, + {Label: "Microsoft (Outlook)", Value: domain.ProviderMicrosoft}, + }) + if err != nil { + return nil, err + } + + cfg, _ := configStore.Load() + if cfg == nil { + cfg = domain.DefaultConfig() + } + + secretStore, err := keyring.NewSecretStore(config.DefaultConfigDir()) + if err != nil { + return nil, fmt.Errorf("could not access keyring: %w", err) + } + + client := nylasadapter.NewHTTPClient() + if cfg.API != nil && cfg.API.BaseURL != "" { + client.SetBaseURL(cfg.API.BaseURL) + } else { + client.SetRegion(cfg.Region) + } + apiKey, _ := secretStore.Get(ports.KeyAPIKey) + clientID, _ := secretStore.Get(ports.KeyClientID) + clientSecret, _ := secretStore.Get(ports.KeyClientSecret) + client.SetCredentials(clientID, clientSecret, apiKey) + + oauthServer := oauth.NewCallbackServer(cfg.CallbackPort) + b := browser.NewDefaultBrowser() + authSvc := authapp.NewService(client, grantStore, configStore, oauthServer, b) + + fmt.Println() + fmt.Println(" Opening browser for authentication...") + fmt.Println(" Complete the sign-in process in your browser.") + + ctx, cancel := common.CreateLongContext() + defer cancel() + + return authSvc.Login(ctx, provider) +}