From ea794af15eaf1d9e2a972f5dfe267eda6e2c8d8e Mon Sep 17 00:00:00 2001 From: Aaron de Mello Date: Mon, 23 Mar 2026 11:16:22 -0400 Subject: [PATCH 01/20] feat(dashboard): add dashboard auth and application management commands MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add `nylas dashboard` command group for authenticating with the Nylas Dashboard and managing applications via the API gateway. Auth flows: - Email/password registration with email verification - Email/password login with MFA support - SSO device flow (Google, Microsoft, GitHub) - Session refresh and logout - DPoP proof-of-possession (Ed25519) on all requests Application management: - `nylas dashboard apps list` — queries both US and EU gateways - `nylas dashboard apps create` — creates apps in a specified region UX: - Interactive auth method picker (SSO first, recommended) - Direct flags for non-interactive use (--google, --email, --user, etc.) - Spinners on all network operations - User-friendly errors with hints Co-Authored-By: Claude Opus 4.6 (1M context) --- cmd/nylas/main.go | 2 + internal/adapters/dashboard/account_client.go | 208 +++++++++++++ internal/adapters/dashboard/gateway_client.go | 200 ++++++++++++ internal/adapters/dashboard/http.go | 152 +++++++++ internal/adapters/dashboard/mock.go | 61 ++++ internal/adapters/dpop/dpop.go | 171 +++++++++++ internal/adapters/dpop/dpop_test.go | 288 ++++++++++++++++++ internal/app/dashboard/app_service.go | 118 +++++++ internal/app/dashboard/auth_service.go | 210 +++++++++++++ internal/cli/dashboard/apps.go | 195 ++++++++++++ internal/cli/dashboard/dashboard.go | 35 +++ internal/cli/dashboard/helpers.go | 237 ++++++++++++++ internal/cli/dashboard/login.go | 145 +++++++++ internal/cli/dashboard/logout.go | 33 ++ internal/cli/dashboard/refresh.go | 37 +++ internal/cli/dashboard/register.go | 163 ++++++++++ internal/cli/dashboard/sso.go | 207 +++++++++++++ internal/cli/dashboard/status.go | 53 ++++ internal/domain/config.go | 3 + internal/domain/dashboard.go | 127 ++++++++ internal/domain/errors.go | 8 + internal/ports/dashboard.go | 57 ++++ internal/ports/secrets.go | 7 + 23 files changed, 2717 insertions(+) create mode 100644 internal/adapters/dashboard/account_client.go create mode 100644 internal/adapters/dashboard/gateway_client.go create mode 100644 internal/adapters/dashboard/http.go create mode 100644 internal/adapters/dashboard/mock.go create mode 100644 internal/adapters/dpop/dpop.go create mode 100644 internal/adapters/dpop/dpop_test.go create mode 100644 internal/app/dashboard/app_service.go create mode 100644 internal/app/dashboard/auth_service.go create mode 100644 internal/cli/dashboard/apps.go create mode 100644 internal/cli/dashboard/dashboard.go create mode 100644 internal/cli/dashboard/helpers.go create mode 100644 internal/cli/dashboard/login.go create mode 100644 internal/cli/dashboard/logout.go create mode 100644 internal/cli/dashboard/refresh.go create mode 100644 internal/cli/dashboard/register.go create mode 100644 internal/cli/dashboard/sso.go create mode 100644 internal/cli/dashboard/status.go create mode 100644 internal/domain/dashboard.go create mode 100644 internal/ports/dashboard.go diff --git a/cmd/nylas/main.go b/cmd/nylas/main.go index 46edb52..8704bb6 100644 --- a/cmd/nylas/main.go +++ b/cmd/nylas/main.go @@ -15,6 +15,7 @@ import ( "github.com/nylas/cli/internal/cli/calendar" "github.com/nylas/cli/internal/cli/config" "github.com/nylas/cli/internal/cli/contacts" + "github.com/nylas/cli/internal/cli/dashboard" "github.com/nylas/cli/internal/cli/demo" "github.com/nylas/cli/internal/cli/email" "github.com/nylas/cli/internal/cli/inbound" @@ -43,6 +44,7 @@ func main() { rootCmd.AddCommand(email.NewEmailCmd()) rootCmd.AddCommand(calendar.NewCalendarCmd()) rootCmd.AddCommand(contacts.NewContactsCmd()) + rootCmd.AddCommand(dashboard.NewDashboardCmd()) rootCmd.AddCommand(scheduler.NewSchedulerCmd()) rootCmd.AddCommand(admin.NewAdminCmd()) rootCmd.AddCommand(webhook.NewWebhookCmd()) diff --git a/internal/adapters/dashboard/account_client.go b/internal/adapters/dashboard/account_client.go new file mode 100644 index 0000000..e362747 --- /dev/null +++ b/internal/adapters/dashboard/account_client.go @@ -0,0 +1,208 @@ +// Package dashboard implements clients for the Nylas Dashboard account +// and API gateway services. +package dashboard + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + + "github.com/nylas/cli/internal/domain" + "github.com/nylas/cli/internal/ports" +) + +// AccountClient implements ports.DashboardAccountClient for the +// dashboard-account CLI auth endpoints. +type AccountClient struct { + baseURL string + httpClient *http.Client + dpop ports.DPoP +} + +// NewAccountClient creates a new dashboard account client. +func NewAccountClient(baseURL string, dpop ports.DPoP) *AccountClient { + return &AccountClient{ + baseURL: baseURL, + httpClient: &http.Client{}, + dpop: dpop, + } +} + +// Register creates a new dashboard account and triggers email verification. +func (c *AccountClient) Register(ctx context.Context, email, password string, privacyPolicyAccepted bool) (*domain.DashboardRegisterResponse, error) { + body := map[string]any{ + "email": email, + "password": password, + "privacyPolicyAccepted": privacyPolicyAccepted, + } + + var result domain.DashboardRegisterResponse + if err := c.doPost(ctx, "/auth/cli/register", body, nil, "", &result); err != nil { + return nil, fmt.Errorf("registration failed: %w", err) + } + return &result, nil +} + +// VerifyEmailCode verifies the email verification code after registration. +func (c *AccountClient) VerifyEmailCode(ctx context.Context, email, code, region string) (*domain.DashboardAuthResponse, error) { + body := map[string]any{ + "email": email, + "code": code, + "region": region, + } + + var result domain.DashboardAuthResponse + if err := c.doPost(ctx, "/auth/cli/verify-email-code", body, nil, "", &result); err != nil { + return nil, fmt.Errorf("verification code invalid or expired: %w", err) + } + return &result, nil +} + +// ResendVerificationCode resends the email verification code. +func (c *AccountClient) ResendVerificationCode(ctx context.Context, email string) error { + body := map[string]any{"email": email} + return c.doPost(ctx, "/auth/cli/resend-verification-code", body, nil, "", nil) +} + +// Login authenticates with email and password. +func (c *AccountClient) Login(ctx context.Context, email, password, orgPublicID string) (*domain.DashboardAuthResponse, *domain.DashboardMFARequired, error) { + body := map[string]any{ + "email": email, + "password": password, + } + if orgPublicID != "" { + body["orgPublicId"] = orgPublicID + } + + raw, err := c.doPostRaw(ctx, "/auth/cli/login", body, nil, "") + if err != nil { + return nil, nil, fmt.Errorf("%w", domain.ErrDashboardLoginFailed) + } + + // Check if response contains userToken (success) or totpFactor (MFA required) + var probe struct { + UserToken string `json:"userToken"` + TOTPFactor any `json:"totpFactor"` + } + if err := json.Unmarshal(raw, &probe); err != nil { + return nil, nil, fmt.Errorf("failed to parse login response: %w", err) + } + + if probe.UserToken != "" { + var auth domain.DashboardAuthResponse + if err := json.Unmarshal(raw, &auth); err != nil { + return nil, nil, fmt.Errorf("failed to parse auth response: %w", err) + } + return &auth, nil, nil + } + + if probe.TOTPFactor != nil { + var mfa domain.DashboardMFARequired + if err := json.Unmarshal(raw, &mfa); err != nil { + return nil, nil, fmt.Errorf("failed to parse MFA response: %w", err) + } + return nil, &mfa, nil + } + + return nil, nil, fmt.Errorf("%w", domain.ErrDashboardLoginFailed) +} + +// LoginMFA completes MFA authentication with a TOTP code. +func (c *AccountClient) LoginMFA(ctx context.Context, userPublicID, code, orgPublicID string) (*domain.DashboardAuthResponse, error) { + body := map[string]any{ + "userPublicId": userPublicID, + "code": code, + } + if orgPublicID != "" { + body["orgPublicId"] = orgPublicID + } + + var result domain.DashboardAuthResponse + if err := c.doPost(ctx, "/auth/cli/login/mfa", body, nil, "", &result); err != nil { + return nil, fmt.Errorf("%w", domain.ErrDashboardLoginFailed) + } + return &result, nil +} + +// Refresh refreshes the session tokens. +func (c *AccountClient) Refresh(ctx context.Context, userToken, orgToken string) (*domain.DashboardRefreshResponse, error) { + headers := bearerHeaders(userToken, orgToken) + var result domain.DashboardRefreshResponse + if err := c.doPost(ctx, "/auth/cli/refresh", nil, headers, userToken, &result); err != nil { + return nil, fmt.Errorf("%w", domain.ErrDashboardSessionExpired) + } + return &result, nil +} + +// Logout invalidates the session tokens. +func (c *AccountClient) Logout(ctx context.Context, userToken, orgToken string) error { + headers := bearerHeaders(userToken, orgToken) + return c.doPost(ctx, "/auth/cli/logout", nil, headers, userToken, nil) +} + +// SSOStart initiates an SSO device authorization flow. +func (c *AccountClient) SSOStart(ctx context.Context, loginType, mode string, privacyPolicyAccepted bool) (*domain.DashboardSSOStartResponse, error) { + body := map[string]any{ + "loginType": loginType, + "mode": mode, + } + if mode == "register" { + body["privacyPolicyAccepted"] = privacyPolicyAccepted + } + + var result domain.DashboardSSOStartResponse + if err := c.doPost(ctx, "/auth/cli/sso/start", body, nil, "", &result); err != nil { + return nil, fmt.Errorf("%w: %w", domain.ErrDashboardSSOFailed, err) + } + return &result, nil +} + +// SSOPoll polls the SSO device flow for completion. +func (c *AccountClient) SSOPoll(ctx context.Context, flowID, orgPublicID string) (*domain.DashboardSSOPollResponse, error) { + body := map[string]any{ + "flowId": flowID, + } + if orgPublicID != "" { + body["orgPublicId"] = orgPublicID + } + + raw, err := c.doPostRaw(ctx, "/auth/cli/sso/poll", body, nil, "") + if err != nil { + return nil, fmt.Errorf("%w: %w", domain.ErrDashboardSSOFailed, err) + } + + var result domain.DashboardSSOPollResponse + if err := json.Unmarshal(raw, &result); err != nil { + return nil, fmt.Errorf("failed to parse SSO poll response: %w", err) + } + + switch result.Status { + case domain.SSOStatusComplete: + var auth domain.DashboardAuthResponse + if err := json.Unmarshal(raw, &auth); err != nil { + return nil, fmt.Errorf("failed to parse SSO auth: %w", err) + } + result.Auth = &auth + + case domain.SSOStatusMFARequired: + var mfa domain.DashboardMFARequired + if err := json.Unmarshal(raw, &mfa); err != nil { + return nil, fmt.Errorf("failed to parse SSO MFA: %w", err) + } + result.MFA = &mfa + } + + return &result, nil +} + +// bearerHeaders creates the Authorization and X-Nylas-Org headers. +func bearerHeaders(userToken, orgToken string) map[string]string { + h := map[string]string{ + "Authorization": "Bearer " + userToken, + } + if orgToken != "" { + h["X-Nylas-Org"] = orgToken + } + return h +} diff --git a/internal/adapters/dashboard/gateway_client.go b/internal/adapters/dashboard/gateway_client.go new file mode 100644 index 0000000..b3d8694 --- /dev/null +++ b/internal/adapters/dashboard/gateway_client.go @@ -0,0 +1,200 @@ +package dashboard + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "os" + + "github.com/nylas/cli/internal/domain" + "github.com/nylas/cli/internal/ports" +) + +// GatewayClient implements ports.DashboardGatewayClient for the +// dashboard API gateway GraphQL endpoints. +type GatewayClient struct { + httpClient *http.Client + dpop ports.DPoP +} + +// NewGatewayClient creates a new dashboard gateway GraphQL client. +func NewGatewayClient(dpop ports.DPoP) *GatewayClient { + return &GatewayClient{ + httpClient: &http.Client{}, + dpop: dpop, + } +} + +// ListApplications retrieves applications from the dashboard API gateway. +func (c *GatewayClient) ListApplications(ctx context.Context, orgPublicID, region, userToken, orgToken string) ([]domain.GatewayApplication, error) { + query := `query V3_GetApplications($filter: ApplicationFilter!) { + applications(filter: $filter) { + applications { + applicationId + organizationId + region + environment + branding { name description } + } + } +}` + + variables := map[string]any{ + "filter": map[string]any{ + "orgPublicId": orgPublicID, + }, + } + + url := gatewayURL(region) + raw, err := c.doGraphQL(ctx, url, query, variables, userToken, orgToken) + if err != nil { + return nil, fmt.Errorf("failed to list applications: %w", err) + } + + var resp struct { + Data struct { + Applications struct { + Applications []domain.GatewayApplication `json:"applications"` + } `json:"applications"` + } `json:"data"` + Errors []graphQLError `json:"errors"` + } + + if err := json.Unmarshal(raw, &resp); err != nil { + return nil, fmt.Errorf("failed to decode applications response: %w", err) + } + + if len(resp.Errors) > 0 { + return nil, fmt.Errorf("GraphQL error: %s", resp.Errors[0].Message) + } + + return resp.Data.Applications.Applications, nil +} + +// CreateApplication creates a new application via the dashboard API gateway. +func (c *GatewayClient) CreateApplication(ctx context.Context, orgPublicID, region, name, userToken, orgToken string) (*domain.GatewayCreatedApplication, error) { + query := `mutation V3_CreateApplication($orgPublicId: String!, $options: ApplicationOptions!) { + createApplication(orgPublicId: $orgPublicId, options: $options) { + applicationId + clientSecret + organizationId + region + environment + branding { name } + } +}` + + variables := map[string]any{ + "orgPublicId": orgPublicID, + "options": map[string]any{ + "region": region, + "branding": map[string]any{ + "name": name, + }, + }, + } + + url := gatewayURL(region) + raw, err := c.doGraphQL(ctx, url, query, variables, userToken, orgToken) + if err != nil { + return nil, fmt.Errorf("failed to create application: %w", err) + } + + var resp struct { + Data struct { + CreateApplication domain.GatewayCreatedApplication `json:"createApplication"` + } `json:"data"` + Errors []graphQLError `json:"errors"` + } + + if err := json.Unmarshal(raw, &resp); err != nil { + return nil, fmt.Errorf("failed to decode create response: %w", err) + } + + if len(resp.Errors) > 0 { + return nil, fmt.Errorf("GraphQL error: %s", resp.Errors[0].Message) + } + + return &resp.Data.CreateApplication, nil +} + +// doGraphQL sends a GraphQL request with auth headers and DPoP proof. +func (c *GatewayClient) doGraphQL(ctx context.Context, url, query string, variables map[string]any, userToken, orgToken string) ([]byte, error) { + reqBody := map[string]any{ + "query": query, + "variables": variables, + } + + bodyJSON, err := json.Marshal(reqBody) + if err != nil { + return nil, fmt.Errorf("failed to encode GraphQL request: %w", err) + } + + req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(bodyJSON)) + if err != nil { + return nil, fmt.Errorf("failed to create request: %w", err) + } + + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Authorization", "Bearer "+userToken) + if orgToken != "" { + req.Header.Set("X-Nylas-Org", orgToken) + } + + // Add DPoP proof with access token hash + proof, err := c.dpop.GenerateProof(http.MethodPost, url, userToken) + if err != nil { + return nil, err + } + req.Header.Set("DPoP", proof) + + resp, err := c.httpClient.Do(req) + if err != nil { + return nil, fmt.Errorf("request failed: %w", err) + } + defer func() { _ = resp.Body.Close() }() + + respBody, err := io.ReadAll(io.LimitReader(resp.Body, maxResponseBody)) + if err != nil { + return nil, fmt.Errorf("failed to read response: %w", err) + } + + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return nil, parseErrorResponse(resp.StatusCode, respBody) + } + + return respBody, nil +} + +// gatewayURL returns the API gateway GraphQL URL for the given region. +// Per-region env vars take priority, then the shared override, then defaults. +// +// NYLAS_DASHBOARD_GATEWAY_US_URL → overrides US only +// NYLAS_DASHBOARD_GATEWAY_EU_URL → overrides EU only +// NYLAS_DASHBOARD_GATEWAY_URL → overrides both (single local gateway) +func gatewayURL(region string) string { + if region == "eu" { + if envURL := os.Getenv("NYLAS_DASHBOARD_GATEWAY_EU_URL"); envURL != "" { + return envURL + } + if envURL := os.Getenv("NYLAS_DASHBOARD_GATEWAY_URL"); envURL != "" { + return envURL + } + return domain.GatewayBaseURLEU + } + if envURL := os.Getenv("NYLAS_DASHBOARD_GATEWAY_US_URL"); envURL != "" { + return envURL + } + if envURL := os.Getenv("NYLAS_DASHBOARD_GATEWAY_URL"); envURL != "" { + return envURL + } + return domain.GatewayBaseURLUS +} + +// graphQLError represents a GraphQL error from the gateway. +type graphQLError struct { + Message string `json:"message"` +} diff --git a/internal/adapters/dashboard/http.go b/internal/adapters/dashboard/http.go new file mode 100644 index 0000000..82bb8a6 --- /dev/null +++ b/internal/adapters/dashboard/http.go @@ -0,0 +1,152 @@ +package dashboard + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" +) + +const ( + maxResponseBody = 1 << 20 // 1 MB +) + +// doPost sends a JSON POST request and decodes the response into result. +// The server wraps responses in {"request_id","success","data":{...}}. +// This method unwraps the data field before decoding into result. +// If result is nil, the response body is discarded. +func (c *AccountClient) doPost(ctx context.Context, path string, body any, extraHeaders map[string]string, accessToken string, result any) error { + raw, err := c.doPostRaw(ctx, path, body, extraHeaders, accessToken) + if err != nil { + return err + } + + if result != nil { + data, unwrapErr := unwrapEnvelope(raw) + if unwrapErr != nil { + return unwrapErr + } + if err := json.Unmarshal(data, result); err != nil { + return fmt.Errorf("failed to decode response: %w", err) + } + } + return nil +} + +// doPostRaw sends a JSON POST request and returns the raw response body. +func (c *AccountClient) doPostRaw(ctx context.Context, path string, body any, extraHeaders map[string]string, accessToken string) ([]byte, error) { + fullURL := c.baseURL + path + + var bodyReader io.Reader + if body != nil { + bodyJSON, err := json.Marshal(body) + if err != nil { + return nil, fmt.Errorf("failed to encode request: %w", err) + } + bodyReader = bytes.NewReader(bodyJSON) + } + + req, err := http.NewRequestWithContext(ctx, http.MethodPost, fullURL, bodyReader) + if err != nil { + return nil, fmt.Errorf("failed to create request: %w", err) + } + + if body != nil { + req.Header.Set("Content-Type", "application/json") + } + + // Add DPoP proof + proof, err := c.dpop.GenerateProof(http.MethodPost, fullURL, accessToken) + if err != nil { + return nil, err + } + req.Header.Set("DPoP", proof) + + // Add extra headers (Authorization, X-Nylas-Org) + for k, v := range extraHeaders { + req.Header.Set(k, v) + } + + resp, err := c.httpClient.Do(req) + if err != nil { + return nil, fmt.Errorf("request failed: %w", err) + } + defer func() { _ = resp.Body.Close() }() + + respBody, err := io.ReadAll(io.LimitReader(resp.Body, maxResponseBody)) + if err != nil { + return nil, fmt.Errorf("failed to read response: %w", err) + } + + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return nil, parseErrorResponse(resp.StatusCode, respBody) + } + + // Unwrap the {request_id, success, data} envelope + data, unwrapErr := unwrapEnvelope(respBody) + if unwrapErr != nil { + return nil, unwrapErr + } + + return data, nil +} + +// unwrapEnvelope extracts the "data" field from the API response envelope. +// The dashboard-account API wraps all successful responses in: +// +// {"request_id": "...", "success": true, "data": {...}} +func unwrapEnvelope(body []byte) ([]byte, error) { + var envelope struct { + Data json.RawMessage `json:"data"` + } + if err := json.Unmarshal(body, &envelope); err != nil { + return nil, fmt.Errorf("failed to decode response envelope: %w", err) + } + if len(envelope.Data) == 0 { + return body, nil // no envelope, return as-is + } + return envelope.Data, nil +} + +// DashboardAPIError represents an error from the dashboard API. +// It carries the status code and server message for debugging. +type DashboardAPIError struct { + StatusCode int + ServerMsg string +} + +func (e *DashboardAPIError) Error() string { + if e.ServerMsg != "" { + return fmt.Sprintf("dashboard API error (HTTP %d): %s", e.StatusCode, e.ServerMsg) + } + return fmt.Sprintf("dashboard API error (HTTP %d)", e.StatusCode) +} + +// parseErrorResponse extracts a user-friendly error from an HTTP error response. +// The dashboard-account error envelope is: +// +// {"request_id":"...","success":false,"error":{"code":"...","message":"..."}} +func parseErrorResponse(statusCode int, body []byte) error { + var errResp struct { + Error struct { + Code string `json:"code"` + Message string `json:"message"` + } `json:"error"` + } + msg := "" + if json.Unmarshal(body, &errResp) == nil && errResp.Error.Message != "" { + msg = errResp.Error.Message + if errResp.Error.Code != "" { + msg = errResp.Error.Code + ": " + msg + } + } + if msg == "" { + msg = string(body) + if len(msg) > 200 { + msg = msg[:200] + } + } + return &DashboardAPIError{StatusCode: statusCode, ServerMsg: msg} +} diff --git a/internal/adapters/dashboard/mock.go b/internal/adapters/dashboard/mock.go new file mode 100644 index 0000000..396a927 --- /dev/null +++ b/internal/adapters/dashboard/mock.go @@ -0,0 +1,61 @@ +package dashboard + +import ( + "context" + + "github.com/nylas/cli/internal/domain" +) + +// MockAccountClient is a test mock for ports.DashboardAccountClient. +type MockAccountClient struct { + RegisterFn func(ctx context.Context, email, password string, privacyPolicyAccepted bool) (*domain.DashboardRegisterResponse, error) + VerifyEmailCodeFn func(ctx context.Context, email, code, region string) (*domain.DashboardAuthResponse, error) + ResendVerificationCodeFn func(ctx context.Context, email string) error + LoginFn func(ctx context.Context, email, password, orgPublicID string) (*domain.DashboardAuthResponse, *domain.DashboardMFARequired, error) + LoginMFAFn func(ctx context.Context, userPublicID, code, orgPublicID string) (*domain.DashboardAuthResponse, error) + RefreshFn func(ctx context.Context, userToken, orgToken string) (*domain.DashboardRefreshResponse, error) + LogoutFn func(ctx context.Context, userToken, orgToken string) error + SSOStartFn func(ctx context.Context, loginType, mode string, privacyPolicyAccepted bool) (*domain.DashboardSSOStartResponse, error) + SSOPollFn func(ctx context.Context, flowID, orgPublicID string) (*domain.DashboardSSOPollResponse, error) +} + +func (m *MockAccountClient) Register(ctx context.Context, email, password string, privacyPolicyAccepted bool) (*domain.DashboardRegisterResponse, error) { + return m.RegisterFn(ctx, email, password, privacyPolicyAccepted) +} +func (m *MockAccountClient) VerifyEmailCode(ctx context.Context, email, code, region string) (*domain.DashboardAuthResponse, error) { + return m.VerifyEmailCodeFn(ctx, email, code, region) +} +func (m *MockAccountClient) ResendVerificationCode(ctx context.Context, email string) error { + return m.ResendVerificationCodeFn(ctx, email) +} +func (m *MockAccountClient) Login(ctx context.Context, email, password, orgPublicID string) (*domain.DashboardAuthResponse, *domain.DashboardMFARequired, error) { + return m.LoginFn(ctx, email, password, orgPublicID) +} +func (m *MockAccountClient) LoginMFA(ctx context.Context, userPublicID, code, orgPublicID string) (*domain.DashboardAuthResponse, error) { + return m.LoginMFAFn(ctx, userPublicID, code, orgPublicID) +} +func (m *MockAccountClient) Refresh(ctx context.Context, userToken, orgToken string) (*domain.DashboardRefreshResponse, error) { + return m.RefreshFn(ctx, userToken, orgToken) +} +func (m *MockAccountClient) Logout(ctx context.Context, userToken, orgToken string) error { + return m.LogoutFn(ctx, userToken, orgToken) +} +func (m *MockAccountClient) SSOStart(ctx context.Context, loginType, mode string, privacyPolicyAccepted bool) (*domain.DashboardSSOStartResponse, error) { + return m.SSOStartFn(ctx, loginType, mode, privacyPolicyAccepted) +} +func (m *MockAccountClient) SSOPoll(ctx context.Context, flowID, orgPublicID string) (*domain.DashboardSSOPollResponse, error) { + return m.SSOPollFn(ctx, flowID, orgPublicID) +} + +// MockGatewayClient is a test mock for ports.DashboardGatewayClient. +type MockGatewayClient struct { + ListApplicationsFn func(ctx context.Context, orgPublicID, region, userToken, orgToken string) ([]domain.GatewayApplication, error) + CreateApplicationFn func(ctx context.Context, orgPublicID, region, name, userToken, orgToken string) (*domain.GatewayCreatedApplication, error) +} + +func (m *MockGatewayClient) ListApplications(ctx context.Context, orgPublicID, region, userToken, orgToken string) ([]domain.GatewayApplication, error) { + return m.ListApplicationsFn(ctx, orgPublicID, region, userToken, orgToken) +} +func (m *MockGatewayClient) CreateApplication(ctx context.Context, orgPublicID, region, name, userToken, orgToken string) (*domain.GatewayCreatedApplication, error) { + return m.CreateApplicationFn(ctx, orgPublicID, region, name, userToken, orgToken) +} diff --git a/internal/adapters/dpop/dpop.go b/internal/adapters/dpop/dpop.go new file mode 100644 index 0000000..9f18091 --- /dev/null +++ b/internal/adapters/dpop/dpop.go @@ -0,0 +1,171 @@ +// Package dpop implements DPoP (Demonstrating Proof-of-Possession) proof +// generation using Ed25519 keys for CLI authentication. +package dpop + +import ( + "crypto/ed25519" + "crypto/rand" + "crypto/sha256" + "encoding/base64" + "encoding/json" + "fmt" + "net/url" + "strings" + "time" + + "github.com/google/uuid" + "github.com/nylas/cli/internal/domain" + "github.com/nylas/cli/internal/ports" +) + +// Service implements the ports.DPoP interface using Ed25519 keys. +type Service struct { + privateKey ed25519.PrivateKey + publicKey ed25519.PublicKey + thumbprint string +} + +// New creates a DPoP service, loading an existing key from the secret store +// or generating a new Ed25519 keypair if none exists. +func New(secrets ports.SecretStore) (*Service, error) { + s := &Service{} + + // Try to load existing key + seedB64, err := secrets.Get(ports.KeyDashboardDPoPKey) + if err == nil && seedB64 != "" { + seed, decErr := base64.StdEncoding.DecodeString(seedB64) + if decErr == nil && len(seed) == ed25519.SeedSize { + s.privateKey = ed25519.NewKeyFromSeed(seed) + s.publicKey = s.privateKey.Public().(ed25519.PublicKey) + s.thumbprint = s.computeThumbprint() + return s, nil + } + } + + // Generate new keypair + pub, priv, err := ed25519.GenerateKey(rand.Reader) + if err != nil { + return nil, fmt.Errorf("%w: %w", domain.ErrDashboardDPoP, err) + } + s.privateKey = priv + s.publicKey = pub + + // Persist the seed (first 32 bytes of the 64-byte private key) + seed := priv.Seed() + if err := secrets.Set(ports.KeyDashboardDPoPKey, base64.StdEncoding.EncodeToString(seed)); err != nil { + return nil, fmt.Errorf("failed to store DPoP key: %w", err) + } + + s.thumbprint = s.computeThumbprint() + return s, nil +} + +// GenerateProof creates a DPoP proof JWT for the given HTTP method and URL. +// If accessToken is non-empty, the proof includes an ath claim. +func (s *Service) GenerateProof(method, rawURL string, accessToken string) (string, error) { + // Normalize the URL: strip fragment and query + htu, err := normalizeHTU(rawURL) + if err != nil { + return "", fmt.Errorf("%w: invalid URL: %w", domain.ErrDashboardDPoP, err) + } + + // Build header + header := jwtHeader{ + Typ: "dpop+jwt", + Alg: "EdDSA", + JWK: &jwkOKP{ + Kty: "OKP", + Crv: "Ed25519", + X: base64urlEncode(s.publicKey), + }, + } + + // Build claims + claims := jwtClaims{ + JTI: uuid.NewString(), + HTM: strings.ToUpper(method), + HTU: htu, + IAT: time.Now().Unix(), + } + + // Add access token hash if provided + if accessToken != "" { + hash := sha256.Sum256([]byte(accessToken)) + claims.ATH = base64urlEncode(hash[:]) + } + + headerJSON, err := json.Marshal(header) + if err != nil { + return "", fmt.Errorf("%w: %w", domain.ErrDashboardDPoP, err) + } + + claimsJSON, err := json.Marshal(claims) + if err != nil { + return "", fmt.Errorf("%w: %w", domain.ErrDashboardDPoP, err) + } + + // Create signing input + headerB64 := base64urlEncode(headerJSON) + claimsB64 := base64urlEncode(claimsJSON) + signingInput := headerB64 + "." + claimsB64 + + // Sign with Ed25519 + signature := ed25519.Sign(s.privateKey, []byte(signingInput)) + + return signingInput + "." + base64urlEncode(signature), nil +} + +// Thumbprint returns the JWK thumbprint (RFC 7638) of the DPoP public key. +func (s *Service) Thumbprint() string { + return s.thumbprint +} + +// computeThumbprint computes the RFC 7638 JWK thumbprint. +// For OKP keys, the canonical JSON uses lexicographically sorted members: +// {"crv":"Ed25519","kty":"OKP","x":""} +func (s *Service) computeThumbprint() string { + canonical := fmt.Sprintf( + `{"crv":"Ed25519","kty":"OKP","x":"%s"}`, + base64urlEncode(s.publicKey), + ) + hash := sha256.Sum256([]byte(canonical)) + return base64urlEncode(hash[:]) +} + +// normalizeHTU strips the fragment and query from a URL per DPoP spec. +func normalizeHTU(rawURL string) (string, error) { + u, err := url.Parse(rawURL) + if err != nil { + return "", err + } + u.Fragment = "" + u.RawQuery = "" + return u.String(), nil +} + +// base64urlEncode encodes bytes as base64url without padding. +func base64urlEncode(data []byte) string { + return base64.RawURLEncoding.EncodeToString(data) +} + +// JWT types for serialization. + +type jwtHeader struct { + Typ string `json:"typ"` + Alg string `json:"alg"` + JWK *jwkOKP `json:"jwk"` +} + +type jwkOKP struct { + Kty string `json:"kty"` + Crv string `json:"crv"` + X string `json:"x"` +} + +type jwtClaims struct { + JTI string `json:"jti"` + HTM string `json:"htm"` + HTU string `json:"htu"` + IAT int64 `json:"iat"` + ATH string `json:"ath,omitempty"` +} diff --git a/internal/adapters/dpop/dpop_test.go b/internal/adapters/dpop/dpop_test.go new file mode 100644 index 0000000..9c07ef3 --- /dev/null +++ b/internal/adapters/dpop/dpop_test.go @@ -0,0 +1,288 @@ +package dpop + +import ( + "crypto/ed25519" + "crypto/sha256" + "encoding/base64" + "encoding/json" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// mockSecretStore is a simple in-memory secret store for testing. +type mockSecretStore struct { + data map[string]string +} + +func newMockSecretStore() *mockSecretStore { + return &mockSecretStore{data: make(map[string]string)} +} + +func (m *mockSecretStore) Set(key, value string) error { m.data[key] = value; return nil } +func (m *mockSecretStore) Get(key string) (string, error) { + v, ok := m.data[key] + if !ok { + return "", nil + } + return v, nil +} +func (m *mockSecretStore) Delete(key string) error { delete(m.data, key); return nil } +func (m *mockSecretStore) IsAvailable() bool { return true } +func (m *mockSecretStore) Name() string { return "mock" } + +func TestNew_GeneratesKey(t *testing.T) { + t.Parallel() + store := newMockSecretStore() + + svc, err := New(store) + require.NoError(t, err) + require.NotNil(t, svc) + + // Key should be persisted + seedB64, err := store.Get("dashboard_dpop_key") + require.NoError(t, err) + assert.NotEmpty(t, seedB64) + + // Seed should be 32 bytes + seed, err := base64.StdEncoding.DecodeString(seedB64) + require.NoError(t, err) + assert.Len(t, seed, ed25519.SeedSize) +} + +func TestNew_LoadsExistingKey(t *testing.T) { + t.Parallel() + store := newMockSecretStore() + + // Create first instance + svc1, err := New(store) + require.NoError(t, err) + thumb1 := svc1.Thumbprint() + + // Create second instance - should load same key + svc2, err := New(store) + require.NoError(t, err) + thumb2 := svc2.Thumbprint() + + assert.Equal(t, thumb1, thumb2, "reloaded key should produce same thumbprint") +} + +func TestGenerateProof_Structure(t *testing.T) { + t.Parallel() + store := newMockSecretStore() + svc, err := New(store) + require.NoError(t, err) + + proof, err := svc.GenerateProof("POST", "https://example.com/auth/cli/login", "") + require.NoError(t, err) + + parts := strings.Split(proof, ".") + require.Len(t, parts, 3, "JWT should have 3 parts") + + // Decode and verify header + headerJSON, err := base64.RawURLEncoding.DecodeString(parts[0]) + require.NoError(t, err) + + var header jwtHeader + require.NoError(t, json.Unmarshal(headerJSON, &header)) + assert.Equal(t, "dpop+jwt", header.Typ) + assert.Equal(t, "EdDSA", header.Alg) + require.NotNil(t, header.JWK) + assert.Equal(t, "OKP", header.JWK.Kty) + assert.Equal(t, "Ed25519", header.JWK.Crv) + assert.NotEmpty(t, header.JWK.X) + + // Decode and verify claims + claimsJSON, err := base64.RawURLEncoding.DecodeString(parts[1]) + require.NoError(t, err) + + var claims jwtClaims + require.NoError(t, json.Unmarshal(claimsJSON, &claims)) + assert.NotEmpty(t, claims.JTI, "jti must be present") + assert.Equal(t, "POST", claims.HTM) + assert.Equal(t, "https://example.com/auth/cli/login", claims.HTU) + assert.NotZero(t, claims.IAT) + assert.Empty(t, claims.ATH, "ath should be empty when no access token") +} + +func TestGenerateProof_WithAccessToken(t *testing.T) { + t.Parallel() + store := newMockSecretStore() + svc, err := New(store) + require.NoError(t, err) + + accessToken := "test-access-token" + proof, err := svc.GenerateProof("GET", "https://example.com/api", accessToken) + require.NoError(t, err) + + parts := strings.Split(proof, ".") + require.Len(t, parts, 3) + + claimsJSON, err := base64.RawURLEncoding.DecodeString(parts[1]) + require.NoError(t, err) + + var claims jwtClaims + require.NoError(t, json.Unmarshal(claimsJSON, &claims)) + assert.NotEmpty(t, claims.ATH, "ath should be present when access token provided") + + // Verify ath is SHA-256 of the access token + expectedHash := sha256.Sum256([]byte(accessToken)) + expectedATH := base64.RawURLEncoding.EncodeToString(expectedHash[:]) + assert.Equal(t, expectedATH, claims.ATH) +} + +func TestGenerateProof_SignatureVerifies(t *testing.T) { + t.Parallel() + store := newMockSecretStore() + svc, err := New(store) + require.NoError(t, err) + + proof, err := svc.GenerateProof("POST", "https://example.com/test", "") + require.NoError(t, err) + + parts := strings.Split(proof, ".") + require.Len(t, parts, 3) + + // Extract public key from header + headerJSON, err := base64.RawURLEncoding.DecodeString(parts[0]) + require.NoError(t, err) + + var header jwtHeader + require.NoError(t, json.Unmarshal(headerJSON, &header)) + + pubKeyBytes, err := base64.RawURLEncoding.DecodeString(header.JWK.X) + require.NoError(t, err) + pubKey := ed25519.PublicKey(pubKeyBytes) + + // Verify signature + signingInput := []byte(parts[0] + "." + parts[1]) + signature, err := base64.RawURLEncoding.DecodeString(parts[2]) + require.NoError(t, err) + + assert.True(t, ed25519.Verify(pubKey, signingInput, signature), "signature should verify") +} + +func TestGenerateProof_UniqueJTI(t *testing.T) { + t.Parallel() + store := newMockSecretStore() + svc, err := New(store) + require.NoError(t, err) + + proof1, err := svc.GenerateProof("POST", "https://example.com/test", "") + require.NoError(t, err) + + proof2, err := svc.GenerateProof("POST", "https://example.com/test", "") + require.NoError(t, err) + + // Extract JTIs + jti1 := extractClaim(t, proof1, "jti") + jti2 := extractClaim(t, proof2, "jti") + + assert.NotEqual(t, jti1, jti2, "each proof should have a unique jti") +} + +func TestGenerateProof_MethodUppercased(t *testing.T) { + t.Parallel() + store := newMockSecretStore() + svc, err := New(store) + require.NoError(t, err) + + proof, err := svc.GenerateProof("post", "https://example.com/test", "") + require.NoError(t, err) + + htm := extractClaim(t, proof, "htm") + assert.Equal(t, "POST", htm) +} + +func TestGenerateProof_URLNormalization(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + inputURL string + expected string + }{ + { + name: "strips fragment", + inputURL: "https://example.com/path#fragment", + expected: "https://example.com/path", + }, + { + name: "strips query", + inputURL: "https://example.com/path?key=value", + expected: "https://example.com/path", + }, + { + name: "preserves path", + inputURL: "https://example.com/auth/cli/login", + expected: "https://example.com/auth/cli/login", + }, + } + + store := newMockSecretStore() + svc, err := New(store) + require.NoError(t, err) + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + proof, err := svc.GenerateProof("POST", tt.inputURL, "") + require.NoError(t, err) + + htu := extractClaim(t, proof, "htu") + assert.Equal(t, tt.expected, htu) + }) + } +} + +func TestThumbprint_Consistent(t *testing.T) { + t.Parallel() + store := newMockSecretStore() + svc, err := New(store) + require.NoError(t, err) + + thumb1 := svc.Thumbprint() + thumb2 := svc.Thumbprint() + + assert.NotEmpty(t, thumb1) + assert.Equal(t, thumb1, thumb2, "thumbprint should be deterministic") +} + +func TestThumbprint_MatchesRFC7638(t *testing.T) { + t.Parallel() + store := newMockSecretStore() + svc, err := New(store) + require.NoError(t, err) + + // Manually compute the expected thumbprint + xB64 := base64urlEncode(svc.publicKey) + canonical := `{"crv":"Ed25519","kty":"OKP","x":"` + xB64 + `"}` + hash := sha256.Sum256([]byte(canonical)) + expected := base64urlEncode(hash[:]) + + assert.Equal(t, expected, svc.Thumbprint()) +} + +// extractClaim extracts a string claim value from a JWT proof. +func extractClaim(t *testing.T, proof, key string) string { + t.Helper() + parts := strings.Split(proof, ".") + require.Len(t, parts, 3) + + claimsJSON, err := base64.RawURLEncoding.DecodeString(parts[1]) + require.NoError(t, err) + + var raw map[string]any + require.NoError(t, json.Unmarshal(claimsJSON, &raw)) + + val, ok := raw[key] + require.True(t, ok, "claim %q not found", key) + + str, ok := val.(string) + if ok { + return str + } + return "" +} diff --git a/internal/app/dashboard/app_service.go b/internal/app/dashboard/app_service.go new file mode 100644 index 0000000..ad2b15a --- /dev/null +++ b/internal/app/dashboard/app_service.go @@ -0,0 +1,118 @@ +package dashboard + +import ( + "context" + "fmt" + "sync" + + "github.com/nylas/cli/internal/domain" + "github.com/nylas/cli/internal/ports" +) + +// AppService handles application management via the dashboard API gateway. +type AppService struct { + gateway ports.DashboardGatewayClient + secrets ports.SecretStore +} + +// NewAppService creates a new application management service. +func NewAppService(gateway ports.DashboardGatewayClient, secrets ports.SecretStore) *AppService { + return &AppService{ + gateway: gateway, + secrets: secrets, + } +} + +// ListApplications retrieves applications from both US and EU regions in parallel. +// If regionFilter is non-empty, only that region is queried. +func (s *AppService) ListApplications(ctx context.Context, orgPublicID, regionFilter string) ([]domain.GatewayApplication, error) { + userToken, orgToken, err := s.loadTokens() + if err != nil { + return nil, err + } + + if regionFilter != "" { + return s.gateway.ListApplications(ctx, orgPublicID, regionFilter, userToken, orgToken) + } + + // Query both regions in parallel + type result struct { + apps []domain.GatewayApplication + err error + } + + var wg sync.WaitGroup + results := make([]result, 2) + regions := []string{"us", "eu"} + + for i, region := range regions { + wg.Add(1) + go func(idx int, r string) { + defer wg.Done() + apps, err := s.gateway.ListApplications(ctx, orgPublicID, r, userToken, orgToken) + results[idx] = result{apps: apps, err: err} + }(i, region) + } + wg.Wait() + + var allApps []domain.GatewayApplication + var errs []error + for _, r := range results { + if r.err != nil { + errs = append(errs, r.err) + continue + } + allApps = append(allApps, r.apps...) + } + + // If both failed, return the first error + if len(errs) == len(regions) { + return nil, fmt.Errorf("failed to list applications: %w", errs[0]) + } + + allApps = deduplicateApps(allApps) + + return allApps, nil +} + +// CreateApplication creates a new application in the specified region. +func (s *AppService) CreateApplication(ctx context.Context, orgPublicID, region, name string) (*domain.GatewayCreatedApplication, error) { + userToken, orgToken, err := s.loadTokens() + if err != nil { + return nil, err + } + + return s.gateway.CreateApplication(ctx, orgPublicID, region, name, userToken, orgToken) +} + +// deduplicateApps removes duplicate applications (same applicationId). +func deduplicateApps(apps []domain.GatewayApplication) []domain.GatewayApplication { + seen := make(map[string]bool, len(apps)) + out := make([]domain.GatewayApplication, 0, len(apps)) + for _, app := range apps { + key := app.ApplicationID + if key == "" { + // Use a composite key for apps without an ID + key = app.Region + ":" + app.Environment + ":" + if app.Branding != nil { + key += app.Branding.Name + } + } + if seen[key] { + continue + } + seen[key] = true + out = append(out, app) + } + return out +} + +// loadTokens retrieves the stored dashboard tokens. +func (s *AppService) loadTokens() (userToken, orgToken string, err error) { + userToken, err = s.secrets.Get(ports.KeyDashboardUserToken) + if err != nil || userToken == "" { + return "", "", fmt.Errorf("%w", domain.ErrDashboardNotLoggedIn) + } + orgToken, _ = s.secrets.Get(ports.KeyDashboardOrgToken) + return userToken, orgToken, nil +} diff --git a/internal/app/dashboard/auth_service.go b/internal/app/dashboard/auth_service.go new file mode 100644 index 0000000..fc229a0 --- /dev/null +++ b/internal/app/dashboard/auth_service.go @@ -0,0 +1,210 @@ +// Package dashboard provides the application-layer orchestration for +// dashboard authentication and application management. +package dashboard + +import ( + "context" + "fmt" + + "github.com/nylas/cli/internal/domain" + "github.com/nylas/cli/internal/ports" +) + +// AuthService orchestrates dashboard auth flows and manages token lifecycle. +type AuthService struct { + account ports.DashboardAccountClient + secrets ports.SecretStore +} + +// NewAuthService creates a new dashboard auth service. +func NewAuthService(account ports.DashboardAccountClient, secrets ports.SecretStore) *AuthService { + return &AuthService{ + account: account, + secrets: secrets, + } +} + +// Register creates a new dashboard account and triggers email verification. +func (s *AuthService) Register(ctx context.Context, email, password string, privacyPolicyAccepted bool) (*domain.DashboardRegisterResponse, error) { + return s.account.Register(ctx, email, password, privacyPolicyAccepted) +} + +// VerifyEmailCode verifies the email code and stores resulting tokens. +func (s *AuthService) VerifyEmailCode(ctx context.Context, email, code, region string) (*domain.DashboardAuthResponse, error) { + resp, err := s.account.VerifyEmailCode(ctx, email, code, region) + if err != nil { + return nil, err + } + + if err := s.storeTokens(resp); err != nil { + return nil, fmt.Errorf("failed to store credentials: %w", err) + } + return resp, nil +} + +// ResendVerificationCode resends the email verification code. +func (s *AuthService) ResendVerificationCode(ctx context.Context, email string) error { + return s.account.ResendVerificationCode(ctx, email) +} + +// Login authenticates with email and password. +// Returns (auth, nil) on success, (nil, mfa) when MFA is required. +func (s *AuthService) Login(ctx context.Context, email, password, orgPublicID string) (*domain.DashboardAuthResponse, *domain.DashboardMFARequired, error) { + auth, mfa, err := s.account.Login(ctx, email, password, orgPublicID) + if err != nil { + return nil, nil, err + } + + if auth != nil { + if err := s.storeTokens(auth); err != nil { + return nil, nil, fmt.Errorf("failed to store credentials: %w", err) + } + return auth, nil, nil + } + + return nil, mfa, nil +} + +// CompleteMFA finishes MFA authentication and stores tokens. +func (s *AuthService) CompleteMFA(ctx context.Context, userPublicID, code, orgPublicID string) (*domain.DashboardAuthResponse, error) { + resp, err := s.account.LoginMFA(ctx, userPublicID, code, orgPublicID) + if err != nil { + return nil, err + } + + if err := s.storeTokens(resp); err != nil { + return nil, fmt.Errorf("failed to store credentials: %w", err) + } + return resp, nil +} + +// Refresh refreshes the session tokens using the stored tokens. +func (s *AuthService) Refresh(ctx context.Context) error { + userToken, orgToken, err := s.loadTokens() + if err != nil { + return err + } + + resp, err := s.account.Refresh(ctx, userToken, orgToken) + if err != nil { + return err + } + + if err := s.secrets.Set(ports.KeyDashboardUserToken, resp.UserToken); err != nil { + return fmt.Errorf("failed to store refreshed user token: %w", err) + } + if resp.OrgToken != "" { + if err := s.secrets.Set(ports.KeyDashboardOrgToken, resp.OrgToken); err != nil { + return fmt.Errorf("failed to store refreshed org token: %w", err) + } + } + + return nil +} + +// Logout invalidates the session and clears local tokens. +func (s *AuthService) Logout(ctx context.Context) error { + userToken, orgToken, _ := s.loadTokens() + + // Best effort: call the server to invalidate tokens + if userToken != "" { + _ = s.account.Logout(ctx, userToken, orgToken) + } + + // Always clear local state + s.clearTokens() + return nil +} + +// SSOStart initiates an SSO device authorization flow. +func (s *AuthService) SSOStart(ctx context.Context, loginType, mode string, privacyPolicyAccepted bool) (*domain.DashboardSSOStartResponse, error) { + return s.account.SSOStart(ctx, loginType, mode, privacyPolicyAccepted) +} + +// SSOPoll polls the SSO device flow. On completion, stores tokens. +func (s *AuthService) SSOPoll(ctx context.Context, flowID, orgPublicID string) (*domain.DashboardSSOPollResponse, error) { + resp, err := s.account.SSOPoll(ctx, flowID, orgPublicID) + if err != nil { + return nil, err + } + + if resp.Status == domain.SSOStatusComplete && resp.Auth != nil { + if err := s.storeTokens(resp.Auth); err != nil { + return nil, fmt.Errorf("failed to store credentials: %w", err) + } + } + + return resp, nil +} + +// IsLoggedIn returns true if dashboard tokens exist in the keyring. +func (s *AuthService) IsLoggedIn() bool { + token, err := s.secrets.Get(ports.KeyDashboardUserToken) + return err == nil && token != "" +} + +// Status represents the current dashboard authentication status. +type Status struct { + LoggedIn bool + UserID string + OrgID string + HasOrgToken bool +} + +// GetStatus returns the current dashboard auth status. +func (s *AuthService) GetStatus() Status { + st := Status{} + userToken, _ := s.secrets.Get(ports.KeyDashboardUserToken) + st.LoggedIn = userToken != "" + st.UserID, _ = s.secrets.Get(ports.KeyDashboardUserPublicID) + st.OrgID, _ = s.secrets.Get(ports.KeyDashboardOrgPublicID) + orgToken, _ := s.secrets.Get(ports.KeyDashboardOrgToken) + st.HasOrgToken = orgToken != "" + return st +} + +// storeTokens persists auth tokens and user/org identifiers. +func (s *AuthService) storeTokens(resp *domain.DashboardAuthResponse) error { + if err := s.secrets.Set(ports.KeyDashboardUserToken, resp.UserToken); err != nil { + return err + } + if resp.OrgToken != "" { + if err := s.secrets.Set(ports.KeyDashboardOrgToken, resp.OrgToken); err != nil { + return err + } + } + if resp.User.PublicID != "" { + if err := s.secrets.Set(ports.KeyDashboardUserPublicID, resp.User.PublicID); err != nil { + return err + } + } + if len(resp.Organizations) > 0 { + if err := s.secrets.Set(ports.KeyDashboardOrgPublicID, resp.Organizations[0].PublicID); err != nil { + return err + } + } + return nil +} + +// SetActiveOrg updates the active organization. +func (s *AuthService) SetActiveOrg(orgPublicID string) error { + return s.secrets.Set(ports.KeyDashboardOrgPublicID, orgPublicID) +} + +// clearTokens removes all dashboard auth data from the keyring. +func (s *AuthService) clearTokens() { + _ = s.secrets.Delete(ports.KeyDashboardUserToken) + _ = s.secrets.Delete(ports.KeyDashboardOrgToken) + _ = s.secrets.Delete(ports.KeyDashboardUserPublicID) + _ = s.secrets.Delete(ports.KeyDashboardOrgPublicID) +} + +// loadTokens retrieves the stored tokens. +func (s *AuthService) loadTokens() (userToken, orgToken string, err error) { + userToken, err = s.secrets.Get(ports.KeyDashboardUserToken) + if err != nil || userToken == "" { + return "", "", fmt.Errorf("%w", domain.ErrDashboardNotLoggedIn) + } + orgToken, _ = s.secrets.Get(ports.KeyDashboardOrgToken) + return userToken, orgToken, nil +} diff --git a/internal/cli/dashboard/apps.go b/internal/cli/dashboard/apps.go new file mode 100644 index 0000000..5dd97ac --- /dev/null +++ b/internal/cli/dashboard/apps.go @@ -0,0 +1,195 @@ +package dashboard + +import ( + "fmt" + + "github.com/spf13/cobra" + + "github.com/nylas/cli/internal/cli/common" + "github.com/nylas/cli/internal/domain" + "github.com/nylas/cli/internal/ports" +) + +func newAppsCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "apps", + Short: "Manage Nylas applications", + Long: `List and create Nylas applications via the Dashboard API.`, + } + + cmd.AddCommand(newAppsListCmd()) + cmd.AddCommand(newAppsCreateCmd()) + + return cmd +} + +// appRow is a flat struct for table output. +type appRow struct { + ApplicationID string `json:"application_id"` + Region string `json:"region"` + Environment string `json:"environment"` + Name string `json:"name"` +} + +var appColumns = []ports.Column{ + {Header: "APPLICATION ID", Field: "ApplicationID"}, + {Header: "REGION", Field: "Region"}, + {Header: "ENVIRONMENT", Field: "Environment"}, + {Header: "NAME", Field: "Name"}, +} + +func newAppsListCmd() *cobra.Command { + var region string + + cmd := &cobra.Command{ + Use: "list", + Short: "List applications", + Long: `List all Nylas applications in your organization. + +By default, queries both US and EU regions and merges results. +Use --region to filter to a specific region.`, + Example: ` # List all applications + nylas dashboard apps list + + # List only US applications + nylas dashboard apps list --region us`, + RunE: func(cmd *cobra.Command, args []string) error { + appSvc, err := createAppService() + if err != nil { + return wrapDashboardError(err) + } + + orgPublicID, err := getActiveOrgID() + if err != nil { + return wrapDashboardError(err) + } + + ctx, cancel := common.CreateContext() + defer cancel() + + apps, err := appSvc.ListApplications(ctx, orgPublicID, region) + if err != nil { + return wrapDashboardError(err) + } + + if len(apps) == 0 { + fmt.Println("No applications found.") + fmt.Println("\nCreate one with: nylas dashboard apps create --name MyApp --region us") + return nil + } + + rows := toAppRows(apps) + return common.WriteListWithColumns(cmd, rows, appColumns) + }, + } + + cmd.Flags().StringVarP(®ion, "region", "r", "", "Filter by region (us or eu)") + + return cmd +} + +func newAppsCreateCmd() *cobra.Command { + var ( + name string + region string + ) + + cmd := &cobra.Command{ + Use: "create", + Short: "Create a new application", + Long: `Create a new Nylas application in the specified region.`, + Example: ` # Create a US application + nylas dashboard apps create --name "My App" --region us + + # Create an EU application + nylas dashboard apps create --name "EU App" --region eu`, + RunE: func(cmd *cobra.Command, args []string) error { + if name == "" { + return dashboardError("application name is required", "Use --name to specify the application name") + } + if region == "" { + return dashboardError("region is required", "Use --region us or --region eu") + } + if region != "us" && region != "eu" { + return dashboardError("invalid region", "Use --region us or --region eu") + } + + appSvc, err := createAppService() + if err != nil { + return wrapDashboardError(err) + } + + orgPublicID, err := getActiveOrgID() + if err != nil { + return wrapDashboardError(err) + } + + ctx, cancel := common.CreateContext() + defer cancel() + + app, err := appSvc.CreateApplication(ctx, orgPublicID, region, name) + if err != nil { + return wrapDashboardError(err) + } + + _, _ = common.Green.Println("✓ Application created!") + fmt.Printf(" Application ID: %s\n", app.ApplicationID) + fmt.Printf(" Region: %s\n", app.Region) + if app.Environment != "" { + fmt.Printf(" Environment: %s\n", app.Environment) + } + + _, _ = common.Yellow.Println("\n Client Secret (shown once — save it now):") + fmt.Printf(" %s\n", app.ClientSecret) + + fmt.Println("\nTo configure the CLI with this application:") + fmt.Printf(" nylas auth config --api-key --region %s\n", app.Region) + + return nil + }, + } + + cmd.Flags().StringVarP(&name, "name", "n", "", "Application name (required)") + cmd.Flags().StringVarP(®ion, "region", "r", "", "Region (required: us or eu)") + + return cmd +} + +// getActiveOrgID retrieves the active organization ID from the keyring. +func getActiveOrgID() (string, error) { + _, secrets, err := createDPoPService() + if err != nil { + return "", err + } + + orgID, err := secrets.Get(ports.KeyDashboardOrgPublicID) + if err != nil || orgID == "" { + return "", dashboardError( + "no active organization", + "Run 'nylas dashboard login' first", + ) + } + return orgID, nil +} + +// toAppRows converts gateway applications to flat display rows. +func toAppRows(apps []domain.GatewayApplication) []appRow { + rows := make([]appRow, len(apps)) + for i, app := range apps { + name := "" + if app.Branding != nil { + name = app.Branding.Name + } + env := app.Environment + if env == "" { + env = "production" + } + rows[i] = appRow{ + ApplicationID: app.ApplicationID, + Region: app.Region, + Environment: env, + Name: name, + } + } + return rows +} diff --git a/internal/cli/dashboard/dashboard.go b/internal/cli/dashboard/dashboard.go new file mode 100644 index 0000000..113ac6a --- /dev/null +++ b/internal/cli/dashboard/dashboard.go @@ -0,0 +1,35 @@ +// Package dashboard provides the CLI commands for Nylas Dashboard +// account authentication and application management. +package dashboard + +import ( + "github.com/spf13/cobra" +) + +// NewDashboardCmd creates the dashboard command group. +func NewDashboardCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "dashboard", + Short: "Nylas Dashboard account and application management", + Long: `Authenticate with the Nylas Dashboard and manage applications. + +Commands: + register Create a new Nylas Dashboard account + login Log in to your Nylas Dashboard account + sso Authenticate via SSO (Google, Microsoft, GitHub) + logout Log out of the Nylas Dashboard + status Show current dashboard authentication status + refresh Refresh dashboard session tokens + apps Manage Nylas applications`, + } + + cmd.AddCommand(newRegisterCmd()) + cmd.AddCommand(newLoginCmd()) + cmd.AddCommand(newSSOCmd()) + cmd.AddCommand(newLogoutCmd()) + cmd.AddCommand(newStatusCmd()) + cmd.AddCommand(newRefreshCmd()) + cmd.AddCommand(newAppsCmd()) + + return cmd +} diff --git a/internal/cli/dashboard/helpers.go b/internal/cli/dashboard/helpers.go new file mode 100644 index 0000000..9b8964e --- /dev/null +++ b/internal/cli/dashboard/helpers.go @@ -0,0 +1,237 @@ +package dashboard + +import ( + "bufio" + "errors" + "fmt" + "os" + "strings" + + "golang.org/x/term" + + "github.com/nylas/cli/internal/adapters/config" + "github.com/nylas/cli/internal/adapters/dashboard" + "github.com/nylas/cli/internal/adapters/dpop" + "github.com/nylas/cli/internal/adapters/keyring" + dashboardapp "github.com/nylas/cli/internal/app/dashboard" + "github.com/nylas/cli/internal/cli/common" + "github.com/nylas/cli/internal/domain" + "github.com/nylas/cli/internal/ports" +) + +// createDPoPService creates a DPoP service backed by the keyring. +func createDPoPService() (ports.DPoP, ports.SecretStore, error) { + secretStore, err := keyring.NewSecretStore(config.DefaultConfigDir()) + if err != nil { + return nil, nil, err + } + + dpopSvc, err := dpop.New(secretStore) + if err != nil { + return nil, nil, err + } + + return dpopSvc, secretStore, nil +} + +// createAuthService creates the full dashboard auth service chain. +func createAuthService() (*dashboardapp.AuthService, ports.SecretStore, error) { + dpopSvc, secretStore, err := createDPoPService() + if err != nil { + return nil, nil, err + } + + baseURL := getDashboardAccountBaseURL(secretStore) + accountClient := dashboard.NewAccountClient(baseURL, dpopSvc) + + return dashboardapp.NewAuthService(accountClient, secretStore), secretStore, nil +} + +// createAppService creates the dashboard app management service. +func createAppService() (*dashboardapp.AppService, error) { + dpopSvc, secretStore, err := createDPoPService() + if err != nil { + return nil, err + } + + gatewayClient := dashboard.NewGatewayClient(dpopSvc) + return dashboardapp.NewAppService(gatewayClient, secretStore), nil +} + +// getDashboardAccountBaseURL returns the dashboard-account base URL. +// Priority: NYLAS_DASHBOARD_ACCOUNT_URL env var > config file > default. +func getDashboardAccountBaseURL(secrets ports.SecretStore) string { + if envURL := os.Getenv("NYLAS_DASHBOARD_ACCOUNT_URL"); envURL != "" { + return envURL + } + configStore := config.NewDefaultFileStore() + cfg, err := configStore.Load() + if err == nil && cfg.Dashboard != nil && cfg.Dashboard.AccountBaseURL != "" { + return cfg.Dashboard.AccountBaseURL + } + return domain.DefaultDashboardAccountBaseURL +} + +// readPassword prompts for a password without terminal echo. +func readPassword(prompt string) (string, error) { + fmt.Print(prompt) + pwBytes, err := term.ReadPassword(int(os.Stdin.Fd())) + fmt.Println() + if err != nil { + return "", fmt.Errorf("failed to read input: %w", err) + } + return strings.TrimSpace(string(pwBytes)), nil +} + +// readLine prompts for a line of text input. +func readLine(prompt string) (string, error) { + fmt.Print(prompt) + reader := bufio.NewReader(os.Stdin) + input, err := reader.ReadString('\n') + if err != nil { + return "", fmt.Errorf("failed to read input: %w", err) + } + return strings.TrimSpace(input), nil +} + +// wrapDashboardError wraps a dashboard error as a CLIError, preserving +// the actual error message. +func wrapDashboardError(err error) error { + if err == nil { + return nil + } + var cliErr *common.CLIError + if errors.As(err, &cliErr) { + return cliErr + } + return &common.CLIError{ + Err: err, + Message: err.Error(), + } +} + +// dashboardError creates a user-friendly error with the hint included in the +// message itself, so it's always visible regardless of how the error is displayed. +func dashboardError(message, hint string) error { + if hint != "" { + message = message + "\n Hint: " + hint + } + return &common.CLIError{Message: message} +} + +// Auth method resolution: flags take priority, then interactive menu. +const ( + methodGoogle = "google" + methodMicrosoft = "microsoft" + methodGitHub = "github" + methodEmailPassword = "email" +) + +// resolveAuthMethod determines the auth method from flags or prompts interactively. +func resolveAuthMethod(google, microsoft, github, email bool, action string) (string, error) { + // Count how many flags were set + set := 0 + if google { + set++ + } + if microsoft { + set++ + } + if github { + set++ + } + if email { + set++ + } + if set > 1 { + return "", dashboardError("only one auth method flag allowed", "Use --google, --microsoft, --github, or --email") + } + + switch { + case google: + return methodGoogle, nil + case microsoft: + return methodMicrosoft, nil + case github: + return methodGitHub, nil + case email: + return methodEmailPassword, nil + default: + return chooseAuthMethod(action) + } +} + +// chooseAuthMethod presents an interactive menu. SSO first. +func chooseAuthMethod(action string) (string, error) { + fmt.Printf("\nHow would you like to %s?\n\n", action) + _, _ = common.Cyan.Println(" [1] Google (recommended)") + fmt.Println(" [2] Microsoft") + fmt.Println(" [3] GitHub") + _, _ = common.Dim.Println(" [4] Email and password") + fmt.Println() + + choice, err := readLine("Choose [1-4]: ") + if err != nil { + return "", err + } + + switch strings.TrimSpace(choice) { + case "1", "": + return methodGoogle, nil + case "2": + return methodMicrosoft, nil + case "3": + return methodGitHub, nil + case "4": + return methodEmailPassword, nil + default: + return "", dashboardError("invalid selection", "Choose 1-4") + } +} + +// selectOrg prompts the user to select an organization if multiple are available. +func selectOrg(orgs []domain.DashboardOrganization) string { + if len(orgs) <= 1 { + if len(orgs) == 1 { + return orgs[0].PublicID + } + return "" + } + + fmt.Println("\nAvailable organizations:") + for i, org := range orgs { + name := org.Name + if name == "" { + name = org.PublicID + } + fmt.Printf(" [%d] %s\n", i+1, name) + } + fmt.Println() + + choice, err := readLine(fmt.Sprintf("Select organization [1-%d]: ", len(orgs))) + if err != nil { + return orgs[0].PublicID + } + + var selected int + if _, err := fmt.Sscanf(choice, "%d", &selected); err != nil || selected < 1 || selected > len(orgs) { + return orgs[0].PublicID + } + return orgs[selected-1].PublicID +} + +// printAuthSuccess prints the standard post-login success message. +func printAuthSuccess(auth *domain.DashboardAuthResponse) { + _, _ = common.Green.Printf("✓ Authenticated as %s\n", auth.User.PublicID) + if len(auth.Organizations) > 0 { + fmt.Printf(" Organization: %s\n", auth.Organizations[0].PublicID) + } +} + +// acceptPrivacyPolicy prompts for or validates privacy policy acceptance. +func acceptPrivacyPolicy() error { + if !common.Confirm("Accept Nylas Privacy Policy?", true) { + return dashboardError("privacy policy must be accepted to continue", "") + } + return nil +} diff --git a/internal/cli/dashboard/login.go b/internal/cli/dashboard/login.go new file mode 100644 index 0000000..f00ec9a --- /dev/null +++ b/internal/cli/dashboard/login.go @@ -0,0 +1,145 @@ +package dashboard + +import ( + "github.com/spf13/cobra" + + "github.com/nylas/cli/internal/cli/common" + "github.com/nylas/cli/internal/domain" +) + +func newLoginCmd() *cobra.Command { + var ( + orgPublicID string + google bool + microsoft bool + github bool + emailFlag bool + userFlag string + passFlag string + ) + + cmd := &cobra.Command{ + Use: "login", + Short: "Log in to your Nylas Dashboard account", + Long: `Authenticate with the Nylas Dashboard. + +Choose SSO (recommended) or email/password. Pass a flag to skip the menu.`, + Example: ` # Interactive — choose auth method + nylas dashboard login + + # Google SSO (non-interactive) + nylas dashboard login --google + + # Email/password (non-interactive) + nylas dashboard login --email --user user@example.com --password secret + + # Login to a specific organization + nylas dashboard login --google --org org_123`, + RunE: func(cmd *cobra.Command, args []string) error { + method, err := resolveAuthMethod(google, microsoft, github, emailFlag, "log in") + if err != nil { + return wrapDashboardError(err) + } + + switch method { + case methodGoogle, methodMicrosoft, methodGitHub: + return runSSO(method, "login", false) + case methodEmailPassword: + return runEmailLogin(userFlag, passFlag, orgPublicID) + default: + return dashboardError("invalid selection", "Choose a valid option") + } + }, + } + + cmd.Flags().BoolVar(&google, "google", false, "Log in with Google SSO") + cmd.Flags().BoolVar(µsoft, "microsoft", false, "Log in with Microsoft SSO") + cmd.Flags().BoolVar(&github, "github", false, "Log in with GitHub SSO") + cmd.Flags().BoolVar(&emailFlag, "email", false, "Log in with email and password") + cmd.Flags().StringVar(&orgPublicID, "org", "", "Organization public ID") + cmd.Flags().StringVar(&userFlag, "user", "", "Email address (non-interactive)") + cmd.Flags().StringVar(&passFlag, "password", "", "Password (non-interactive, use with care)") + + return cmd +} + +func runEmailLogin(userFlag, passFlag, orgPublicID string) error { + authSvc, _, err := createAuthService() + if err != nil { + return wrapDashboardError(err) + } + + email := userFlag + if email == "" { + email, err = readLine("Email: ") + if err != nil { + return wrapDashboardError(err) + } + } + if email == "" { + return dashboardError("email is required", "Use --user or enter at prompt") + } + + password := passFlag + if password == "" { + password, err = readPassword("Password: ") + if err != nil { + return wrapDashboardError(err) + } + } + if password == "" { + return dashboardError("password is required", "Use --password or enter at prompt") + } + + ctx, cancel := common.CreateContext() + defer cancel() + + var auth *domain.DashboardAuthResponse + var mfa *domain.DashboardMFARequired + + err = common.RunWithSpinner("Authenticating...", func() error { + auth, mfa, err = authSvc.Login(ctx, email, password, orgPublicID) + return err + }) + if err != nil { + return wrapDashboardError(err) + } + + if mfa != nil { + code, readErr := readPassword("MFA code: ") + if readErr != nil { + return wrapDashboardError(readErr) + } + if code == "" { + return dashboardError("MFA code is required", "Enter the code from your authenticator app") + } + + mfaOrg := orgPublicID + if mfaOrg == "" && len(mfa.Organizations) > 0 { + if len(mfa.Organizations) > 1 { + mfaOrg = selectOrg(mfa.Organizations) + } else { + mfaOrg = mfa.Organizations[0].PublicID + } + } + + ctx2, cancel2 := common.CreateContext() + defer cancel2() + + err = common.RunWithSpinner("Verifying MFA...", func() error { + auth, err = authSvc.CompleteMFA(ctx2, mfa.User.PublicID, code, mfaOrg) + return err + }) + if err != nil { + return wrapDashboardError(err) + } + } + + if orgPublicID == "" && len(auth.Organizations) > 1 { + orgID := selectOrg(auth.Organizations) + _ = authSvc.SetActiveOrg(orgID) + } + + printAuthSuccess(auth) + return nil +} diff --git a/internal/cli/dashboard/logout.go b/internal/cli/dashboard/logout.go new file mode 100644 index 0000000..fe3f9e4 --- /dev/null +++ b/internal/cli/dashboard/logout.go @@ -0,0 +1,33 @@ +package dashboard + +import ( + "github.com/spf13/cobra" + + "github.com/nylas/cli/internal/cli/common" +) + +func newLogoutCmd() *cobra.Command { + return &cobra.Command{ + Use: "logout", + Short: "Log out of the Nylas Dashboard", + RunE: func(cmd *cobra.Command, args []string) error { + authSvc, _, err := createAuthService() + if err != nil { + return wrapDashboardError(err) + } + + ctx, cancel := common.CreateContext() + defer cancel() + + err = common.RunWithSpinner("Logging out...", func() error { + return authSvc.Logout(ctx) + }) + if err != nil { + return wrapDashboardError(err) + } + + _, _ = common.Green.Println("✓ Logged out") + return nil + }, + } +} diff --git a/internal/cli/dashboard/refresh.go b/internal/cli/dashboard/refresh.go new file mode 100644 index 0000000..d423060 --- /dev/null +++ b/internal/cli/dashboard/refresh.go @@ -0,0 +1,37 @@ +package dashboard + +import ( + "fmt" + + "github.com/spf13/cobra" + + "github.com/nylas/cli/internal/cli/common" +) + +func newRefreshCmd() *cobra.Command { + return &cobra.Command{ + Use: "refresh", + Short: "Refresh dashboard session tokens", + RunE: func(cmd *cobra.Command, args []string) error { + authSvc, _, err := createAuthService() + if err != nil { + return wrapDashboardError(err) + } + + ctx, cancel := common.CreateContext() + defer cancel() + + err = common.RunWithSpinner("Refreshing session...", func() error { + return authSvc.Refresh(ctx) + }) + if err != nil { + fmt.Println("Session expired. Please log in again:") + fmt.Println(" nylas dashboard login") + return wrapDashboardError(err) + } + + _, _ = common.Green.Println("✓ Session refreshed") + return nil + }, + } +} diff --git a/internal/cli/dashboard/register.go b/internal/cli/dashboard/register.go new file mode 100644 index 0000000..6e1ad5b --- /dev/null +++ b/internal/cli/dashboard/register.go @@ -0,0 +1,163 @@ +package dashboard + +import ( + "fmt" + + "github.com/spf13/cobra" + + "github.com/nylas/cli/internal/cli/common" + "github.com/nylas/cli/internal/domain" +) + +func newRegisterCmd() *cobra.Command { + var ( + region string + google bool + microsoft bool + github bool + emailFlag bool + userFlag string + passFlag string + codeFlag string + ) + + cmd := &cobra.Command{ + Use: "register", + Short: "Create a new Nylas Dashboard account", + Long: `Register a new Nylas Dashboard account. + +Choose SSO (recommended) or email/password. Pass a flag to skip the menu.`, + Example: ` # Interactive — choose method + nylas dashboard register + + # Google SSO (non-interactive) + nylas dashboard register --google + + # Email/password fully non-interactive + nylas dashboard register --email --user me@co.com --password s3cret --code AB12CD34 --region us`, + RunE: func(cmd *cobra.Command, args []string) error { + method, err := resolveAuthMethod(google, microsoft, github, emailFlag, "register") + if err != nil { + return wrapDashboardError(err) + } + + switch method { + case methodGoogle, methodMicrosoft, methodGitHub: + return runSSORegister(method) + case methodEmailPassword: + return runEmailRegister(userFlag, passFlag, codeFlag, region) + default: + return dashboardError("invalid selection", "Choose a valid option") + } + }, + } + + cmd.Flags().BoolVar(&google, "google", false, "Register with Google SSO") + cmd.Flags().BoolVar(µsoft, "microsoft", false, "Register with Microsoft SSO") + cmd.Flags().BoolVar(&github, "github", false, "Register with GitHub SSO") + cmd.Flags().BoolVar(&emailFlag, "email", false, "Register with email and password") + cmd.Flags().StringVarP(®ion, "region", "r", "us", "Account region (us or eu)") + cmd.Flags().StringVar(&userFlag, "user", "", "Email address (non-interactive)") + cmd.Flags().StringVar(&passFlag, "password", "", "Password (non-interactive, use with care)") + cmd.Flags().StringVar(&codeFlag, "code", "", "Verification code (non-interactive, skip prompt)") + + return cmd +} + +func runSSORegister(provider string) error { + if err := acceptPrivacyPolicy(); err != nil { + return err + } + return runSSO(provider, "register", true) +} + +func runEmailRegister(userFlag, passFlag, codeFlag, region string) error { + if err := acceptPrivacyPolicy(); err != nil { + return err + } + + authSvc, _, err := createAuthService() + if err != nil { + return wrapDashboardError(err) + } + + email := userFlag + if email == "" { + email, err = readLine("Email: ") + if err != nil { + return wrapDashboardError(err) + } + } + if email == "" { + return dashboardError("email is required", "Use --user or enter at prompt") + } + + password := passFlag + if password == "" { + password, err = readPassword("Password: ") + if err != nil { + return wrapDashboardError(err) + } + confirm, cErr := readPassword("Confirm password: ") + if cErr != nil { + return wrapDashboardError(cErr) + } + if password != confirm { + return dashboardError("passwords do not match", "Try again") + } + } + if password == "" { + return dashboardError("password is required", "Use --password or enter at prompt") + } + + ctx, cancel := common.CreateContext() + defer cancel() + + var resp *domain.DashboardRegisterResponse + err = common.RunWithSpinner("Creating account...", func() error { + resp, err = authSvc.Register(ctx, email, password, true) + return err + }) + if err != nil { + return wrapDashboardError(err) + } + + _, _ = common.Green.Println("✓ Verification code sent to your email") + _, _ = common.Dim.Printf(" Expires: %s\n", resp.ExpiresAt) + + code := codeFlag + if code == "" { + fmt.Println() + code, err = readLine("Enter verification code: ") + if err != nil { + return wrapDashboardError(err) + } + } + if code == "" { + return dashboardError("verification code is required", "Check your email, or use --code") + } + + ctx2, cancel2 := common.CreateContext() + defer cancel2() + + var authResp *domain.DashboardAuthResponse + err = common.RunWithSpinner("Verifying...", func() error { + authResp, err = authSvc.VerifyEmailCode(ctx2, email, code, region) + return err + }) + if err != nil { + return wrapDashboardError(err) + } + + if len(authResp.Organizations) > 1 { + orgID := selectOrg(authResp.Organizations) + _ = authSvc.SetActiveOrg(orgID) + } + + printAuthSuccess(authResp) + fmt.Println("\nNext steps:") + fmt.Println(" nylas dashboard apps list List your applications") + fmt.Println(" nylas dashboard apps create Create a new application") + + return nil +} diff --git a/internal/cli/dashboard/sso.go b/internal/cli/dashboard/sso.go new file mode 100644 index 0000000..c425b90 --- /dev/null +++ b/internal/cli/dashboard/sso.go @@ -0,0 +1,207 @@ +package dashboard + +import ( + "context" + "fmt" + "strings" + "time" + + "github.com/spf13/cobra" + + "github.com/nylas/cli/internal/adapters/browser" + dashboardapp "github.com/nylas/cli/internal/app/dashboard" + "github.com/nylas/cli/internal/cli/common" + "github.com/nylas/cli/internal/domain" +) + +func newSSOCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "sso", + Short: "Authenticate via SSO", + Long: `Authenticate with the Nylas Dashboard using SSO (Google, Microsoft, or GitHub).`, + } + + cmd.AddCommand(newSSOLoginCmd()) + cmd.AddCommand(newSSORegisterCmd()) + + return cmd +} + +func newSSOLoginCmd() *cobra.Command { + var provider string + + cmd := &cobra.Command{ + Use: "login", + Short: "Log in via SSO", + Example: ` nylas dashboard sso login --provider google + nylas dashboard sso login --provider microsoft + nylas dashboard sso login --provider github`, + RunE: func(cmd *cobra.Command, args []string) error { + return runSSO(provider, "login", false) + }, + } + + cmd.Flags().StringVarP(&provider, "provider", "p", "google", "SSO provider (google, microsoft, github)") + + return cmd +} + +func newSSORegisterCmd() *cobra.Command { + var provider string + + cmd := &cobra.Command{ + Use: "register", + Short: "Register via SSO", + Example: ` nylas dashboard sso register --provider google`, + RunE: func(cmd *cobra.Command, args []string) error { + if err := acceptPrivacyPolicy(); err != nil { + return err + } + return runSSO(provider, "register", true) + }, + } + + cmd.Flags().StringVarP(&provider, "provider", "p", "google", "SSO provider (google, microsoft, github)") + + return cmd +} + +func runSSO(provider, mode string, privacyPolicyAccepted bool) error { + loginType, err := mapProvider(provider) + if err != nil { + return err + } + + authSvc, _, err := createAuthService() + if err != nil { + return wrapDashboardError(err) + } + + ctx, cancel := common.CreateLongContext() + defer cancel() + + var resp *domain.DashboardSSOStartResponse + err = common.RunWithSpinner("Starting SSO...", func() error { + resp, err = authSvc.SSOStart(ctx, loginType, mode, privacyPolicyAccepted) + return err + }) + if err != nil { + return wrapDashboardError(err) + } + + // Show the URL and code + url := resp.VerificationURIComplete + if url == "" { + url = resp.VerificationURI + } + + fmt.Println() + _, _ = common.BoldCyan.Printf(" Open: %s\n", url) + if resp.UserCode != "" && resp.VerificationURIComplete == "" { + _, _ = common.Bold.Printf(" Code: %s\n", resp.UserCode) + } + fmt.Println() + + // Try to open browser + b := browser.NewDefaultBrowser() + if openErr := b.Open(url); openErr == nil { + _, _ = common.Dim.Println(" Browser opened. Complete sign-in there.") + fmt.Println() + } + + // Poll with spinner + interval := time.Duration(resp.Interval) * time.Second + if interval < time.Second { + interval = 5 * time.Second + } + + auth, err := pollSSO(ctx, authSvc, resp.FlowID, interval) + if err != nil { + return wrapDashboardError(err) + } + + printAuthSuccess(auth) + return nil +} + +func pollSSO(ctx context.Context, authSvc *dashboardapp.AuthService, flowID string, interval time.Duration) (*domain.DashboardAuthResponse, error) { + spinner := common.NewSpinner("Waiting for browser authentication...") + spinner.Start() + defer spinner.Stop() + + for { + select { + case <-ctx.Done(): + spinner.StopWithError("Timed out") + return nil, fmt.Errorf("authentication timed out") + case <-time.After(interval): + } + + resp, err := authSvc.SSOPoll(ctx, flowID, "") + if err != nil { + spinner.StopWithError("Failed") + return nil, err + } + + switch resp.Status { + case domain.SSOStatusComplete: + spinner.StopWithSuccess("Authenticated!") + if resp.Auth != nil { + return resp.Auth, nil + } + return nil, fmt.Errorf("unexpected empty auth response") + + case domain.SSOStatusMFARequired: + spinner.Stop() + if resp.MFA == nil { + return nil, fmt.Errorf("unexpected empty MFA response") + } + code, readErr := readPassword("MFA code: ") + if readErr != nil { + return nil, readErr + } + + ctx2, cancel := common.CreateContext() + var auth *domain.DashboardAuthResponse + mfaErr := common.RunWithSpinner("Verifying MFA...", func() error { + auth, err = authSvc.CompleteMFA(ctx2, resp.MFA.User.PublicID, code, "") + return err + }) + cancel() + if mfaErr != nil { + return nil, mfaErr + } + return auth, nil + + case domain.SSOStatusAccessDenied: + spinner.StopWithError("Access denied") + return nil, fmt.Errorf("%w: access denied by provider", domain.ErrDashboardSSOFailed) + + case domain.SSOStatusExpired: + spinner.StopWithError("Device code expired") + return nil, fmt.Errorf("%w: device code expired, please try again", domain.ErrDashboardSSOFailed) + + case domain.SSOStatusPending: + if resp.RetryAfter > 0 { + interval = time.Duration(resp.RetryAfter) * time.Second + } + } + } +} + +// mapProvider maps a user-friendly provider name to the server login type. +func mapProvider(provider string) (string, error) { + switch strings.ToLower(provider) { + case "google": + return domain.SSOLoginTypeGoogle, nil + case "microsoft": + return domain.SSOLoginTypeMicrosoft, nil + case "github": + return domain.SSOLoginTypeGitHub, nil + default: + return "", dashboardError( + fmt.Sprintf("unsupported SSO provider: %s", provider), + "Use one of: google, microsoft, github", + ) + } +} diff --git a/internal/cli/dashboard/status.go b/internal/cli/dashboard/status.go new file mode 100644 index 0000000..5bcdd24 --- /dev/null +++ b/internal/cli/dashboard/status.go @@ -0,0 +1,53 @@ +package dashboard + +import ( + "fmt" + + "github.com/spf13/cobra" + + "github.com/nylas/cli/internal/cli/common" +) + +func newStatusCmd() *cobra.Command { + return &cobra.Command{ + Use: "status", + Short: "Show current dashboard authentication status", + RunE: func(cmd *cobra.Command, args []string) error { + authSvc, _, err := createAuthService() + if err != nil { + return wrapDashboardError(err) + } + + status := authSvc.GetStatus() + + if !status.LoggedIn { + _, _ = common.Yellow.Println("Not logged in") + fmt.Println(" nylas dashboard login") + return nil + } + + _, _ = common.Green.Println("✓ Logged in") + if status.UserID != "" { + fmt.Printf(" User: %s\n", status.UserID) + } + if status.OrgID != "" { + fmt.Printf(" Organization: %s\n", status.OrgID) + } + fmt.Printf(" Org token: %s\n", presentAbsent(status.HasOrgToken)) + + dpopSvc, _, dpopErr := createDPoPService() + if dpopErr == nil { + fmt.Printf(" DPoP key: %s\n", dpopSvc.Thumbprint()) + } + + return nil + }, + } +} + +func presentAbsent(present bool) string { + if present { + return "present" + } + return "absent" +} diff --git a/internal/domain/config.go b/internal/domain/config.go index ad44de1..ddf3d32 100644 --- a/internal/domain/config.go +++ b/internal/domain/config.go @@ -62,6 +62,9 @@ type Config struct { // GPG settings GPG *GPGConfig `yaml:"gpg,omitempty"` + + // Dashboard authentication settings + Dashboard *DashboardConfig `yaml:"dashboard,omitempty"` } // APIConfig represents API-specific configuration. diff --git a/internal/domain/dashboard.go b/internal/domain/dashboard.go new file mode 100644 index 0000000..838b204 --- /dev/null +++ b/internal/domain/dashboard.go @@ -0,0 +1,127 @@ +package domain + +// DashboardUser represents an authenticated dashboard user. +type DashboardUser struct { + PublicID string `json:"publicId"` + EmailAddress string `json:"emailAddress,omitempty"` + FirstName string `json:"firstName,omitempty"` + LastName string `json:"lastName,omitempty"` +} + +// DashboardOrganization represents a Nylas organization. +type DashboardOrganization struct { + PublicID string `json:"publicId"` + Name string `json:"name,omitempty"` + Region string `json:"region,omitempty"` + Role string `json:"role,omitempty"` +} + +// DashboardRegisterResponse is the response from a successful registration. +type DashboardRegisterResponse struct { + VerificationChannel string `json:"verificationChannel"` + ExpiresAt string `json:"expiresAt"` +} + +// DashboardAuthResponse is the response from a successful login or verification. +type DashboardAuthResponse struct { + UserToken string `json:"userToken"` + OrgToken string `json:"orgToken"` + User DashboardUser `json:"user"` + Organizations []DashboardOrganization `json:"organizations"` +} + +// DashboardMFARequired is returned when MFA is needed after login. +type DashboardMFARequired struct { + User DashboardUser `json:"user"` + Organizations []DashboardOrganization `json:"organizations"` + TOTPFactor *DashboardTOTPFactor `json:"totpFactor"` +} + +// DashboardTOTPFactor contains the TOTP factor details for MFA. +type DashboardTOTPFactor struct { + FactorSID string `json:"factorSid"` + Binding string `json:"binding,omitempty"` +} + +// DashboardRefreshResponse is the response from a session refresh. +type DashboardRefreshResponse struct { + UserToken string `json:"userToken"` + OrgToken string `json:"orgToken,omitempty"` +} + +// DashboardSSOStartResponse is the response from starting an SSO device flow. +type DashboardSSOStartResponse struct { + FlowID string `json:"flowId"` + VerificationURI string `json:"verificationUri"` + VerificationURIComplete string `json:"verificationUriComplete,omitempty"` + UserCode string `json:"userCode"` + ExpiresIn int `json:"expiresIn"` + Interval int `json:"interval"` +} + +// DashboardSSOPollResponse represents the poll result for an SSO device flow. +type DashboardSSOPollResponse struct { + Status string `json:"status"` + RetryAfter int `json:"retryAfter,omitempty"` + + // Populated when Status == "complete" + Auth *DashboardAuthResponse `json:"-"` + + // Populated when Status == "mfa_required" + MFA *DashboardMFARequired `json:"-"` +} + +// SSO poll status constants. +const ( + SSOStatusPending = "authorization_pending" + SSOStatusAccessDenied = "access_denied" + SSOStatusExpired = "expired_token" + SSOStatusComplete = "complete" + SSOStatusMFARequired = "mfa_required" +) + +// SSO login type constants matching the server schema. +const ( + SSOLoginTypeGoogle = "google_SSO" + SSOLoginTypeMicrosoft = "microsoft_SSO" + SSOLoginTypeGitHub = "github_SSO" +) + +// GatewayApplication is an application as returned by the dashboard API gateway. +type GatewayApplication struct { + ApplicationID string `json:"applicationId"` + OrganizationID string `json:"organizationId"` + Region string `json:"region"` + Environment string `json:"environment"` + Branding *GatewayApplicationBrand `json:"branding,omitempty"` +} + +// GatewayCreatedApplication includes the client secret shown once on creation. +type GatewayCreatedApplication struct { + ApplicationID string `json:"applicationId"` + ClientSecret string `json:"clientSecret"` + OrganizationID string `json:"organizationId"` + Region string `json:"region"` + Environment string `json:"environment"` + Branding *GatewayApplicationBrand `json:"branding,omitempty"` +} + +// GatewayApplicationBrand holds application branding info. +type GatewayApplicationBrand struct { + Name string `json:"name,omitempty"` + Description string `json:"description,omitempty"` +} + +// DashboardConfig holds dashboard authentication settings. +type DashboardConfig struct { + AccountBaseURL string `yaml:"account_base_url,omitempty"` +} + +// DefaultDashboardAccountBaseURL is the global dashboard-account endpoint. +const DefaultDashboardAccountBaseURL = "https://dashboard-account.eu.nylas.com" + +// Dashboard API gateway URLs by region. +const ( + GatewayBaseURLUS = "https://dashboard-api-gateway.us.nylas.com/graphql" + GatewayBaseURLEU = "https://dashboard-api-gateway.eu.nylas.com/graphql" +) diff --git a/internal/domain/errors.go b/internal/domain/errors.go index 2f1060a..3c14ec6 100644 --- a/internal/domain/errors.go +++ b/internal/domain/errors.go @@ -56,6 +56,14 @@ var ( ErrConnectorNotFound = errors.New("connector not found") ErrCredentialNotFound = errors.New("credential not found") + // Dashboard auth errors + ErrDashboardNotLoggedIn = errors.New("not logged in to Nylas Dashboard") + ErrDashboardSessionExpired = errors.New("dashboard session expired") + ErrDashboardLoginFailed = errors.New("dashboard login failed") + ErrDashboardMFARequired = errors.New("MFA required") + ErrDashboardSSOFailed = errors.New("SSO authentication failed") + ErrDashboardDPoP = errors.New("DPoP proof generation failed") + // Scheduler errors ErrBookingNotFound = errors.New("booking not found") ErrSessionNotFound = errors.New("session not found") diff --git a/internal/ports/dashboard.go b/internal/ports/dashboard.go new file mode 100644 index 0000000..515ee58 --- /dev/null +++ b/internal/ports/dashboard.go @@ -0,0 +1,57 @@ +package ports + +import ( + "context" + + "github.com/nylas/cli/internal/domain" +) + +// DashboardAccountClient defines the interface for dashboard-account CLI auth endpoints. +type DashboardAccountClient interface { + // Register creates a new dashboard account and triggers email verification. + Register(ctx context.Context, email, password string, privacyPolicyAccepted bool) (*domain.DashboardRegisterResponse, error) + + // VerifyEmailCode verifies the email verification code after registration. + VerifyEmailCode(ctx context.Context, email, code, region string) (*domain.DashboardAuthResponse, error) + + // ResendVerificationCode resends the email verification code. + ResendVerificationCode(ctx context.Context, email string) error + + // Login authenticates with email and password. + // Returns auth response on success, or MFA info if MFA is required. + Login(ctx context.Context, email, password, orgPublicID string) (*domain.DashboardAuthResponse, *domain.DashboardMFARequired, error) + + // LoginMFA completes MFA authentication with a TOTP code. + LoginMFA(ctx context.Context, userPublicID, code, orgPublicID string) (*domain.DashboardAuthResponse, error) + + // Refresh refreshes the session tokens. + Refresh(ctx context.Context, userToken, orgToken string) (*domain.DashboardRefreshResponse, error) + + // Logout invalidates the session tokens. + Logout(ctx context.Context, userToken, orgToken string) error + + // SSOStart initiates an SSO device authorization flow. + SSOStart(ctx context.Context, loginType, mode string, privacyPolicyAccepted bool) (*domain.DashboardSSOStartResponse, error) + + // SSOPoll polls the SSO device flow for completion. + SSOPoll(ctx context.Context, flowID, orgPublicID string) (*domain.DashboardSSOPollResponse, error) +} + +// DashboardGatewayClient defines the interface for dashboard API gateway GraphQL operations. +type DashboardGatewayClient interface { + // ListApplications retrieves applications from the dashboard API gateway. + ListApplications(ctx context.Context, orgPublicID, region, userToken, orgToken string) ([]domain.GatewayApplication, error) + + // CreateApplication creates a new application via the dashboard API gateway. + CreateApplication(ctx context.Context, orgPublicID, region, name, userToken, orgToken string) (*domain.GatewayCreatedApplication, error) +} + +// DPoP defines the interface for DPoP proof generation. +type DPoP interface { + // GenerateProof creates a DPoP proof JWT for the given HTTP method and URL. + // If accessToken is non-empty, the proof includes an ath (access token hash) claim. + GenerateProof(method, url string, accessToken string) (string, error) + + // Thumbprint returns the JWK thumbprint (RFC 7638) of the DPoP public key. + Thumbprint() string +} diff --git a/internal/ports/secrets.go b/internal/ports/secrets.go index f473a8d..c3af74e 100644 --- a/internal/ports/secrets.go +++ b/internal/ports/secrets.go @@ -25,6 +25,13 @@ const ( KeyClientSecret = "client_secret" KeyAPIKey = "api_key" KeyOrgID = "org_id" + + // Dashboard auth keys + KeyDashboardUserToken = "dashboard_user_token" + KeyDashboardOrgToken = "dashboard_org_token" + KeyDashboardUserPublicID = "dashboard_user_public_id" + KeyDashboardOrgPublicID = "dashboard_org_public_id" + KeyDashboardDPoPKey = "dashboard_dpop_key" ) // GrantTokenKey returns the keystore key for a grant's access token. From b93b34cc86625efd581f49a9ad430f6eadbe96a8 Mon Sep 17 00:00:00 2001 From: Aaron de Mello Date: Mon, 23 Mar 2026 11:33:17 -0400 Subject: [PATCH 02/20] feat(dashboard): add API key management and active app selection MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - `nylas dashboard apps apikeys create` — creates API keys with interactive delivery options (activate in CLI keyring, copy to clipboard, or print) - `nylas dashboard apps apikeys list` — lists API keys for an application - `nylas dashboard apps use --region ` — sets active app so --app/--region flags aren't needed on every command - `nylas dashboard status` now shows the active app - Improved GraphQL error messages to surface UAS error details from extensions - Auto-generated unique key names (CLI-) to avoid name conflicts Co-Authored-By: Claude Opus 4.6 (1M context) --- internal/adapters/dashboard/gateway_client.go | 112 +++++++- internal/adapters/dashboard/mock.go | 8 + internal/app/dashboard/app_service.go | 20 ++ internal/cli/dashboard/apps.go | 83 ++++++ internal/cli/dashboard/keys.go | 257 ++++++++++++++++++ internal/cli/dashboard/status.go | 10 +- internal/domain/dashboard.go | 21 ++ internal/ports/dashboard.go | 6 + internal/ports/secrets.go | 2 + 9 files changed, 515 insertions(+), 4 deletions(-) create mode 100644 internal/cli/dashboard/keys.go diff --git a/internal/adapters/dashboard/gateway_client.go b/internal/adapters/dashboard/gateway_client.go index b3d8694..236ec0b 100644 --- a/internal/adapters/dashboard/gateway_client.go +++ b/internal/adapters/dashboard/gateway_client.go @@ -68,7 +68,7 @@ func (c *GatewayClient) ListApplications(ctx context.Context, orgPublicID, regio } if len(resp.Errors) > 0 { - return nil, fmt.Errorf("GraphQL error: %s", resp.Errors[0].Message) + return nil, fmt.Errorf("GraphQL error: %s", formatGraphQLError(resp.Errors[0])) } return resp.Data.Applications.Applications, nil @@ -115,12 +115,103 @@ func (c *GatewayClient) CreateApplication(ctx context.Context, orgPublicID, regi } if len(resp.Errors) > 0 { - return nil, fmt.Errorf("GraphQL error: %s", resp.Errors[0].Message) + return nil, fmt.Errorf("GraphQL error: %s", formatGraphQLError(resp.Errors[0])) } return &resp.Data.CreateApplication, nil } +// ListAPIKeys retrieves API keys for an application. +func (c *GatewayClient) ListAPIKeys(ctx context.Context, appID, region, userToken, orgToken string) ([]domain.GatewayAPIKey, error) { + query := `query V3_ApiKeys($appId: String!) { + apiKeys(appId: $appId) { + id + name + status + permissions + expiresAt + createdAt + } +}` + + variables := map[string]any{ + "appId": appID, + } + + url := gatewayURL(region) + raw, err := c.doGraphQL(ctx, url, query, variables, userToken, orgToken) + if err != nil { + return nil, fmt.Errorf("failed to list API keys: %w", err) + } + + var resp struct { + Data struct { + APIKeys []domain.GatewayAPIKey `json:"apiKeys"` + } `json:"data"` + Errors []graphQLError `json:"errors"` + } + + if err := json.Unmarshal(raw, &resp); err != nil { + return nil, fmt.Errorf("failed to decode API keys response: %w", err) + } + + if len(resp.Errors) > 0 { + return nil, fmt.Errorf("GraphQL error: %s", formatGraphQLError(resp.Errors[0])) + } + + return resp.Data.APIKeys, nil +} + +// CreateAPIKey creates a new API key for an application. +func (c *GatewayClient) CreateAPIKey(ctx context.Context, appID, region, name string, expiresInDays int, userToken, orgToken string) (*domain.GatewayCreatedAPIKey, error) { + query := `mutation V3_CreateApiKey($appId: String!, $options: ApiKeyOptions) { + createApiKey(appId: $appId, options: $options) { + id + name + apiKey + status + permissions + expiresAt + createdAt + } +}` + + options := map[string]any{ + "name": name, + } + if expiresInDays > 0 { + options["expiresIn"] = expiresInDays + } + + variables := map[string]any{ + "appId": appID, + "options": options, + } + + url := gatewayURL(region) + raw, err := c.doGraphQL(ctx, url, query, variables, userToken, orgToken) + if err != nil { + return nil, fmt.Errorf("failed to create API key: %w", err) + } + + var resp struct { + Data struct { + CreateAPIKey domain.GatewayCreatedAPIKey `json:"createApiKey"` + } `json:"data"` + Errors []graphQLError `json:"errors"` + } + + if err := json.Unmarshal(raw, &resp); err != nil { + return nil, fmt.Errorf("failed to decode create API key response: %w", err) + } + + if len(resp.Errors) > 0 { + return nil, fmt.Errorf("GraphQL error: %s", formatGraphQLError(resp.Errors[0])) + } + + return &resp.Data.CreateAPIKey, nil +} + // doGraphQL sends a GraphQL request with auth headers and DPoP proof. func (c *GatewayClient) doGraphQL(ctx context.Context, url, query string, variables map[string]any, userToken, orgToken string) ([]byte, error) { reqBody := map[string]any{ @@ -196,5 +287,20 @@ func gatewayURL(region string) string { // graphQLError represents a GraphQL error from the gateway. type graphQLError struct { - Message string `json:"message"` + Message string `json:"message"` + Extensions *graphQLExtensions `json:"extensions,omitempty"` +} + +type graphQLExtensions struct { + Code string `json:"code,omitempty"` + Message string `json:"message,omitempty"` +} + +// formatGraphQLError returns a human-readable error from a GraphQL error. +func formatGraphQLError(e graphQLError) string { + // Prefer extensions.message (more specific), fall back to top-level message + if e.Extensions != nil && e.Extensions.Message != "" && e.Extensions.Message != e.Message { + return e.Extensions.Message + } + return e.Message } diff --git a/internal/adapters/dashboard/mock.go b/internal/adapters/dashboard/mock.go index 396a927..f3721ef 100644 --- a/internal/adapters/dashboard/mock.go +++ b/internal/adapters/dashboard/mock.go @@ -51,6 +51,8 @@ func (m *MockAccountClient) SSOPoll(ctx context.Context, flowID, orgPublicID str type MockGatewayClient struct { ListApplicationsFn func(ctx context.Context, orgPublicID, region, userToken, orgToken string) ([]domain.GatewayApplication, error) CreateApplicationFn func(ctx context.Context, orgPublicID, region, name, userToken, orgToken string) (*domain.GatewayCreatedApplication, error) + ListAPIKeysFn func(ctx context.Context, appID, region, userToken, orgToken string) ([]domain.GatewayAPIKey, error) + CreateAPIKeyFn func(ctx context.Context, appID, region, name string, expiresInDays int, userToken, orgToken string) (*domain.GatewayCreatedAPIKey, error) } func (m *MockGatewayClient) ListApplications(ctx context.Context, orgPublicID, region, userToken, orgToken string) ([]domain.GatewayApplication, error) { @@ -59,3 +61,9 @@ func (m *MockGatewayClient) ListApplications(ctx context.Context, orgPublicID, r func (m *MockGatewayClient) CreateApplication(ctx context.Context, orgPublicID, region, name, userToken, orgToken string) (*domain.GatewayCreatedApplication, error) { return m.CreateApplicationFn(ctx, orgPublicID, region, name, userToken, orgToken) } +func (m *MockGatewayClient) ListAPIKeys(ctx context.Context, appID, region, userToken, orgToken string) ([]domain.GatewayAPIKey, error) { + return m.ListAPIKeysFn(ctx, appID, region, userToken, orgToken) +} +func (m *MockGatewayClient) CreateAPIKey(ctx context.Context, appID, region, name string, expiresInDays int, userToken, orgToken string) (*domain.GatewayCreatedAPIKey, error) { + return m.CreateAPIKeyFn(ctx, appID, region, name, expiresInDays, userToken, orgToken) +} diff --git a/internal/app/dashboard/app_service.go b/internal/app/dashboard/app_service.go index ad2b15a..37e4ff5 100644 --- a/internal/app/dashboard/app_service.go +++ b/internal/app/dashboard/app_service.go @@ -85,6 +85,26 @@ func (s *AppService) CreateApplication(ctx context.Context, orgPublicID, region, return s.gateway.CreateApplication(ctx, orgPublicID, region, name, userToken, orgToken) } +// ListAPIKeys retrieves API keys for an application. +func (s *AppService) ListAPIKeys(ctx context.Context, appID, region string) ([]domain.GatewayAPIKey, error) { + userToken, orgToken, err := s.loadTokens() + if err != nil { + return nil, err + } + + return s.gateway.ListAPIKeys(ctx, appID, region, userToken, orgToken) +} + +// CreateAPIKey creates a new API key for an application. +func (s *AppService) CreateAPIKey(ctx context.Context, appID, region, name string, expiresInDays int) (*domain.GatewayCreatedAPIKey, error) { + userToken, orgToken, err := s.loadTokens() + if err != nil { + return nil, err + } + + return s.gateway.CreateAPIKey(ctx, appID, region, name, expiresInDays, userToken, orgToken) +} + // deduplicateApps removes duplicate applications (same applicationId). func deduplicateApps(apps []domain.GatewayApplication) []domain.GatewayApplication { seen := make(map[string]bool, len(apps)) diff --git a/internal/cli/dashboard/apps.go b/internal/cli/dashboard/apps.go index 5dd97ac..1e4d168 100644 --- a/internal/cli/dashboard/apps.go +++ b/internal/cli/dashboard/apps.go @@ -19,6 +19,8 @@ func newAppsCmd() *cobra.Command { cmd.AddCommand(newAppsListCmd()) cmd.AddCommand(newAppsCreateCmd()) + cmd.AddCommand(newAppsUseCmd()) + cmd.AddCommand(newAPIKeysCmd()) return cmd } @@ -155,6 +157,87 @@ func newAppsCreateCmd() *cobra.Command { return cmd } +func newAppsUseCmd() *cobra.Command { + var region string + + cmd := &cobra.Command{ + Use: "use ", + Short: "Set the active application for subsequent commands", + Long: `Set an application as active so you don't need to pass --app and --region +to every apikeys command.`, + Example: ` # Set active app + nylas dashboard apps use b09141da-ead2-46bd-8f4c-c9ec5af4c6cc --region us + + # Now apikeys commands use the active app automatically + nylas dashboard apps apikeys list + nylas dashboard apps apikeys create --name "My key"`, + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + appID := args[0] + if region == "" { + return dashboardError("region is required", "Use --region us or --region eu") + } + + _, secrets, err := createDPoPService() + if err != nil { + return wrapDashboardError(err) + } + + if err := secrets.Set(ports.KeyDashboardAppID, appID); err != nil { + return wrapDashboardError(err) + } + if err := secrets.Set(ports.KeyDashboardAppRegion, region); err != nil { + return wrapDashboardError(err) + } + + _, _ = common.Green.Printf("✓ Active app: %s (%s)\n", appID, region) + return nil + }, + } + + cmd.Flags().StringVarP(®ion, "region", "r", "", "Region of the application (required: us or eu)") + + return cmd +} + +// getActiveApp returns the active app ID and region from the keyring. +// Flags take priority over the stored active app. +func getActiveApp(appFlag, regionFlag string) (appID, region string, err error) { + if appFlag != "" && regionFlag != "" { + return appFlag, regionFlag, nil + } + + _, secrets, sErr := createDPoPService() + if sErr != nil { + return appFlag, regionFlag, sErr + } + + if appFlag == "" { + appID, _ = secrets.Get(ports.KeyDashboardAppID) + } else { + appID = appFlag + } + if regionFlag == "" { + region, _ = secrets.Get(ports.KeyDashboardAppRegion) + } else { + region = regionFlag + } + + if appID == "" { + return "", "", dashboardError( + "no active application", + "Run 'nylas dashboard apps use --region ' or pass --app and --region", + ) + } + if region == "" { + return "", "", dashboardError( + "no region set for active application", + "Run 'nylas dashboard apps use --region ' or pass --region", + ) + } + return appID, region, nil +} + // getActiveOrgID retrieves the active organization ID from the keyring. func getActiveOrgID() (string, error) { _, secrets, err := createDPoPService() diff --git a/internal/cli/dashboard/keys.go b/internal/cli/dashboard/keys.go new file mode 100644 index 0000000..7a98407 --- /dev/null +++ b/internal/cli/dashboard/keys.go @@ -0,0 +1,257 @@ +package dashboard + +import ( + "fmt" + "time" + + "github.com/spf13/cobra" + + "github.com/nylas/cli/internal/adapters/config" + "github.com/nylas/cli/internal/adapters/keyring" + 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" +) + +func newAPIKeysCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "apikeys", + Aliases: []string{"keys"}, + Short: "Manage API keys for an application", + } + + cmd.AddCommand(newAPIKeysListCmd()) + cmd.AddCommand(newAPIKeysCreateCmd()) + + return cmd +} + +// apiKeyRow is a flat struct for table output. +type apiKeyRow struct { + ID string `json:"id"` + Name string `json:"name"` + Status string `json:"status"` + ExpiresAt string `json:"expires_at"` + CreatedAt string `json:"created_at"` +} + +var apiKeyColumns = []ports.Column{ + {Header: "ID", Field: "ID"}, + {Header: "NAME", Field: "Name"}, + {Header: "STATUS", Field: "Status"}, + {Header: "EXPIRES", Field: "ExpiresAt"}, + {Header: "CREATED", Field: "CreatedAt"}, +} + +func newAPIKeysListCmd() *cobra.Command { + var ( + appID string + region string + ) + + cmd := &cobra.Command{ + Use: "list", + Short: "List API keys for an application", + Long: `List API keys for an application. Uses the active app if --app is not specified. +Set an active app with: nylas dashboard apps use --region `, + Example: ` # Using active app + nylas dashboard apps apikeys list + + # Explicit app + nylas dashboard apps apikeys list --app --region us`, + RunE: func(cmd *cobra.Command, args []string) error { + resolvedApp, resolvedRegion, err := getActiveApp(appID, region) + if err != nil { + return err + } + appID = resolvedApp + region = resolvedRegion + + appSvc, err := createAppService() + if err != nil { + return wrapDashboardError(err) + } + + ctx, cancel := common.CreateContext() + defer cancel() + + var keys []domain.GatewayAPIKey + err = common.RunWithSpinner("Fetching API keys...", func() error { + keys, err = appSvc.ListAPIKeys(ctx, appID, region) + return err + }) + if err != nil { + return wrapDashboardError(err) + } + + if len(keys) == 0 { + fmt.Println("No API keys found.") + fmt.Printf("\nCreate one with: nylas dashboard apps apikeys create --app %s --region %s\n", appID, region) + return nil + } + + rows := toAPIKeyRows(keys) + return common.WriteListWithColumns(cmd, rows, apiKeyColumns) + }, + } + + cmd.Flags().StringVar(&appID, "app", "", "Application ID (overrides active app)") + cmd.Flags().StringVarP(®ion, "region", "r", "", "Region (overrides active app)") + + return cmd +} + +func newAPIKeysCreateCmd() *cobra.Command { + var ( + appID string + region string + name string + expiresIn int + ) + + cmd := &cobra.Command{ + Use: "create", + Short: "Create an API key for an application", + Long: `Create a new API key for an application. Uses the active app if --app is not specified. + +After creation, you choose what to do with the key: + 1. Activate it — store in CLI keyring as the active API key (recommended) + 2. Copy to clipboard — for use in other tools + 3. Print to terminal — for piping or scripts + +Set an active app with: nylas dashboard apps use --region `, + Example: ` # Using active app (simplest) + nylas dashboard apps apikeys create + + # With a custom name + nylas dashboard apps apikeys create --name "My key" + + # Explicit app + nylas dashboard apps apikeys create --app --region us + + # Create with custom expiration (days) + nylas dashboard apps apikeys create --expires 30`, + RunE: func(cmd *cobra.Command, args []string) error { + resolvedApp, resolvedRegion, err := getActiveApp(appID, region) + if err != nil { + return err + } + appID = resolvedApp + region = resolvedRegion + + if name == "" { + name = "CLI-" + time.Now().Format("20060102-150405") + } + + appSvc, err := createAppService() + if err != nil { + return wrapDashboardError(err) + } + + ctx, cancel := common.CreateContext() + defer cancel() + + var key *domain.GatewayCreatedAPIKey + err = common.RunWithSpinner("Creating API key...", func() error { + key, err = appSvc.CreateAPIKey(ctx, appID, region, name, expiresIn) + return err + }) + if err != nil { + return wrapDashboardError(err) + } + + _, _ = common.Green.Println("✓ API key created") + fmt.Printf(" ID: %s\n", key.ID) + fmt.Printf(" Name: %s\n", key.Name) + + return handleAPIKeyDelivery(key.APIKey, appID, region) + }, + } + + cmd.Flags().StringVar(&appID, "app", "", "Application ID (overrides active app)") + cmd.Flags().StringVarP(®ion, "region", "r", "", "Region (overrides active app)") + cmd.Flags().StringVarP(&name, "name", "n", "", "API key name (default: CLI-)") + cmd.Flags().IntVar(&expiresIn, "expires", 0, "Expiration in days (default: no expiration)") + + return cmd +} + +// handleAPIKeyDelivery prompts the user to choose how to handle the newly created key. +func handleAPIKeyDelivery(apiKey, appID, region string) error { + fmt.Println("\nWhat would you like to do with this API key?") + fmt.Println() + _, _ = common.Cyan.Println(" [1] Activate for this CLI (recommended)") + fmt.Println(" [2] Copy to clipboard") + fmt.Println(" [3] Print to terminal") + fmt.Println() + + choice, err := readLine("Choose [1-3]: ") + if err != nil { + return wrapDashboardError(err) + } + + switch choice { + case "1", "": + if err := activateAPIKey(apiKey, appID, region); err != nil { + _, _ = common.Yellow.Printf(" Could not activate: %v\n", err) + return nil + } + _, _ = common.Green.Println("✓ API key activated — CLI is ready to use") + _, _ = common.Dim.Println(" Try: nylas auth status") + + case "2": + if err := common.CopyToClipboard(apiKey); err != nil { + _, _ = common.Yellow.Printf(" Clipboard unavailable: %v\n", err) + fmt.Println(" Falling back to print:") + fmt.Printf(" %s\n", apiKey) + return nil + } + _, _ = common.Green.Println("✓ API key copied to clipboard") + + case "3": + fmt.Println() + fmt.Println(apiKey) + + default: + return dashboardError("invalid selection", "Choose 1-3") + } + + return nil +} + +// activateAPIKey stores the API key and configures the CLI to use it. +func activateAPIKey(apiKey, clientID, region string) error { + configStore := config.NewDefaultFileStore() + secretStore, err := keyring.NewSecretStore(config.DefaultConfigDir()) + if err != nil { + return err + } + + configSvc := authapp.NewConfigService(configStore, secretStore) + return configSvc.SetupConfig(region, clientID, "", apiKey, "") +} + +// toAPIKeyRows converts API keys to flat display rows. +func toAPIKeyRows(keys []domain.GatewayAPIKey) []apiKeyRow { + rows := make([]apiKeyRow, len(keys)) + for i, k := range keys { + rows[i] = apiKeyRow{ + ID: k.ID, + Name: k.Name, + Status: k.Status, + ExpiresAt: formatEpoch(k.ExpiresAt), + CreatedAt: formatEpoch(k.CreatedAt), + } + } + return rows +} + +// formatEpoch formats a Unix epoch (seconds) as a human-readable date. +func formatEpoch(epoch float64) string { + if epoch == 0 { + return "-" + } + t := time.Unix(int64(epoch), 0) + return t.Format("2006-01-02") +} diff --git a/internal/cli/dashboard/status.go b/internal/cli/dashboard/status.go index 5bcdd24..3f3449f 100644 --- a/internal/cli/dashboard/status.go +++ b/internal/cli/dashboard/status.go @@ -6,6 +6,7 @@ import ( "github.com/spf13/cobra" "github.com/nylas/cli/internal/cli/common" + "github.com/nylas/cli/internal/ports" ) func newStatusCmd() *cobra.Command { @@ -13,7 +14,7 @@ func newStatusCmd() *cobra.Command { Use: "status", Short: "Show current dashboard authentication status", RunE: func(cmd *cobra.Command, args []string) error { - authSvc, _, err := createAuthService() + authSvc, secrets, err := createAuthService() if err != nil { return wrapDashboardError(err) } @@ -35,6 +36,13 @@ func newStatusCmd() *cobra.Command { } fmt.Printf(" Org token: %s\n", presentAbsent(status.HasOrgToken)) + // Active app + appID, _ := secrets.Get(ports.KeyDashboardAppID) + appRegion, _ := secrets.Get(ports.KeyDashboardAppRegion) + if appID != "" { + fmt.Printf(" Active app: %s (%s)\n", appID, appRegion) + } + dpopSvc, _, dpopErr := createDPoPService() if dpopErr == nil { fmt.Printf(" DPoP key: %s\n", dpopSvc.Thumbprint()) diff --git a/internal/domain/dashboard.go b/internal/domain/dashboard.go index 838b204..04dc0e8 100644 --- a/internal/domain/dashboard.go +++ b/internal/domain/dashboard.go @@ -112,6 +112,27 @@ type GatewayApplicationBrand struct { Description string `json:"description,omitempty"` } +// GatewayAPIKey represents an API key as returned by the dashboard API gateway. +type GatewayAPIKey struct { + ID string `json:"id"` + Name string `json:"name"` + Status string `json:"status"` + Permissions []string `json:"permissions"` + ExpiresAt float64 `json:"expiresAt"` + CreatedAt float64 `json:"createdAt"` +} + +// GatewayCreatedAPIKey includes the actual key value (shown once on creation). +type GatewayCreatedAPIKey struct { + ID string `json:"id"` + Name string `json:"name"` + APIKey string `json:"apiKey"` + Status string `json:"status"` + Permissions []string `json:"permissions"` + ExpiresAt float64 `json:"expiresAt"` + CreatedAt float64 `json:"createdAt"` +} + // DashboardConfig holds dashboard authentication settings. type DashboardConfig struct { AccountBaseURL string `yaml:"account_base_url,omitempty"` diff --git a/internal/ports/dashboard.go b/internal/ports/dashboard.go index 515ee58..c126e0f 100644 --- a/internal/ports/dashboard.go +++ b/internal/ports/dashboard.go @@ -44,6 +44,12 @@ type DashboardGatewayClient interface { // CreateApplication creates a new application via the dashboard API gateway. CreateApplication(ctx context.Context, orgPublicID, region, name, userToken, orgToken string) (*domain.GatewayCreatedApplication, error) + + // ListAPIKeys retrieves API keys for an application. + ListAPIKeys(ctx context.Context, appID, region, userToken, orgToken string) ([]domain.GatewayAPIKey, error) + + // CreateAPIKey creates a new API key for an application. + CreateAPIKey(ctx context.Context, appID, region, name string, expiresInDays int, userToken, orgToken string) (*domain.GatewayCreatedAPIKey, error) } // DPoP defines the interface for DPoP proof generation. diff --git a/internal/ports/secrets.go b/internal/ports/secrets.go index c3af74e..dee81ff 100644 --- a/internal/ports/secrets.go +++ b/internal/ports/secrets.go @@ -32,6 +32,8 @@ const ( KeyDashboardUserPublicID = "dashboard_user_public_id" KeyDashboardOrgPublicID = "dashboard_org_public_id" KeyDashboardDPoPKey = "dashboard_dpop_key" + KeyDashboardAppID = "dashboard_app_id" + KeyDashboardAppRegion = "dashboard_app_region" ) // GrantTokenKey returns the keystore key for a grant's access token. From 7bf588b702cffe0221d431d987a2b219ef0a8f8c Mon Sep 17 00:00:00 2001 From: Aaron de Mello Date: Mon, 23 Mar 2026 11:38:56 -0400 Subject: [PATCH 03/20] fix: gofmt formatting in gateway_client.go and sso.go Co-Authored-By: Claude Opus 4.6 (1M context) --- internal/adapters/dashboard/gateway_client.go | 4 ++-- internal/cli/dashboard/sso.go | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/internal/adapters/dashboard/gateway_client.go b/internal/adapters/dashboard/gateway_client.go index 236ec0b..7c7cc9c 100644 --- a/internal/adapters/dashboard/gateway_client.go +++ b/internal/adapters/dashboard/gateway_client.go @@ -287,8 +287,8 @@ func gatewayURL(region string) string { // graphQLError represents a GraphQL error from the gateway. type graphQLError struct { - Message string `json:"message"` - Extensions *graphQLExtensions `json:"extensions,omitempty"` + Message string `json:"message"` + Extensions *graphQLExtensions `json:"extensions,omitempty"` } type graphQLExtensions struct { diff --git a/internal/cli/dashboard/sso.go b/internal/cli/dashboard/sso.go index c425b90..b10400a 100644 --- a/internal/cli/dashboard/sso.go +++ b/internal/cli/dashboard/sso.go @@ -50,8 +50,8 @@ func newSSORegisterCmd() *cobra.Command { var provider string cmd := &cobra.Command{ - Use: "register", - Short: "Register via SSO", + Use: "register", + Short: "Register via SSO", Example: ` nylas dashboard sso register --provider google`, RunE: func(cmd *cobra.Command, args []string) error { if err := acceptPrivacyPolicy(); err != nil { From 382921d8d3c2e975ea0b37886157fab4c81c8c4d Mon Sep 17 00:00:00 2001 From: Aaron de Mello Date: Mon, 23 Mar 2026 11:46:09 -0400 Subject: [PATCH 04/20] fix(dashboard): never print API keys to terminal Remove the "print to terminal" option from API key delivery to prevent keys from leaking in terminal scrollback, shell history, or CI logs. Replace with "save to file" which writes to a temp file with 0600 permissions. The three options are now: 1. Activate in CLI keyring (key never leaves the process) 2. Copy to clipboard (key never appears in terminal output) 3. Save to temp file (restrictive permissions, user deletes after) Co-Authored-By: Claude Opus 4.6 (1M context) --- internal/cli/dashboard/keys.go | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/internal/cli/dashboard/keys.go b/internal/cli/dashboard/keys.go index 7a98407..bad694d 100644 --- a/internal/cli/dashboard/keys.go +++ b/internal/cli/dashboard/keys.go @@ -2,6 +2,8 @@ package dashboard import ( "fmt" + "os" + "path/filepath" "time" "github.com/spf13/cobra" @@ -178,12 +180,13 @@ Set an active app with: nylas dashboard apps use --region `, } // handleAPIKeyDelivery prompts the user to choose how to handle the newly created key. +// The API key is never printed to stdout to prevent leaking it in terminal history or logs. func handleAPIKeyDelivery(apiKey, appID, region string) error { fmt.Println("\nWhat would you like to do with this API key?") fmt.Println() _, _ = common.Cyan.Println(" [1] Activate for this CLI (recommended)") fmt.Println(" [2] Copy to clipboard") - fmt.Println(" [3] Print to terminal") + fmt.Println(" [3] Save to file") fmt.Println() choice, err := readLine("Choose [1-3]: ") @@ -203,15 +206,18 @@ func handleAPIKeyDelivery(apiKey, appID, region string) error { case "2": if err := common.CopyToClipboard(apiKey); err != nil { _, _ = common.Yellow.Printf(" Clipboard unavailable: %v\n", err) - fmt.Println(" Falling back to print:") - fmt.Printf(" %s\n", apiKey) + _, _ = common.Dim.Println(" Try option [3] to save to a file instead") return nil } _, _ = common.Green.Println("✓ API key copied to clipboard") case "3": - fmt.Println() - fmt.Println(apiKey) + keyFile := filepath.Join(os.TempDir(), "nylas-api-key.txt") + if err := os.WriteFile(keyFile, []byte(apiKey+"\n"), 0o600); err != nil { // #nosec G306 + return wrapDashboardError(fmt.Errorf("failed to write key file: %w", err)) + } + _, _ = common.Green.Printf("✓ API key saved to: %s\n", keyFile) + _, _ = common.Dim.Println(" Read it, then delete the file") default: return dashboardError("invalid selection", "Choose 1-3") From bda3826de74bc12cf1e41de76112738fd71f4d6f Mon Sep 17 00:00:00 2001 From: Aaron de Mello Date: Mon, 23 Mar 2026 17:31:23 -0400 Subject: [PATCH 05/20] fix(dashboard): temporarily disable email/password registration Email/password registration is disabled for the CLI. Users must use SSO (Google, Microsoft, or GitHub) to register. Login via email/password remains available. Co-Authored-By: Claude Opus 4.6 (1M context) --- internal/cli/dashboard/dashboard.go | 2 +- internal/cli/dashboard/helpers.go | 24 +++++- internal/cli/dashboard/register.go | 124 +++------------------------- 3 files changed, 31 insertions(+), 119 deletions(-) diff --git a/internal/cli/dashboard/dashboard.go b/internal/cli/dashboard/dashboard.go index 113ac6a..61054f7 100644 --- a/internal/cli/dashboard/dashboard.go +++ b/internal/cli/dashboard/dashboard.go @@ -14,7 +14,7 @@ func NewDashboardCmd() *cobra.Command { Long: `Authenticate with the Nylas Dashboard and manage applications. Commands: - register Create a new Nylas Dashboard account + register Create a new Nylas Dashboard account (SSO only) login Log in to your Nylas Dashboard account sso Authenticate via SSO (Google, Microsoft, GitHub) logout Log out of the Nylas Dashboard diff --git a/internal/cli/dashboard/helpers.go b/internal/cli/dashboard/helpers.go index 9b8964e..4552daa 100644 --- a/internal/cli/dashboard/helpers.go +++ b/internal/cli/dashboard/helpers.go @@ -155,6 +155,9 @@ func resolveAuthMethod(google, microsoft, github, email bool, action string) (st case github: return methodGitHub, nil case email: + if action == "register" { + return "", dashboardError("email/password registration is temporarily disabled", "Use SSO instead: --google, --microsoft, or --github") + } return methodEmailPassword, nil default: return chooseAuthMethod(action) @@ -162,15 +165,25 @@ func resolveAuthMethod(google, microsoft, github, email bool, action string) (st } // chooseAuthMethod presents an interactive menu. SSO first. +// Email/password registration is temporarily disabled. func chooseAuthMethod(action string) (string, error) { + allowEmail := action != "register" + fmt.Printf("\nHow would you like to %s?\n\n", action) _, _ = common.Cyan.Println(" [1] Google (recommended)") fmt.Println(" [2] Microsoft") fmt.Println(" [3] GitHub") - _, _ = common.Dim.Println(" [4] Email and password") + if allowEmail { + _, _ = common.Dim.Println(" [4] Email and password") + } fmt.Println() - choice, err := readLine("Choose [1-4]: ") + maxChoice := "3" + if allowEmail { + maxChoice = "4" + } + + choice, err := readLine(fmt.Sprintf("Choose [1-%s]: ", maxChoice)) if err != nil { return "", err } @@ -183,9 +196,12 @@ func chooseAuthMethod(action string) (string, error) { case "3": return methodGitHub, nil case "4": - return methodEmailPassword, nil + if allowEmail { + return methodEmailPassword, nil + } + return "", dashboardError("invalid selection", "Choose 1-3") default: - return "", dashboardError("invalid selection", "Choose 1-4") + return "", dashboardError("invalid selection", fmt.Sprintf("Choose 1-%s", maxChoice)) } } diff --git a/internal/cli/dashboard/register.go b/internal/cli/dashboard/register.go index 6e1ad5b..05c3fc1 100644 --- a/internal/cli/dashboard/register.go +++ b/internal/cli/dashboard/register.go @@ -1,42 +1,35 @@ package dashboard import ( - "fmt" - "github.com/spf13/cobra" - - "github.com/nylas/cli/internal/cli/common" - "github.com/nylas/cli/internal/domain" ) func newRegisterCmd() *cobra.Command { var ( - region string google bool microsoft bool github bool - emailFlag bool - userFlag string - passFlag string - codeFlag string ) cmd := &cobra.Command{ Use: "register", Short: "Create a new Nylas Dashboard account", - Long: `Register a new Nylas Dashboard account. + Long: `Register a new Nylas Dashboard account using SSO. -Choose SSO (recommended) or email/password. Pass a flag to skip the menu.`, - Example: ` # Interactive — choose method +Email/password registration is temporarily disabled. Use SSO instead.`, + Example: ` # Interactive — choose SSO provider nylas dashboard register # Google SSO (non-interactive) nylas dashboard register --google - # Email/password fully non-interactive - nylas dashboard register --email --user me@co.com --password s3cret --code AB12CD34 --region us`, + # Microsoft SSO + nylas dashboard register --microsoft + + # GitHub SSO + nylas dashboard register --github`, RunE: func(cmd *cobra.Command, args []string) error { - method, err := resolveAuthMethod(google, microsoft, github, emailFlag, "register") + method, err := resolveAuthMethod(google, microsoft, github, false, "register") if err != nil { return wrapDashboardError(err) } @@ -44,10 +37,8 @@ Choose SSO (recommended) or email/password. Pass a flag to skip the menu.`, switch method { case methodGoogle, methodMicrosoft, methodGitHub: return runSSORegister(method) - case methodEmailPassword: - return runEmailRegister(userFlag, passFlag, codeFlag, region) default: - return dashboardError("invalid selection", "Choose a valid option") + return dashboardError("invalid selection", "Choose a valid SSO provider") } }, } @@ -55,11 +46,6 @@ Choose SSO (recommended) or email/password. Pass a flag to skip the menu.`, cmd.Flags().BoolVar(&google, "google", false, "Register with Google SSO") cmd.Flags().BoolVar(µsoft, "microsoft", false, "Register with Microsoft SSO") cmd.Flags().BoolVar(&github, "github", false, "Register with GitHub SSO") - cmd.Flags().BoolVar(&emailFlag, "email", false, "Register with email and password") - cmd.Flags().StringVarP(®ion, "region", "r", "us", "Account region (us or eu)") - cmd.Flags().StringVar(&userFlag, "user", "", "Email address (non-interactive)") - cmd.Flags().StringVar(&passFlag, "password", "", "Password (non-interactive, use with care)") - cmd.Flags().StringVar(&codeFlag, "code", "", "Verification code (non-interactive, skip prompt)") return cmd } @@ -71,93 +57,3 @@ func runSSORegister(provider string) error { return runSSO(provider, "register", true) } -func runEmailRegister(userFlag, passFlag, codeFlag, region string) error { - if err := acceptPrivacyPolicy(); err != nil { - return err - } - - authSvc, _, err := createAuthService() - if err != nil { - return wrapDashboardError(err) - } - - email := userFlag - if email == "" { - email, err = readLine("Email: ") - if err != nil { - return wrapDashboardError(err) - } - } - if email == "" { - return dashboardError("email is required", "Use --user or enter at prompt") - } - - password := passFlag - if password == "" { - password, err = readPassword("Password: ") - if err != nil { - return wrapDashboardError(err) - } - confirm, cErr := readPassword("Confirm password: ") - if cErr != nil { - return wrapDashboardError(cErr) - } - if password != confirm { - return dashboardError("passwords do not match", "Try again") - } - } - if password == "" { - return dashboardError("password is required", "Use --password or enter at prompt") - } - - ctx, cancel := common.CreateContext() - defer cancel() - - var resp *domain.DashboardRegisterResponse - err = common.RunWithSpinner("Creating account...", func() error { - resp, err = authSvc.Register(ctx, email, password, true) - return err - }) - if err != nil { - return wrapDashboardError(err) - } - - _, _ = common.Green.Println("✓ Verification code sent to your email") - _, _ = common.Dim.Printf(" Expires: %s\n", resp.ExpiresAt) - - code := codeFlag - if code == "" { - fmt.Println() - code, err = readLine("Enter verification code: ") - if err != nil { - return wrapDashboardError(err) - } - } - if code == "" { - return dashboardError("verification code is required", "Check your email, or use --code") - } - - ctx2, cancel2 := common.CreateContext() - defer cancel2() - - var authResp *domain.DashboardAuthResponse - err = common.RunWithSpinner("Verifying...", func() error { - authResp, err = authSvc.VerifyEmailCode(ctx2, email, code, region) - return err - }) - if err != nil { - return wrapDashboardError(err) - } - - if len(authResp.Organizations) > 1 { - orgID := selectOrg(authResp.Organizations) - _ = authSvc.SetActiveOrg(orgID) - } - - printAuthSuccess(authResp) - fmt.Println("\nNext steps:") - fmt.Println(" nylas dashboard apps list List your applications") - fmt.Println(" nylas dashboard apps create Create a new application") - - return nil -} From 7ec12490c5a9801eed0e023bff431f8e6dfb08a3 Mon Sep 17 00:00:00 2001 From: Aaron de Mello Date: Tue, 24 Mar 2026 11:24:07 -0400 Subject: [PATCH 06/20] feat(setup): add first-time setup wizard (`nylas init`) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduce a guided onboarding experience for new CLI users. Running `nylas` with no args now detects first-run state and shows a welcome message pointing to `nylas init`. The wizard walks through four steps: 1. Account — register or login via SSO, or paste an existing API key 2. Application — auto-create or select from existing apps 3. API Key — generate and activate into the keyring 4. Grants — sync existing email accounts from the Nylas API Key design decisions: - Re-entrant: running `nylas init` again skips completed steps - Non-interactive: `nylas init --api-key ` for CI/scripts - Graceful recovery: each step prints manual commands on failure - Shared grant sync: extracted from auth/config.go for reuse New files: internal/cli/setup/ (detect, wizard, grants, helpers, tests) internal/cli/dashboard/exports.go (exported service wrappers) Co-Authored-By: Claude Opus 4.6 (1M context) --- CLAUDE.md | 4 +- README.md | 10 +- cmd/nylas/main.go | 2 + docs/ARCHITECTURE.md | 1 + docs/COMMANDS.md | 19 ++ docs/DEVELOPMENT.md | 3 +- docs/INDEX.md | 1 + internal/cli/auth/config.go | 99 ++---- internal/cli/dashboard/exports.go | 41 +++ internal/cli/dashboard/register.go | 1 - internal/cli/root.go | 30 ++ internal/cli/setup/detect.go | 85 +++++ internal/cli/setup/grants.go | 104 ++++++ internal/cli/setup/setup.go | 47 +++ internal/cli/setup/setup_test.go | 202 ++++++++++++ internal/cli/setup/wizard.go | 474 +++++++++++++++++++++++++++ internal/cli/setup/wizard_helpers.go | 196 +++++++++++ 17 files changed, 1234 insertions(+), 85 deletions(-) create mode 100644 internal/cli/dashboard/exports.go create mode 100644 internal/cli/setup/detect.go create mode 100644 internal/cli/setup/grants.go create mode 100644 internal/cli/setup/setup.go create mode 100644 internal/cli/setup/setup_test.go create mode 100644 internal/cli/setup/wizard.go create mode 100644 internal/cli/setup/wizard_helpers.go diff --git a/CLAUDE.md b/CLAUDE.md index ffd2d92..394b8d8 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -106,7 +106,7 @@ Credentials stored in system keyring (service: `"nylas"`) via `nylas auth config **Quick lookup:** CLI helpers in `internal/cli/common/`, HTTP in `client.go`, Air at `internal/air/`, Chat at `internal/chat/` -**CLI packages:** admin, ai, audit, auth, calendar, config, contacts, email, inbound, mcp, notetaker, otp, scheduler, slack, timezone, webhook +**CLI packages:** admin, ai, audit, auth, calendar, config, contacts, email, inbound, mcp, notetaker, otp, scheduler, setup, slack, timezone, webhook **Additional packages:** - `internal/ports/output.go` - OutputWriter interface for pluggable formatting @@ -115,6 +115,7 @@ Credentials stored in system keyring (service: `"nylas"`) via `nylas auth config - `internal/adapters/gpg/` - GPG/PGP email signing service (2026) - `internal/adapters/mime/` - RFC 3156 PGP/MIME message builder (2026) - `internal/chat/` - AI chat interface with local agent support (2026) +- `internal/cli/setup/` - First-time setup wizard (`nylas init`) **Full inventory:** `docs/ARCHITECTURE.md` @@ -173,6 +174,7 @@ Credentials stored in system keyring (service: `"nylas"`) via `nylas auth config | `make ci-full` | Complete CI (quality + tests) - **run before commits** | | `make ci` | Quick quality checks (no integration) | | `make build` | Build binary | +| `nylas init` | First-time setup wizard | | `nylas air` | Start Air web UI (localhost:7365) | | `nylas chat` | Start AI chat interface (localhost:7367) | diff --git a/README.md b/README.md index d53ccbe..29369ab 100644 --- a/README.md +++ b/README.md @@ -28,13 +28,17 @@ go install github.com/nylas/cli/cmd/nylas@latest nylas tui --demo ``` -**Ready to connect your account?** [Get API credentials](https://dashboard.nylas.com/) (free tier available), then: +**Ready to connect your account?** The setup wizard handles everything: ```bash -nylas auth config # Enter your API key -nylas auth login # Connect your email provider +nylas init # Guided setup — account, app, API key, done nylas email list # You're ready! ``` +Already have an API key? Skip the wizard: +```bash +nylas init --api-key +``` + ## Basic Commands | Command | Example | diff --git a/cmd/nylas/main.go b/cmd/nylas/main.go index 8704bb6..c7c7a74 100644 --- a/cmd/nylas/main.go +++ b/cmd/nylas/main.go @@ -23,6 +23,7 @@ import ( "github.com/nylas/cli/internal/cli/notetaker" "github.com/nylas/cli/internal/cli/otp" "github.com/nylas/cli/internal/cli/scheduler" + "github.com/nylas/cli/internal/cli/setup" "github.com/nylas/cli/internal/cli/slack" "github.com/nylas/cli/internal/cli/timezone" "github.com/nylas/cli/internal/cli/update" @@ -45,6 +46,7 @@ func main() { rootCmd.AddCommand(calendar.NewCalendarCmd()) rootCmd.AddCommand(contacts.NewContactsCmd()) rootCmd.AddCommand(dashboard.NewDashboardCmd()) + rootCmd.AddCommand(setup.NewSetupCmd()) rootCmd.AddCommand(scheduler.NewSchedulerCmd()) rootCmd.AddCommand(admin.NewAdminCmd()) rootCmd.AddCommand(webhook.NewWebhookCmd()) diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 676cc30..2a5a704 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -40,6 +40,7 @@ internal/ notetaker/ # Meeting notetaker otp/ # OTP extraction scheduler/ # Booking pages + setup/ # First-time setup wizard (nylas init) slack/ # Slack integration timezone/ # Timezone utilities update/ # Self-update diff --git a/docs/COMMANDS.md b/docs/COMMANDS.md index ca41811..5b93e01 100644 --- a/docs/COMMANDS.md +++ b/docs/COMMANDS.md @@ -71,6 +71,25 @@ nylas completion powershell >> $PROFILE --- +## Getting Started + +```bash +nylas init # Guided first-time setup +nylas init --api-key # Quick setup with existing API key +nylas init --api-key --region eu # Setup with EU region +nylas init --google # Setup with Google SSO shortcut +``` + +The `init` command walks you through: +1. Creating or logging into your Nylas account (SSO) +2. Selecting or creating an application +3. Generating and activating an API key +4. Syncing existing email accounts + +Run `nylas init` again after partial setup — it skips completed steps. + +--- + ## Authentication ```bash diff --git a/docs/DEVELOPMENT.md b/docs/DEVELOPMENT.md index c7c668c..d115875 100644 --- a/docs/DEVELOPMENT.md +++ b/docs/DEVELOPMENT.md @@ -70,7 +70,8 @@ internal/ ├── domain/ # Domain models ├── ports/ # Interfaces ├── adapters/ # Implementations - └── cli/ # Commands + ├── cli/ # Commands (incl. setup/ for nylas init) + └── ... ``` --- diff --git a/docs/INDEX.md b/docs/INDEX.md index 95fab48..5d7d20e 100644 --- a/docs/INDEX.md +++ b/docs/INDEX.md @@ -10,6 +10,7 @@ Quick navigation guide to find the right documentation for your needs. ### Get Started +- **First-time setup** → `nylas init` ([details](COMMANDS.md#getting-started)) - **Learn about Nylas CLI** → [README.md](../README.md) - **Quick command reference** → [COMMANDS.md](COMMANDS.md) - **See examples** → [COMMANDS.md](COMMANDS.md) and [commands/](commands/) diff --git a/internal/cli/auth/config.go b/internal/cli/auth/config.go index 9e509ac..e6cb50d 100644 --- a/internal/cli/auth/config.go +++ b/internal/cli/auth/config.go @@ -11,6 +11,7 @@ import ( nylasadapter "github.com/nylas/cli/internal/adapters/nylas" "github.com/nylas/cli/internal/cli/common" + "github.com/nylas/cli/internal/cli/setup" "github.com/nylas/cli/internal/domain" ) @@ -222,59 +223,22 @@ The CLI only requires your API Key - Client ID is auto-detected.`, fmt.Println() fmt.Println("Checking for existing grants...") - client := nylasadapter.NewHTTPClient() - client.SetRegion(region) - client.SetCredentials(clientID, "", apiKey) - - ctx, cancel := common.CreateContext() - defer cancel() - - grants, err := client.ListGrants(ctx) + grantStore, err := createGrantStore() if err != nil { - _, _ = common.Yellow.Printf(" Could not fetch grants: %v\n", err) - fmt.Println() - fmt.Println("Next steps:") - fmt.Println(" nylas auth login Authenticate with your email provider") + _, _ = common.Yellow.Printf(" Could not access grant store: %v\n", err) return nil } - if len(grants) == 0 { - fmt.Println(" No existing grants found") + result, err := setup.SyncGrants(grantStore, apiKey, clientID, region) + if err != nil { + _, _ = common.Yellow.Printf(" Could not fetch grants: %v\n", err) fmt.Println() fmt.Println("Next steps:") fmt.Println(" nylas auth login Authenticate with your email provider") return nil } - // Get grant store to save grants locally - grantStore, err := createGrantStore() - if err != nil { - _, _ = common.Yellow.Printf(" Could not save grants locally: %v\n", err) - return nil - } - - // First pass: Add all valid grants without setting default - var validGrants []domain.Grant - for _, grant := range grants { - if !grant.IsValid() { - continue - } - - grantInfo := domain.GrantInfo{ - ID: grant.ID, - Email: grant.Email, - Provider: grant.Provider, - } - - if err := grantStore.SaveGrant(grantInfo); err != nil { - continue - } - - validGrants = append(validGrants, grant) - _, _ = common.Green.Printf(" ✓ Added %s (%s)\n", grant.Email, grant.Provider.DisplayName()) - } - - if len(validGrants) == 0 { + if len(result.ValidGrants) == 0 { fmt.Println(" No valid grants found") fmt.Println() fmt.Println("Next steps:") @@ -282,53 +246,30 @@ The CLI only requires your API Key - Client ID is auto-detected.`, return nil } - // Second pass: Set default grant - var defaultGrantID string - if len(validGrants) == 1 { - // Single grant - auto-select as default - defaultGrantID = validGrants[0].ID - _ = grantStore.SetDefaultGrant(defaultGrantID) + // Set default grant + defaultGrantID := result.DefaultGrantID + if defaultGrantID != "" { + // Single grant, auto-selected fmt.Println() - _, _ = common.Green.Printf("✓ Set %s as default account\n", validGrants[0].Email) - } else { - // Multiple grants - let user choose default - fmt.Println() - fmt.Println("Select default account:") - for i, grant := range validGrants { - fmt.Printf(" [%d] %s (%s)\n", i+1, grant.Email, grant.Provider.DisplayName()) - } - fmt.Println() - fmt.Print("Select default account (1-", len(validGrants), "): ") - input, _ := reader.ReadString('\n') - choice := strings.TrimSpace(input) - - var selected int - if _, err := fmt.Sscanf(choice, "%d", &selected); err != nil || selected < 1 || selected > len(validGrants) { - // If invalid selection, default to first - _, _ = common.Yellow.Printf("Invalid selection, defaulting to %s\n", validGrants[0].Email) - defaultGrantID = validGrants[0].ID - } else { - defaultGrantID = validGrants[selected-1].ID - } - - _ = grantStore.SetDefaultGrant(defaultGrantID) - selectedGrant := validGrants[0] - for _, g := range validGrants { + _, _ = 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) + for _, g := range result.ValidGrants { if g.ID == defaultGrantID { - selectedGrant = g + _, _ = common.Green.Printf("✓ Set %s as default account\n", g.Email) break } } - _, _ = common.Green.Printf("✓ Set %s as default account\n", selectedGrant.Email) } fmt.Println() - fmt.Printf("Added %d grant(s). Run 'nylas auth list' to see all accounts.\n", len(validGrants)) + fmt.Printf("Added %d grant(s). Run 'nylas auth list' to see all accounts.\n", len(result.ValidGrants)) // Update config file with default grant and grants list cfg.DefaultGrant = defaultGrantID - cfg.Grants = make([]domain.GrantInfo, len(validGrants)) - for i, grant := range validGrants { + cfg.Grants = make([]domain.GrantInfo, len(result.ValidGrants)) + for i, grant := range result.ValidGrants { cfg.Grants[i] = domain.GrantInfo{ ID: grant.ID, Email: grant.Email, diff --git a/internal/cli/dashboard/exports.go b/internal/cli/dashboard/exports.go new file mode 100644 index 0000000..e7052e9 --- /dev/null +++ b/internal/cli/dashboard/exports.go @@ -0,0 +1,41 @@ +package dashboard + +import ( + dashboardapp "github.com/nylas/cli/internal/app/dashboard" + "github.com/nylas/cli/internal/ports" +) + +// CreateAuthService creates the dashboard auth service chain (exported for setup wizard). +func CreateAuthService() (*dashboardapp.AuthService, ports.SecretStore, error) { + return createAuthService() +} + +// CreateAppService creates the dashboard app management service (exported for setup wizard). +func CreateAppService() (*dashboardapp.AppService, error) { + return createAppService() +} + +// RunSSO executes the SSO device-code flow (exported for setup wizard). +func RunSSO(provider, mode string, privacyAccepted bool) error { + return runSSO(provider, mode, privacyAccepted) +} + +// AcceptPrivacyPolicy prompts for privacy policy acceptance (exported for setup wizard). +func AcceptPrivacyPolicy() error { + return acceptPrivacyPolicy() +} + +// ActivateAPIKey stores an API key in the keyring and configures the CLI (exported for setup wizard). +func ActivateAPIKey(apiKey, clientID, region string) error { + return activateAPIKey(apiKey, clientID, region) +} + +// GetActiveOrgID retrieves the active organization ID (exported for setup wizard). +func GetActiveOrgID() (string, error) { + return getActiveOrgID() +} + +// ReadLine prompts for a line of text input (exported for setup wizard). +func ReadLine(prompt string) (string, error) { + return readLine(prompt) +} diff --git a/internal/cli/dashboard/register.go b/internal/cli/dashboard/register.go index 05c3fc1..11280e1 100644 --- a/internal/cli/dashboard/register.go +++ b/internal/cli/dashboard/register.go @@ -56,4 +56,3 @@ func runSSORegister(provider string) error { } return runSSO(provider, "register", true) } - diff --git a/internal/cli/root.go b/internal/cli/root.go index aae3803..2b11687 100644 --- a/internal/cli/root.go +++ b/internal/cli/root.go @@ -2,7 +2,12 @@ package cli import ( + "fmt" + "github.com/spf13/cobra" + + "github.com/nylas/cli/internal/cli/common" + "github.com/nylas/cli/internal/cli/setup" ) var rootCmd = &cobra.Command{ @@ -67,6 +72,31 @@ INTERACTIVE TUI: Documentation: https://cli.nylas.com/`, SilenceUsage: true, SilenceErrors: true, + RunE: func(cmd *cobra.Command, args []string) error { + if setup.IsFirstRun() { + printWelcome() + return nil + } + return cmd.Help() + }, +} + +// printWelcome displays the first-run welcome message. +func printWelcome() { + fmt.Println() + _, _ = common.Bold.Println(" Welcome to the Nylas CLI!") + fmt.Println() + fmt.Println(" Get started in under a minute:") + fmt.Println() + _, _ = common.Cyan.Println(" nylas init Guided setup") + _, _ = common.Dim.Println(" nylas init --api-key Quick setup with existing key") + fmt.Println() + fmt.Println(" Already configured? Run:") + fmt.Println() + fmt.Println(" nylas --help See all commands") + fmt.Println() + _, _ = common.Dim.Println(" Docs: https://cli.nylas.com/") + fmt.Println() } func init() { diff --git a/internal/cli/setup/detect.go b/internal/cli/setup/detect.go new file mode 100644 index 0000000..3f04bb6 --- /dev/null +++ b/internal/cli/setup/detect.go @@ -0,0 +1,85 @@ +// Package setup provides the first-time user experience wizard for the Nylas CLI. +package setup + +import ( + "os" + + "github.com/nylas/cli/internal/adapters/config" + "github.com/nylas/cli/internal/adapters/keyring" + "github.com/nylas/cli/internal/ports" +) + +// SetupStatus describes what is already configured in the CLI. +type SetupStatus struct { + HasDashboardAuth bool + HasAPIKey bool + HasActiveApp bool + HasGrants bool + ActiveAppID string + ActiveAppRegion string +} + +// IsFirstRun returns true when the CLI has never been configured. +// A user is "first run" when there is no API key (keyring or env) and +// no dashboard session token. +func IsFirstRun() bool { + // Check environment variable first (cheapest check). + if os.Getenv("NYLAS_API_KEY") != "" { + return false + } + + secretStore, err := keyring.NewSecretStore(config.DefaultConfigDir()) + if err != nil { + // Can't access secrets — treat as first run so the welcome message shows. + return true + } + + if hasKey(secretStore, ports.KeyAPIKey) { + return false + } + if hasKey(secretStore, ports.KeyDashboardUserToken) { + return false + } + + return true +} + +// GetSetupStatus returns a detailed view of the current setup state. +func GetSetupStatus() SetupStatus { + status := SetupStatus{} + + // Check env-based API key. + if os.Getenv("NYLAS_API_KEY") != "" { + status.HasAPIKey = true + } + + secretStore, err := keyring.NewSecretStore(config.DefaultConfigDir()) + if err != nil { + return status + } + + status.HasAPIKey = status.HasAPIKey || hasKey(secretStore, ports.KeyAPIKey) + status.HasDashboardAuth = hasKey(secretStore, ports.KeyDashboardUserToken) + + appID, _ := secretStore.Get(ports.KeyDashboardAppID) + appRegion, _ := secretStore.Get(ports.KeyDashboardAppRegion) + if appID != "" && appRegion != "" { + status.HasActiveApp = true + status.ActiveAppID = appID + status.ActiveAppRegion = appRegion + } + + grantStore := keyring.NewGrantStore(secretStore) + grants, err := grantStore.ListGrants() + if err == nil && len(grants) > 0 { + status.HasGrants = true + } + + return status +} + +// hasKey returns true if a non-empty value exists for the given key. +func hasKey(store ports.SecretStore, key string) bool { + val, err := store.Get(key) + return err == nil && val != "" +} diff --git a/internal/cli/setup/grants.go b/internal/cli/setup/grants.go new file mode 100644 index 0000000..f8ab7f8 --- /dev/null +++ b/internal/cli/setup/grants.go @@ -0,0 +1,104 @@ +package setup + +import ( + "bufio" + "fmt" + "os" + "strings" + + nylasadapter "github.com/nylas/cli/internal/adapters/nylas" + "github.com/nylas/cli/internal/cli/common" + "github.com/nylas/cli/internal/domain" + "github.com/nylas/cli/internal/ports" +) + +// SyncResult holds the result of a grant sync operation. +type SyncResult struct { + ValidGrants []domain.Grant + DefaultGrantID string +} + +// SyncGrants fetches grants from the Nylas API and saves them to the local keyring. +// 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) { + client := nylasadapter.NewHTTPClient() + client.SetRegion(region) + client.SetCredentials(clientID, "", apiKey) + + ctx, cancel := common.CreateContext() + defer cancel() + + grants, err := client.ListGrants(ctx) + if err != nil { + return nil, fmt.Errorf("could not fetch grants: %w", err) + } + + var validGrants []domain.Grant + for _, grant := range grants { + if !grant.IsValid() { + continue + } + + grantInfo := domain.GrantInfo{ + ID: grant.ID, + Email: grant.Email, + Provider: grant.Provider, + } + + if saveErr := grantStore.SaveGrant(grantInfo); saveErr != nil { + continue + } + + validGrants = append(validGrants, grant) + _, _ = common.Green.Printf(" ✓ Added %s (%s)\n", grant.Email, grant.Provider.DisplayName()) + } + + result := &SyncResult{ValidGrants: validGrants} + + // Auto-set default if there's exactly one valid grant. + if len(validGrants) == 1 { + result.DefaultGrantID = validGrants[0].ID + _ = grantStore.SetDefaultGrant(result.DefaultGrantID) + } + + 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) { + fmt.Println() + fmt.Println("Select default account:") + for i, grant := range grants { + fmt.Printf(" [%d] %s (%s)\n", i+1, grant.Email, grant.Provider.DisplayName()) + } + fmt.Println() + + choice, err := readLine(fmt.Sprintf("Choose [1-%d]: ", len(grants))) + if err != nil { + return grants[0].ID, nil + } + + var selected int + if _, err := fmt.Sscanf(choice, "%d", &selected); err != nil || selected < 1 || selected > len(grants) { + _, _ = common.Yellow.Printf("Invalid selection, defaulting to %s\n", grants[0].Email) + _ = grantStore.SetDefaultGrant(grants[0].ID) + return grants[0].ID, nil + } + + chosen := grants[selected-1] + _ = grantStore.SetDefaultGrant(chosen.ID) + return chosen.ID, nil +} + +// readLine prompts for a line of text input. +func readLine(prompt string) (string, error) { + fmt.Print(prompt) + reader := bufio.NewReader(os.Stdin) + input, err := reader.ReadString('\n') + if err != nil { + return "", fmt.Errorf("failed to read input: %w", err) + } + return strings.TrimSpace(input), nil +} diff --git a/internal/cli/setup/setup.go b/internal/cli/setup/setup.go new file mode 100644 index 0000000..ff1bb42 --- /dev/null +++ b/internal/cli/setup/setup.go @@ -0,0 +1,47 @@ +package setup + +import ( + "github.com/spf13/cobra" +) + +// NewSetupCmd creates the "init" command for first-time CLI setup. +func NewSetupCmd() *cobra.Command { + var opts wizardOpts + + cmd := &cobra.Command{ + Use: "init", + Short: "Set up the Nylas CLI", + Long: `Guided setup for first-time users. + +This wizard walks you through: + 1. Creating or logging into your Nylas account + 2. Selecting or creating an application + 3. Generating and activating an API key + 4. Syncing existing email accounts + +Already have an API key? Skip the wizard: + nylas init --api-key `, + Example: ` # Interactive guided setup + nylas init + + # Quick setup with existing API key + nylas init --api-key nyl_abc123 + + # Quick setup with region + nylas init --api-key nyl_abc123 --region eu + + # Skip SSO provider menu + nylas init --google`, + RunE: func(cmd *cobra.Command, args []string) error { + return runWizard(opts) + }, + } + + cmd.Flags().StringVar(&opts.apiKey, "api-key", "", "Nylas API key (skips interactive setup)") + cmd.Flags().StringVarP(&opts.region, "region", "r", "us", "API region (us or eu)") + cmd.Flags().BoolVar(&opts.google, "google", false, "Use Google SSO") + cmd.Flags().BoolVar(&opts.microsoft, "microsoft", false, "Use Microsoft SSO") + cmd.Flags().BoolVar(&opts.github, "github", false, "Use GitHub SSO") + + return cmd +} diff --git a/internal/cli/setup/setup_test.go b/internal/cli/setup/setup_test.go new file mode 100644 index 0000000..bb80d81 --- /dev/null +++ b/internal/cli/setup/setup_test.go @@ -0,0 +1,202 @@ +package setup + +import ( + "testing" + + "github.com/nylas/cli/internal/domain" +) + +func TestSanitizeAPIKey(t *testing.T) { + tests := []struct { + name string + input string + want string + }{ + { + name: "clean key", + input: "nyl_abc123def456", + want: "nyl_abc123def456", + }, + { + name: "key with newline", + input: "nyl_abc123\n", + want: "nyl_abc123", + }, + { + name: "key with carriage return", + input: "nyl_abc123\r\n", + want: "nyl_abc123", + }, + { + name: "key with tab", + input: "\tnyl_abc123\t", + want: "nyl_abc123", + }, + { + name: "key with control characters", + input: "\x00nyl_abc123\x01\x02", + want: "nyl_abc123", + }, + { + name: "key with leading/trailing spaces", + input: " nyl_abc123 ", + want: "nyl_abc123", + }, + { + name: "empty key", + input: "", + want: "", + }, + { + name: "only whitespace", + input: " \n\r\t ", + want: "", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := sanitizeAPIKey(tt.input) + if got != tt.want { + t.Errorf("sanitizeAPIKey(%q) = %q, want %q", tt.input, got, tt.want) + } + }) + } +} + +func TestAppDisplayName(t *testing.T) { + tests := []struct { + name string + app appDisplayInput + want string + }{ + { + name: "basic app", + app: appDisplayInput{ + appID: "abc123", + environment: "production", + region: "us", + }, + want: "abc123 (production, us)", + }, + { + name: "app with name", + app: appDisplayInput{ + appID: "abc123", + environment: "production", + region: "us", + brandName: "My App", + }, + want: "My App — abc123 (production, us)", + }, + { + name: "long app ID truncated", + app: appDisplayInput{ + appID: "abcdefghij1234567890xyz", + environment: "production", + region: "eu", + }, + want: "abcdefghij1234567... (production, eu)", + }, + { + name: "empty environment defaults to production", + app: appDisplayInput{ + appID: "abc123", + environment: "", + region: "us", + }, + want: "abc123 (production, us)", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + app := tt.app.toGatewayApp() + got := appDisplayName(app) + if got != tt.want { + t.Errorf("appDisplayName() = %q, want %q", got, tt.want) + } + }) + } +} + +// appDisplayInput is a test helper for constructing GatewayApplication. +type appDisplayInput struct { + appID string + environment string + region string + brandName string +} + +func (a appDisplayInput) toGatewayApp() domain.GatewayApplication { + app := domain.GatewayApplication{ + ApplicationID: a.appID, + Environment: a.environment, + Region: a.region, + } + if a.brandName != "" { + app.Branding = &domain.GatewayApplicationBrand{Name: a.brandName} + } + return app +} + +func TestResolveProvider(t *testing.T) { + tests := []struct { + name string + opts wizardOpts + want string + }{ + { + name: "google flag", + opts: wizardOpts{google: true}, + want: "google", + }, + { + name: "microsoft flag", + opts: wizardOpts{microsoft: true}, + want: "microsoft", + }, + { + name: "github flag", + opts: wizardOpts{github: true}, + want: "github", + }, + { + name: "no flags", + opts: wizardOpts{}, + want: "", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := resolveProvider(tt.opts) + if got != tt.want { + t.Errorf("resolveProvider() = %q, want %q", got, tt.want) + } + }) + } +} + +func TestSetupStatus(t *testing.T) { + // Test that a zero-value SetupStatus has all fields false. + status := SetupStatus{} + if status.HasDashboardAuth { + t.Error("zero SetupStatus.HasDashboardAuth should be false") + } + if status.HasAPIKey { + t.Error("zero SetupStatus.HasAPIKey should be false") + } + if status.HasActiveApp { + t.Error("zero SetupStatus.HasActiveApp should be false") + } + if status.HasGrants { + t.Error("zero SetupStatus.HasGrants should be false") + } + if status.ActiveAppID != "" { + t.Error("zero SetupStatus.ActiveAppID should be empty") + } + if status.ActiveAppRegion != "" { + t.Error("zero SetupStatus.ActiveAppRegion should be empty") + } +} diff --git a/internal/cli/setup/wizard.go b/internal/cli/setup/wizard.go new file mode 100644 index 0000000..68f719b --- /dev/null +++ b/internal/cli/setup/wizard.go @@ -0,0 +1,474 @@ +package setup + +import ( + "fmt" + "os" + "strings" + "time" + + "golang.org/x/term" + + "github.com/nylas/cli/internal/adapters/config" + "github.com/nylas/cli/internal/adapters/keyring" + "github.com/nylas/cli/internal/cli/common" + "github.com/nylas/cli/internal/cli/dashboard" + "github.com/nylas/cli/internal/domain" + "github.com/nylas/cli/internal/ports" +) + +// wizardOpts holds the options parsed from CLI flags. +type wizardOpts struct { + apiKey string + region string + google bool + microsoft bool + github bool +} + +// pathChoice represents the user's initial choice in the wizard. +type pathChoice int + +const ( + pathRegister pathChoice = iota + 1 + pathLogin + pathAPIKey +) + +const ( + stepTotal = 4 + divider = "──────────────────────────────────────────" +) + +func runWizard(opts wizardOpts) error { + fmt.Println() + _, _ = common.Bold.Println(" Welcome to Nylas! Let's get you set up.") + fmt.Println() + + status := GetSetupStatus() + + // Non-interactive: --api-key was provided. + if opts.apiKey != "" { + return runNonInteractive(opts, status) + } + + // Interactive: must be a TTY. + if !term.IsTerminal(int(os.Stdin.Fd())) { + common.PrintError("--api-key is required in non-interactive mode") + fmt.Println() + fmt.Println(" Usage: nylas init --api-key [--region us|eu]") + return fmt.Errorf("non-interactive mode requires --api-key") + } + + // Step 1: Account + if err := stepAccount(opts, &status); err != nil { + return err + } + + // Step 2: Application (skipped for API key path — handled in stepAccount) + if err := stepApplication(&status); err != nil { + printStepRecovery("application", []string{ + "nylas dashboard apps list", + "nylas dashboard apps create --name 'My App' --region us", + }) + } + + // Step 3: API Key + if err := stepAPIKey(&status); err != nil { + printStepRecovery("API key", []string{ + "nylas dashboard apps apikeys create", + }) + } + + // Step 4: Grants + stepGrantSync(&status) + + // Done! + printComplete() + return nil +} + +// runNonInteractive handles the --api-key flag path with no prompts. +func runNonInteractive(opts wizardOpts, status SetupStatus) error { + if status.HasAPIKey { + _, _ = common.Green.Println(" ✓ API key already configured") + stepGrantSync(&status) + printComplete() + return nil + } + + region := opts.region + if region == "" { + region = "us" + } + + apiKey := sanitizeAPIKey(opts.apiKey) + + fmt.Println() + var verifyErr error + _ = common.RunWithSpinner("Verifying API key...", func() error { + verifyErr = verifyAPIKey(apiKey, region) + return verifyErr + }) + if verifyErr != nil { + common.PrintError("Invalid API key: %v", verifyErr) + return verifyErr + } + _, _ = common.Green.Println(" ✓ API key is valid") + + if err := dashboard.ActivateAPIKey(apiKey, "", region); err != nil { + common.PrintError("Could not activate API key: %v", err) + return err + } + _, _ = common.Green.Println(" ✓ Configuration saved") + + // Refresh status after activation. + status = GetSetupStatus() + stepGrantSync(&status) + printComplete() + return nil +} + +// stepAccount handles Step 1: account registration, login, or API key entry. +func stepAccount(opts wizardOpts, status *SetupStatus) error { + _, _ = common.Dim.Printf(" %s\n", divider) + fmt.Println() + _, _ = common.Bold.Printf(" Step 1 of %d: Account\n", stepTotal) + fmt.Println() + + if status.HasDashboardAuth { + _, _ = common.Green.Println(" ✓ Already logged in to Nylas Dashboard") + return nil + } + if status.HasAPIKey { + _, _ = common.Green.Println(" ✓ API key already configured") + return nil + } + + // Determine the path. + path, err := chooseAccountPath(opts) + if err != nil { + return err + } + + switch path { + case pathRegister: + return accountSSO(opts, "register") + case pathLogin: + return accountSSO(opts, "login") + case pathAPIKey: + return accountAPIKey(status) + } + return nil +} + +// chooseAccountPath presents the three-option menu or resolves from flags. +func chooseAccountPath(opts wizardOpts) (pathChoice, error) { + // If SSO flag was provided, determine register vs login. + if opts.google || opts.microsoft || opts.github { + return pathLogin, nil + } + + fmt.Println(" Do you have a Nylas account?") + fmt.Println() + _, _ = common.Cyan.Println(" [1] No, create one (free)") + fmt.Println(" [2] Yes, log me in") + fmt.Println(" [3] I already have an API key") + fmt.Println() + + choice, err := readLine(" Choose [1-3]: ") + if err != nil { + return 0, fmt.Errorf("failed to read choice: %w", err) + } + + switch strings.TrimSpace(choice) { + case "1", "": + return pathRegister, nil + case "2": + return pathLogin, nil + case "3": + return pathAPIKey, nil + default: + common.PrintError("Invalid selection") + return 0, fmt.Errorf("invalid selection: %s", choice) + } +} + +// accountSSO handles SSO registration or login. +func accountSSO(opts wizardOpts, mode string) error { + if mode == "register" { + if err := dashboard.AcceptPrivacyPolicy(); err != nil { + return err + } + } + + provider := resolveProvider(opts) + if provider == "" { + var err error + provider, err = chooseProvider() + if err != nil { + return err + } + } + + return dashboard.RunSSO(provider, mode, mode == "register") +} + +// accountAPIKey handles the "I have an API key" path. +func accountAPIKey(status *SetupStatus) error { + fmt.Print(" API Key (hidden): ") + apiKeyBytes, err := term.ReadPassword(int(os.Stdin.Fd())) + fmt.Println() + if err != nil { + return fmt.Errorf("failed to read API key: %w", err) + } + + apiKey := sanitizeAPIKey(string(apiKeyBytes)) + if apiKey == "" { + return fmt.Errorf("API key is required") + } + + region, err := readLine(" Region [us/eu] (default: us): ") + if err != nil { + return err + } + if region == "" { + region = "us" + } + + var verifyErr error + _ = common.RunWithSpinner("Verifying API key...", func() error { + verifyErr = verifyAPIKey(apiKey, region) + return verifyErr + }) + if verifyErr != nil { + common.PrintError("Invalid API key: %v", verifyErr) + return verifyErr + } + + if err := dashboard.ActivateAPIKey(apiKey, "", region); err != nil { + return fmt.Errorf("could not activate API key: %w", err) + } + + _, _ = common.Green.Println(" ✓ API key activated") + + // Update status — the API key path skips Steps 2 and 3. + *status = GetSetupStatus() + return nil +} + +// stepApplication handles Step 2: list or create an application. +func stepApplication(status *SetupStatus) error { + _, _ = common.Dim.Printf(" %s\n", divider) + fmt.Println() + _, _ = common.Bold.Printf(" Step 2 of %d: Application\n", stepTotal) + fmt.Println() + + // If user entered an API key directly, app is already resolved. + if status.HasAPIKey && !status.HasDashboardAuth { + _, _ = common.Green.Println(" ✓ Application configured via API key") + return nil + } + + if status.HasActiveApp { + _, _ = common.Green.Printf(" ✓ Active application: %s (%s)\n", status.ActiveAppID, status.ActiveAppRegion) + return nil + } + + appSvc, err := dashboard.CreateAppService() + if err != nil { + return err + } + + orgID, err := dashboard.GetActiveOrgID() + if err != nil { + return err + } + + ctx, cancel := common.CreateContext() + defer cancel() + + var apps []domain.GatewayApplication + err = common.RunWithSpinner("Checking for existing applications...", func() error { + apps, err = appSvc.ListApplications(ctx, orgID, "") + return err + }) + if err != nil { + return err + } + + var selectedApp domain.GatewayApplication + + switch len(apps) { + case 0: + // Create a new application. + app, createErr := createDefaultApp(appSvc, orgID) + if createErr != nil { + return createErr + } + selectedApp = domain.GatewayApplication{ + ApplicationID: app.ApplicationID, + Region: app.Region, + Environment: app.Environment, + Branding: app.Branding, + } + case 1: + selectedApp = apps[0] + name := appDisplayName(selectedApp) + _, _ = common.Green.Printf(" ✓ Found application: %s\n", name) + default: + selected, selectErr := selectApp(apps) + if selectErr != nil { + return selectErr + } + selectedApp = selected + } + + // Set as active app. + if err := setActiveApp(selectedApp.ApplicationID, selectedApp.Region); err != nil { + return err + } + + *status = GetSetupStatus() + return nil +} + +// stepAPIKey handles Step 3: create and activate an API key. +func stepAPIKey(status *SetupStatus) error { + _, _ = common.Dim.Printf(" %s\n", divider) + fmt.Println() + _, _ = common.Bold.Printf(" Step 3 of %d: API Key\n", stepTotal) + fmt.Println() + + if status.HasAPIKey { + _, _ = common.Green.Println(" ✓ API key already configured") + return nil + } + + if !status.HasActiveApp { + return fmt.Errorf("no active application — cannot create API key") + } + + appSvc, err := dashboard.CreateAppService() + if err != nil { + return err + } + + keyName := "CLI-" + time.Now().Format("20060102-150405") + + ctx, cancel := common.CreateContext() + defer cancel() + + var key *domain.GatewayCreatedAPIKey + err = common.RunWithSpinner("Creating API key...", func() error { + key, err = appSvc.CreateAPIKey(ctx, status.ActiveAppID, status.ActiveAppRegion, keyName, 0) + return err + }) + if err != nil { + return err + } + + _, _ = common.Green.Println(" ✓ API key created") + + // Activate the key directly (no 3-option menu in the wizard). + err = common.RunWithSpinner("Activating API key...", func() error { + return dashboard.ActivateAPIKey(key.APIKey, status.ActiveAppID, status.ActiveAppRegion) + }) + if err != nil { + return err + } + + _, _ = common.Green.Println(" ✓ API key activated") + *status = GetSetupStatus() + return nil +} + +// stepGrantSync handles Step 4: sync grants from the Nylas API. +func stepGrantSync(status *SetupStatus) { + _, _ = common.Dim.Printf(" %s\n", divider) + fmt.Println() + _, _ = common.Bold.Printf(" Step 4 of %d: Email Accounts\n", stepTotal) + fmt.Println() + + if status.HasGrants { + _, _ = common.Green.Println(" ✓ Email accounts already synced") + return + } + + if !status.HasAPIKey { + _, _ = common.Yellow.Println(" Skipped — no API key configured") + return + } + + secretStore, err := keyring.NewSecretStore(config.DefaultConfigDir()) + if err != nil { + _, _ = common.Yellow.Printf(" Could not access keyring: %v\n", err) + return + } + + apiKey, _ := secretStore.Get(ports.KeyAPIKey) + clientID, _ := secretStore.Get(ports.KeyClientID) + configStore := config.NewDefaultFileStore() + cfg, _ := configStore.Load() + region := cfg.Region + + grantStore := keyring.NewGrantStore(secretStore) + + var result *SyncResult + err = common.RunWithSpinner("Checking for existing email accounts...", func() error { + result, err = SyncGrants(grantStore, apiKey, clientID, region) + return err + }) + if err != nil { + _, _ = common.Yellow.Printf(" Could not sync grants: %v\n", err) + fmt.Println() + fmt.Println(" To authenticate later:") + fmt.Println(" nylas auth login") + return + } + + 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") + return + } + + // Handle default grant selection. + if result.DefaultGrantID != "" { + // Single grant, auto-set. + 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) + if defaultID != "" { + for _, g := range result.ValidGrants { + if g.ID == defaultID { + _, _ = common.Green.Printf(" ✓ Set %s as default account\n", g.Email) + break + } + } + } + } + + // Update config file with grants. + updateConfigGrants(configStore, cfg, result) +} + +// updateConfigGrants writes grant info 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 = make([]domain.GrantInfo, len(result.ValidGrants)) + for i, grant := range result.ValidGrants { + cfg.Grants[i] = domain.GrantInfo{ + ID: grant.ID, + Email: grant.Email, + Provider: grant.Provider, + } + } + _ = configStore.Save(cfg) +} diff --git a/internal/cli/setup/wizard_helpers.go b/internal/cli/setup/wizard_helpers.go new file mode 100644 index 0000000..0802673 --- /dev/null +++ b/internal/cli/setup/wizard_helpers.go @@ -0,0 +1,196 @@ +package setup + +import ( + "fmt" + "strings" + + nylasadapter "github.com/nylas/cli/internal/adapters/nylas" + dashboardapp "github.com/nylas/cli/internal/app/dashboard" + "github.com/nylas/cli/internal/cli/common" + "github.com/nylas/cli/internal/cli/dashboard" + "github.com/nylas/cli/internal/domain" + "github.com/nylas/cli/internal/ports" +) + +// printComplete prints the final success message. +func printComplete() { + fmt.Println() + _, _ = common.Bold.Println(" ══════════════════════════════════════════") + fmt.Println() + _, _ = common.Green.Println(" ✓ Setup complete! You're ready to go.") + fmt.Println() + fmt.Println(" Try these commands:") + fmt.Println(" nylas email list List recent emails") + fmt.Println(" nylas calendar events Upcoming events") + fmt.Println(" nylas auth status Check configuration") + fmt.Println() + fmt.Println(" Documentation: https://cli.nylas.com/") + fmt.Println() +} + +// printStepRecovery prints manual recovery instructions when a step fails. +func printStepRecovery(step string, commands []string) { + fmt.Println() + _, _ = common.Yellow.Printf(" Could not complete %s setup automatically.\n", step) + fmt.Println(" To continue manually:") + for _, cmd := range commands { + fmt.Printf(" %s\n", cmd) + } + fmt.Println() +} + +// resolveProvider returns the SSO provider from flags, or empty string if not set. +func resolveProvider(opts wizardOpts) string { + switch { + case opts.google: + return "google" + case opts.microsoft: + return "microsoft" + case opts.github: + return "github" + default: + return "" + } +} + +// chooseProvider presents an SSO provider menu. +func chooseProvider() (string, error) { + fmt.Println() + fmt.Println(" How would you like to authenticate?") + fmt.Println() + _, _ = common.Cyan.Println(" [1] Google (recommended)") + fmt.Println(" [2] Microsoft") + fmt.Println(" [3] GitHub") + fmt.Println() + + choice, err := readLine(" Choose [1-3]: ") + if err != nil { + return "google", nil + } + + switch strings.TrimSpace(choice) { + case "1", "": + return "google", nil + case "2": + return "microsoft", nil + case "3": + return "github", nil + default: + return "google", nil + } +} + +// selectApp prompts the user to select from multiple applications. +func selectApp(apps []domain.GatewayApplication) (domain.GatewayApplication, error) { + fmt.Printf(" Found %d applications:\n\n", len(apps)) + for i, app := range apps { + fmt.Printf(" [%d] %s\n", i+1, appDisplayName(app)) + } + fmt.Println() + + choice, err := readLine(fmt.Sprintf(" Select application [1-%d]: ", len(apps))) + if err != nil { + return apps[0], nil + } + + var selected int + if _, err := fmt.Sscanf(choice, "%d", &selected); err != nil || selected < 1 || selected > len(apps) { + _, _ = common.Yellow.Println(" Invalid selection, using first application") + return apps[0], nil + } + return apps[selected-1], nil +} + +// createDefaultApp creates a new application with defaults. +func createDefaultApp(appSvc *dashboardapp.AppService, orgID string) (*domain.GatewayCreatedApplication, error) { + fmt.Println(" No applications found. Creating one for you...") + fmt.Println() + + name, err := readLine(" App name [My First App]: ") + if err != nil || name == "" { + name = "My First App" + } + + region, err := readLine(" Region [us/eu] (default: us): ") + if err != nil || region == "" { + region = "us" + } + if region != "us" && region != "eu" { + region = "us" + } + + ctx, cancel := common.CreateContext() + defer cancel() + + var app *domain.GatewayCreatedApplication + err = common.RunWithSpinner("Creating application...", func() error { + app, err = appSvc.CreateApplication(ctx, orgID, region, name) + return err + }) + if err != nil { + return nil, err + } + + _, _ = common.Green.Printf(" ✓ Application created: %s (%s)\n", app.ApplicationID, region) + return app, nil +} + +// setActiveApp stores the active application in the keyring. +func setActiveApp(appID, region string) error { + _, secrets, err := dashboard.CreateAuthService() + if err != nil { + return err + } + + if err := secrets.Set(ports.KeyDashboardAppID, appID); err != nil { + return err + } + return secrets.Set(ports.KeyDashboardAppRegion, region) +} + +// appDisplayName returns a human-readable display name for an application. +func appDisplayName(app domain.GatewayApplication) string { + name := "" + if app.Branding != nil { + name = app.Branding.Name + } + env := app.Environment + if env == "" { + env = "production" + } + + displayID := app.ApplicationID + if len(displayID) > 20 { + displayID = displayID[:17] + "..." + } + + if name != "" { + return fmt.Sprintf("%s — %s (%s, %s)", name, displayID, env, app.Region) + } + return fmt.Sprintf("%s (%s, %s)", displayID, env, app.Region) +} + +// verifyAPIKey checks that an API key works by listing applications. +func verifyAPIKey(apiKey, region string) error { + client := nylasadapter.NewHTTPClient() + client.SetRegion(region) + client.SetCredentials("", "", apiKey) + + ctx, cancel := common.CreateContext() + defer cancel() + + _, err := client.ListApplications(ctx) + return err +} + +// sanitizeAPIKey removes invisible characters from a pasted API key. +func sanitizeAPIKey(key string) string { + var result strings.Builder + result.Grow(len(key)) + for _, r := range key { + if r >= ' ' && r <= '~' { + result.WriteRune(r) + } + } + return strings.TrimSpace(result.String()) +} From a603eebfccc83ec03f4f59fe2ad3ed88a66ced81 Mon Sep 17 00:00:00 2001 From: Aaron de Mello Date: Tue, 24 Mar 2026 11:39:03 -0400 Subject: [PATCH 07/20] docs: add dashboard commands to COMMANDS.md Adds the full Dashboard section covering account management (register, login, SSO), application management (list, create, set active), and API key management (list, create with delivery options). Co-Authored-By: Claude Opus 4.6 (1M context) --- docs/COMMANDS.md | 51 ++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 51 insertions(+) diff --git a/docs/COMMANDS.md b/docs/COMMANDS.md index 5b93e01..a688541 100644 --- a/docs/COMMANDS.md +++ b/docs/COMMANDS.md @@ -110,6 +110,57 @@ nylas auth migrate # Migrate from v2 to v3 --- +## Dashboard + +Manage your Nylas Dashboard account, applications, and API keys directly from the CLI. + +### Account + +```bash +nylas dashboard register # Create a new account (SSO) +nylas dashboard register --google # Register with Google SSO +nylas dashboard register --microsoft # Register with Microsoft SSO +nylas dashboard register --github # Register with GitHub SSO + +nylas dashboard login # Log in (interactive) +nylas dashboard login --google # Log in with Google SSO +nylas dashboard login --email --user user@example.com # Email/password + +nylas dashboard logout # Log out +nylas dashboard status # Show current auth status +nylas dashboard refresh # Refresh session tokens +``` + +### SSO (Direct) + +```bash +nylas dashboard sso login --provider google # SSO login +nylas dashboard sso register --provider github # SSO registration +``` + +### Applications + +```bash +nylas dashboard apps list # List all applications +nylas dashboard apps list --region us # Filter by region +nylas dashboard apps create --name "My App" --region us # Create app +nylas dashboard apps use --region us # Set active app +``` + +### API Keys + +```bash +nylas dashboard apps apikeys list # List keys (active app) +nylas dashboard apps apikeys list --app --region us # Explicit app +nylas dashboard apps apikeys create # Create key (active app) +nylas dashboard apps apikeys create --name "CI" # Custom name +nylas dashboard apps apikeys create --expires 30 # Expire in 30 days +``` + +After creating a key, you choose: activate in CLI (recommended), copy to clipboard, or save to file. + +--- + ## Demo Mode (No Account Required) Explore the CLI with sample data before connecting your accounts: From 62365522903f38930d071d120045e79115571da7 Mon Sep 17 00:00:00 2001 From: Aaron de Mello Date: Tue, 24 Mar 2026 13:24:07 -0400 Subject: [PATCH 08/20] feat(config): add `nylas config reset` to fully reset CLI state Adds a global reset subcommand under `nylas config` that clears all stored data (API credentials, dashboard session, grants, config file) with a confirmation prompt. This ensures `IsFirstRun()` returns true afterward so first-time setup guidance is shown. Individual resets (`nylas auth config --reset`, `nylas dashboard logout`) remain unchanged and scoped to their own domains. Co-Authored-By: Claude Opus 4.6 (1M context) --- internal/app/auth/config_test.go | 61 ++++++++++++++++++ internal/cli/config/config.go | 6 +- internal/cli/config/reset.go | 102 ++++++++++++++++++++++++++++++ internal/cli/config/reset_test.go | 65 +++++++++++++++++++ 4 files changed, 233 insertions(+), 1 deletion(-) create mode 100644 internal/app/auth/config_test.go create mode 100644 internal/cli/config/reset.go create mode 100644 internal/cli/config/reset_test.go diff --git a/internal/app/auth/config_test.go b/internal/app/auth/config_test.go new file mode 100644 index 0000000..503c560 --- /dev/null +++ b/internal/app/auth/config_test.go @@ -0,0 +1,61 @@ +package auth + +import ( + "testing" + + "github.com/nylas/cli/internal/ports" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// mockSecretStore is a simple in-memory secret store for testing. +type mockSecretStore struct { + data map[string]string +} + +func newMockSecretStore() *mockSecretStore { + return &mockSecretStore{data: make(map[string]string)} +} + +func (m *mockSecretStore) Set(key, value string) error { m.data[key] = value; return nil } +func (m *mockSecretStore) Get(key string) (string, error) { + if v, ok := m.data[key]; ok { + return v, nil + } + return "", nil +} +func (m *mockSecretStore) Delete(key string) error { delete(m.data, key); return nil } +func (m *mockSecretStore) IsAvailable() bool { return true } +func (m *mockSecretStore) Name() string { return "mock" } + +func TestConfigService_ResetConfig(t *testing.T) { + t.Run("clears only API credentials", func(t *testing.T) { + secrets := newMockSecretStore() + configStore := newMockConfigStore() + + // Populate API credentials + secrets.data[ports.KeyClientID] = "client-123" + secrets.data[ports.KeyClientSecret] = "secret-456" + secrets.data[ports.KeyAPIKey] = "nyl_abc" + secrets.data[ports.KeyOrgID] = "org-789" + + // Populate dashboard credentials (should NOT be cleared) + secrets.data[ports.KeyDashboardUserToken] = "user-token" + secrets.data[ports.KeyDashboardAppID] = "app-id" + + svc := NewConfigService(configStore, secrets) + + err := svc.ResetConfig() + require.NoError(t, err) + + // API credentials should be cleared + assert.Empty(t, secrets.data[ports.KeyClientID]) + assert.Empty(t, secrets.data[ports.KeyClientSecret]) + assert.Empty(t, secrets.data[ports.KeyAPIKey]) + assert.Empty(t, secrets.data[ports.KeyOrgID]) + + // Dashboard credentials should be untouched + assert.Equal(t, "user-token", secrets.data[ports.KeyDashboardUserToken]) + assert.Equal(t, "app-id", secrets.data[ports.KeyDashboardAppID]) + }) +} diff --git a/internal/cli/config/config.go b/internal/cli/config/config.go index 2053cc7..9bdaffa 100644 --- a/internal/cli/config/config.go +++ b/internal/cli/config/config.go @@ -43,7 +43,10 @@ If the config file doesn't exist, sensible defaults are used automatically.`, nylas config set gpg.auto_sign true # Initialize config with defaults - nylas config init`, + nylas config init + + # Reset everything (credentials, grants, config) + nylas config reset`, } cmd.AddCommand(newListCmd()) @@ -51,6 +54,7 @@ If the config file doesn't exist, sensible defaults are used automatically.`, cmd.AddCommand(newSetCmd()) cmd.AddCommand(newInitCmd()) cmd.AddCommand(newPathCmd()) + cmd.AddCommand(newResetCmd()) return cmd } diff --git a/internal/cli/config/reset.go b/internal/cli/config/reset.go new file mode 100644 index 0000000..e58f0d1 --- /dev/null +++ b/internal/cli/config/reset.go @@ -0,0 +1,102 @@ +package config + +import ( + "fmt" + + "github.com/spf13/cobra" + + adapterconfig "github.com/nylas/cli/internal/adapters/config" + "github.com/nylas/cli/internal/adapters/keyring" + 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" +) + +func newResetCmd() *cobra.Command { + var force bool + + cmd := &cobra.Command{ + Use: "reset", + Short: "Reset all CLI configuration and credentials", + Long: `Reset the Nylas CLI to a clean state by clearing all stored data: + + - API credentials (API key, client ID, client secret) + - Dashboard session (login tokens, selected app) + - Grants (authenticated email accounts) + - Config file (reset to defaults) + +After reset, run 'nylas init' to set up again. + +To reset only part of the CLI: + nylas auth config --reset Reset API credentials only + nylas dashboard logout Log out of Dashboard only`, + Example: ` # Reset with confirmation prompt + nylas config reset + + # Reset without confirmation + nylas config reset --force`, + RunE: func(cmd *cobra.Command, args []string) error { + if !force { + fmt.Println("This will remove all stored credentials, grants, and configuration.") + fmt.Println() + if !common.Confirm("Are you sure you want to reset the CLI?", false) { + fmt.Println("Reset cancelled.") + return nil + } + fmt.Println() + } + + secretStore, err := keyring.NewSecretStore(adapterconfig.DefaultConfigDir()) + if err != nil { + return fmt.Errorf("access secret store: %w", err) + } + + // 1. Clear API credentials + configSvc := authapp.NewConfigService(configStore, secretStore) + if err := configSvc.ResetConfig(); err != nil { + return fmt.Errorf("reset API config: %w", err) + } + _, _ = common.Green.Println(" ✓ API credentials cleared") + + // 2. Clear dashboard credentials + clearDashboardCredentials(secretStore) + _, _ = common.Green.Println(" ✓ Dashboard session cleared") + + // 3. Clear grants + grantStore := keyring.NewGrantStore(secretStore) + if err := grantStore.ClearGrants(); err != nil { + return fmt.Errorf("clear grants: %w", err) + } + _, _ = common.Green.Println(" ✓ Grants cleared") + + // 4. Reset config file to defaults + if err := configStore.Save(domain.DefaultConfig()); err != nil { + return fmt.Errorf("reset config file: %w", err) + } + _, _ = common.Green.Println(" ✓ Config file reset") + + fmt.Println() + _, _ = common.Green.Println("CLI has been reset.") + fmt.Println() + fmt.Println("Run 'nylas init' to set up again.") + + return nil + }, + } + + cmd.Flags().BoolVar(&force, "force", false, "Skip confirmation prompt") + + return cmd +} + +// clearDashboardCredentials removes all dashboard-related keys from the secret store. +func clearDashboardCredentials(secrets ports.SecretStore) { + _ = secrets.Delete(ports.KeyDashboardUserToken) + _ = secrets.Delete(ports.KeyDashboardOrgToken) + _ = secrets.Delete(ports.KeyDashboardUserPublicID) + _ = secrets.Delete(ports.KeyDashboardOrgPublicID) + _ = secrets.Delete(ports.KeyDashboardDPoPKey) + _ = secrets.Delete(ports.KeyDashboardAppID) + _ = secrets.Delete(ports.KeyDashboardAppRegion) +} diff --git a/internal/cli/config/reset_test.go b/internal/cli/config/reset_test.go new file mode 100644 index 0000000..595aaaa --- /dev/null +++ b/internal/cli/config/reset_test.go @@ -0,0 +1,65 @@ +package config + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestNewResetCmd(t *testing.T) { + t.Run("command name and flags", func(t *testing.T) { + cmd := newResetCmd() + + assert.Equal(t, "reset", cmd.Use) + assert.NotEmpty(t, cmd.Short) + assert.NotEmpty(t, cmd.Long) + assert.NotEmpty(t, cmd.Example) + + flag := cmd.Flags().Lookup("force") + require.NotNil(t, flag, "expected --force flag") + assert.Equal(t, "false", flag.DefValue) + }) +} + +func TestClearDashboardCredentials(t *testing.T) { + t.Run("clears all dashboard keys", func(t *testing.T) { + store := &memStore{data: map[string]string{ + "dashboard_user_token": "tok", + "dashboard_org_token": "org-tok", + "dashboard_user_public_id": "uid", + "dashboard_org_public_id": "oid", + "dashboard_dpop_key": "dpop", + "dashboard_app_id": "app", + "dashboard_app_region": "us", + "api_key": "keep-me", + }} + + clearDashboardCredentials(store) + + // Dashboard keys should be gone + assert.Empty(t, store.data["dashboard_user_token"]) + assert.Empty(t, store.data["dashboard_org_token"]) + assert.Empty(t, store.data["dashboard_user_public_id"]) + assert.Empty(t, store.data["dashboard_org_public_id"]) + assert.Empty(t, store.data["dashboard_dpop_key"]) + assert.Empty(t, store.data["dashboard_app_id"]) + assert.Empty(t, store.data["dashboard_app_region"]) + + // Non-dashboard keys should be untouched + assert.Equal(t, "keep-me", store.data["api_key"]) + }) +} + +// memStore is a minimal in-memory SecretStore for testing. +type memStore struct { + data map[string]string +} + +func (m *memStore) Set(key, value string) error { m.data[key] = value; return nil } +func (m *memStore) Get(key string) (string, error) { + return m.data[key], nil +} +func (m *memStore) Delete(key string) error { delete(m.data, key); return nil } +func (m *memStore) IsAvailable() bool { return true } +func (m *memStore) Name() string { return "mem" } From 96492204edd24e9424c2d6b104c2a74db0557a88 Mon Sep 17 00:00:00 2001 From: Aaron de Mello Date: Tue, 24 Mar 2026 13:37:59 -0400 Subject: [PATCH 09/20] feat(cli): redesign first-run welcome screen Replace the plain-text welcome message with a visually polished first-run experience using Unicode box-drawing characters, color hierarchy, and a capabilities overview. No new dependencies. Co-Authored-By: Claude Opus 4.6 (1M context) --- internal/cli/root.go | 66 ++++++++++++++++++++++++++++++++++++++------ 1 file changed, 58 insertions(+), 8 deletions(-) diff --git a/internal/cli/root.go b/internal/cli/root.go index 2b11687..1878511 100644 --- a/internal/cli/root.go +++ b/internal/cli/root.go @@ -83,22 +83,72 @@ Documentation: https://cli.nylas.com/`, // printWelcome displays the first-run welcome message. func printWelcome() { + // Banner fmt.Println() - _, _ = common.Bold.Println(" Welcome to the Nylas CLI!") + _, _ = common.Dim.Println(" ╭──────────────────────────────────────────╮") + _, _ = common.Dim.Println(" │ │") + fmt.Print(" ") + _, _ = common.Dim.Print("│") + fmt.Print(" ") + _, _ = common.BoldCyan.Print("◈ N Y L A S C L I") + fmt.Print(" ") + _, _ = common.Dim.Println("│") + _, _ = common.Dim.Println(" │ │") + fmt.Print(" ") + _, _ = common.Dim.Print("│") + fmt.Print(" Email, calendar, and contacts ") + _, _ = common.Dim.Println("│") + fmt.Print(" ") + _, _ = common.Dim.Print("│") + fmt.Print(" from your terminal. ") + _, _ = common.Dim.Println("│") + _, _ = common.Dim.Println(" │ │") + _, _ = common.Dim.Println(" ╰──────────────────────────────────────────╯") + + // Getting started fmt.Println() - fmt.Println(" Get started in under a minute:") + _, _ = common.Bold.Println(" Get started in under a minute:") fmt.Println() - _, _ = common.Cyan.Println(" nylas init Guided setup") - _, _ = common.Dim.Println(" nylas init --api-key Quick setup with existing key") - fmt.Println() - fmt.Println(" Already configured? Run:") + fmt.Print(" ") + _, _ = common.BoldCyan.Print("❯ nylas init") + fmt.Println(" Guided setup") + fmt.Print(" ") + _, _ = common.Dim.Println(" nylas init --api-key Quick setup with existing key") + + // Capabilities box fmt.Println() - fmt.Println(" nylas --help See all commands") + _, _ = common.Dim.Print(" ╭─") + _, _ = common.Bold.Print(" What you can do ") + _, _ = common.Dim.Println("────────────────────────╮") + _, _ = common.Dim.Println(" │ │") + printCapability("email", "Send, search, and read") + printCapability("calendar", "Events and availability") + printCapability("contacts", "People and groups") + printCapability("webhook", "Real-time notifications") + printCapability("ai", "Chat with your data") + _, _ = common.Dim.Println(" │ │") + _, _ = common.Dim.Println(" ╰──────────────────────────────────────────╯") + + // Footer fmt.Println() - _, _ = common.Dim.Println(" Docs: https://cli.nylas.com/") + fmt.Print(" ") + _, _ = common.Dim.Print("nylas --help") + fmt.Println(" All commands") + fmt.Print(" ") + _, _ = common.Dim.Println("https://cli.nylas.com Documentation") fmt.Println() } +// printCapability prints a single capability row inside the box. +func printCapability(name, desc string) { + fmt.Print(" ") + _, _ = common.Dim.Print("│") + fmt.Print(" ") + _, _ = common.Cyan.Printf("%-12s", name) + fmt.Printf("%-28s", desc) + _, _ = common.Dim.Println("│") +} + func init() { // Global output flags (format, json, quiet, wide, no-color) rootCmd.PersistentFlags().String("format", "", "Output format: table, json, yaml") From 3e19ffffed6584eac83368027bf0cd902165e5d3 Mon Sep 17 00:00:00 2001 From: Aaron de Mello Date: Tue, 24 Mar 2026 13:39:17 -0400 Subject: [PATCH 10/20] fix: gofmt alignment in test files Co-Authored-By: Claude Opus 4.6 (1M context) --- internal/app/auth/config_test.go | 2 +- internal/cli/config/reset_test.go | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/internal/app/auth/config_test.go b/internal/app/auth/config_test.go index 503c560..a504d5d 100644 --- a/internal/app/auth/config_test.go +++ b/internal/app/auth/config_test.go @@ -26,7 +26,7 @@ func (m *mockSecretStore) Get(key string) (string, error) { } func (m *mockSecretStore) Delete(key string) error { delete(m.data, key); return nil } func (m *mockSecretStore) IsAvailable() bool { return true } -func (m *mockSecretStore) Name() string { return "mock" } +func (m *mockSecretStore) Name() string { return "mock" } func TestConfigService_ResetConfig(t *testing.T) { t.Run("clears only API credentials", func(t *testing.T) { diff --git a/internal/cli/config/reset_test.go b/internal/cli/config/reset_test.go index 595aaaa..08b42f9 100644 --- a/internal/cli/config/reset_test.go +++ b/internal/cli/config/reset_test.go @@ -62,4 +62,4 @@ func (m *memStore) Get(key string) (string, error) { } func (m *memStore) Delete(key string) error { delete(m.data, key); return nil } func (m *memStore) IsAvailable() bool { return true } -func (m *memStore) Name() string { return "mem" } +func (m *memStore) Name() string { return "mem" } From aef99f6987404617b0f66b45b523c518bd24d77f Mon Sep 17 00:00:00 2001 From: Aaron de Mello Date: Tue, 24 Mar 2026 15:01:27 -0400 Subject: [PATCH 11/20] fix(dashboard): disable HTTP redirect following for DPoP-protected requests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit DPoP proofs are bound to the request URL via the htu claim. When the server redirects (e.g., staging → production), Go's http.Client silently follows the redirect but the DPoP proof still contains the original URL, causing "Invalid DPoP htu" errors on the destination server. Fix by disabling automatic redirect following and returning a clear error message when a redirect is received. Co-Authored-By: Claude Opus 4.6 (1M context) --- internal/adapters/dashboard/account_client.go | 2 +- internal/adapters/dashboard/gateway_client.go | 7 ++++++- internal/adapters/dashboard/http.go | 16 ++++++++++++++++ 3 files changed, 23 insertions(+), 2 deletions(-) diff --git a/internal/adapters/dashboard/account_client.go b/internal/adapters/dashboard/account_client.go index e362747..0eeeb47 100644 --- a/internal/adapters/dashboard/account_client.go +++ b/internal/adapters/dashboard/account_client.go @@ -24,7 +24,7 @@ type AccountClient struct { func NewAccountClient(baseURL string, dpop ports.DPoP) *AccountClient { return &AccountClient{ baseURL: baseURL, - httpClient: &http.Client{}, + httpClient: newNonRedirectClient(), dpop: dpop, } } diff --git a/internal/adapters/dashboard/gateway_client.go b/internal/adapters/dashboard/gateway_client.go index 7c7cc9c..b4331d3 100644 --- a/internal/adapters/dashboard/gateway_client.go +++ b/internal/adapters/dashboard/gateway_client.go @@ -23,7 +23,7 @@ type GatewayClient struct { // NewGatewayClient creates a new dashboard gateway GraphQL client. func NewGatewayClient(dpop ports.DPoP) *GatewayClient { return &GatewayClient{ - httpClient: &http.Client{}, + httpClient: newNonRedirectClient(), dpop: dpop, } } @@ -253,6 +253,11 @@ func (c *GatewayClient) doGraphQL(ctx context.Context, url, query string, variab return nil, fmt.Errorf("failed to read response: %w", err) } + if resp.StatusCode >= 300 && resp.StatusCode < 400 { + location := resp.Header.Get("Location") + return nil, fmt.Errorf("server redirected to %s — the gateway URL may be incorrect", location) + } + if resp.StatusCode < 200 || resp.StatusCode >= 300 { return nil, parseErrorResponse(resp.StatusCode, respBody) } diff --git a/internal/adapters/dashboard/http.go b/internal/adapters/dashboard/http.go index 82bb8a6..ae9917d 100644 --- a/internal/adapters/dashboard/http.go +++ b/internal/adapters/dashboard/http.go @@ -13,6 +13,17 @@ const ( maxResponseBody = 1 << 20 // 1 MB ) +// newNonRedirectClient creates an HTTP client that does not follow redirects. +// DPoP proofs are bound to a specific URL (the htu claim), so following a +// redirect would cause the proof to be invalid at the destination. +func newNonRedirectClient() *http.Client { + return &http.Client{ + CheckRedirect: func(req *http.Request, via []*http.Request) error { + return http.ErrUseLastResponse + }, + } +} + // doPost sends a JSON POST request and decodes the response into result. // The server wraps responses in {"request_id","success","data":{...}}. // This method unwraps the data field before decoding into result. @@ -80,6 +91,11 @@ func (c *AccountClient) doPostRaw(ctx context.Context, path string, body any, ex return nil, fmt.Errorf("failed to read response: %w", err) } + if resp.StatusCode >= 300 && resp.StatusCode < 400 { + location := resp.Header.Get("Location") + return nil, fmt.Errorf("server redirected to %s — the dashboard URL may be incorrect (set NYLAS_DASHBOARD_ACCOUNT_URL)", location) + } + if resp.StatusCode < 200 || resp.StatusCode >= 300 { return nil, parseErrorResponse(resp.StatusCode, respBody) } From d4faf3c18c4aa94a4c5727c7be409aeac0f21666 Mon Sep 17 00:00:00 2001 From: Aaron de Mello Date: Tue, 24 Mar 2026 15:45:05 -0400 Subject: [PATCH 12/20] fix(dashboard): fix four bugs in setup wizard and SSO flow MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. Wizard reports success on failure: steps 2 (application) and 3 (API key) now return errors instead of silently continuing to "Setup complete". 2. SSO flow ignores --org flag: orgPublicID is now threaded through runSSO → pollSSO → SSOPoll and CompleteMFA so multi-org users can target the correct organization. 3. Dashboard logout leaves stale app selection: clearTokens() now also deletes dashboard_app_id and dashboard_app_region to prevent reusing a previous account's app context after re-login. 4. Wizard default grant not persisted to config.yaml: the user's grant selection is now written back to result.DefaultGrantID so updateConfigGrants persists it correctly. Co-Authored-By: Claude Opus 4.6 (1M context) --- internal/app/dashboard/auth_service.go | 5 ++++- internal/cli/dashboard/exports.go | 2 +- internal/cli/dashboard/login.go | 2 +- internal/cli/dashboard/sso.go | 19 ++++++++++++++----- internal/cli/setup/wizard.go | 3 +++ 5 files changed, 23 insertions(+), 8 deletions(-) diff --git a/internal/app/dashboard/auth_service.go b/internal/app/dashboard/auth_service.go index fc229a0..631785b 100644 --- a/internal/app/dashboard/auth_service.go +++ b/internal/app/dashboard/auth_service.go @@ -191,12 +191,15 @@ func (s *AuthService) SetActiveOrg(orgPublicID string) error { return s.secrets.Set(ports.KeyDashboardOrgPublicID, orgPublicID) } -// clearTokens removes all dashboard auth data from the keyring. +// clearTokens removes all dashboard auth data from the keyring, +// including the active app selection to prevent stale state after re-login. func (s *AuthService) clearTokens() { _ = s.secrets.Delete(ports.KeyDashboardUserToken) _ = s.secrets.Delete(ports.KeyDashboardOrgToken) _ = s.secrets.Delete(ports.KeyDashboardUserPublicID) _ = s.secrets.Delete(ports.KeyDashboardOrgPublicID) + _ = s.secrets.Delete(ports.KeyDashboardAppID) + _ = s.secrets.Delete(ports.KeyDashboardAppRegion) } // loadTokens retrieves the stored tokens. diff --git a/internal/cli/dashboard/exports.go b/internal/cli/dashboard/exports.go index e7052e9..92738a8 100644 --- a/internal/cli/dashboard/exports.go +++ b/internal/cli/dashboard/exports.go @@ -17,7 +17,7 @@ func CreateAppService() (*dashboardapp.AppService, error) { // RunSSO executes the SSO device-code flow (exported for setup wizard). func RunSSO(provider, mode string, privacyAccepted bool) error { - return runSSO(provider, mode, privacyAccepted) + return runSSO(provider, mode, privacyAccepted, "") } // AcceptPrivacyPolicy prompts for privacy policy acceptance (exported for setup wizard). diff --git a/internal/cli/dashboard/login.go b/internal/cli/dashboard/login.go index f00ec9a..43df717 100644 --- a/internal/cli/dashboard/login.go +++ b/internal/cli/dashboard/login.go @@ -43,7 +43,7 @@ Choose SSO (recommended) or email/password. Pass a flag to skip the menu.`, switch method { case methodGoogle, methodMicrosoft, methodGitHub: - return runSSO(method, "login", false) + return runSSO(method, "login", false, orgPublicID) case methodEmailPassword: return runEmailLogin(userFlag, passFlag, orgPublicID) default: diff --git a/internal/cli/dashboard/sso.go b/internal/cli/dashboard/sso.go index b10400a..b544b15 100644 --- a/internal/cli/dashboard/sso.go +++ b/internal/cli/dashboard/sso.go @@ -66,7 +66,12 @@ func newSSORegisterCmd() *cobra.Command { return cmd } -func runSSO(provider, mode string, privacyPolicyAccepted bool) error { +func runSSO(provider, mode string, privacyPolicyAccepted bool, orgPublicIDs ...string) error { + orgPublicID := "" + if len(orgPublicIDs) > 0 { + orgPublicID = orgPublicIDs[0] + } + loginType, err := mapProvider(provider) if err != nil { return err @@ -115,7 +120,7 @@ func runSSO(provider, mode string, privacyPolicyAccepted bool) error { interval = 5 * time.Second } - auth, err := pollSSO(ctx, authSvc, resp.FlowID, interval) + auth, err := pollSSO(ctx, authSvc, resp.FlowID, orgPublicID, interval) if err != nil { return wrapDashboardError(err) } @@ -124,7 +129,7 @@ func runSSO(provider, mode string, privacyPolicyAccepted bool) error { return nil } -func pollSSO(ctx context.Context, authSvc *dashboardapp.AuthService, flowID string, interval time.Duration) (*domain.DashboardAuthResponse, error) { +func pollSSO(ctx context.Context, authSvc *dashboardapp.AuthService, flowID, orgPublicID string, interval time.Duration) (*domain.DashboardAuthResponse, error) { spinner := common.NewSpinner("Waiting for browser authentication...") spinner.Start() defer spinner.Stop() @@ -137,7 +142,7 @@ func pollSSO(ctx context.Context, authSvc *dashboardapp.AuthService, flowID stri case <-time.After(interval): } - resp, err := authSvc.SSOPoll(ctx, flowID, "") + resp, err := authSvc.SSOPoll(ctx, flowID, orgPublicID) if err != nil { spinner.StopWithError("Failed") return nil, err @@ -163,8 +168,12 @@ func pollSSO(ctx context.Context, authSvc *dashboardapp.AuthService, flowID stri ctx2, cancel := common.CreateContext() var auth *domain.DashboardAuthResponse + mfaOrg := orgPublicID + if mfaOrg == "" && len(resp.MFA.Organizations) > 0 { + mfaOrg = resp.MFA.Organizations[0].PublicID + } mfaErr := common.RunWithSpinner("Verifying MFA...", func() error { - auth, err = authSvc.CompleteMFA(ctx2, resp.MFA.User.PublicID, code, "") + auth, err = authSvc.CompleteMFA(ctx2, resp.MFA.User.PublicID, code, mfaOrg) return err }) cancel() diff --git a/internal/cli/setup/wizard.go b/internal/cli/setup/wizard.go index 68f719b..948dc8d 100644 --- a/internal/cli/setup/wizard.go +++ b/internal/cli/setup/wizard.go @@ -70,6 +70,7 @@ func runWizard(opts wizardOpts) error { "nylas dashboard apps list", "nylas dashboard apps create --name 'My App' --region us", }) + return fmt.Errorf("application setup failed: %w", err) } // Step 3: API Key @@ -77,6 +78,7 @@ func runWizard(opts wizardOpts) error { printStepRecovery("API key", []string{ "nylas dashboard apps apikeys create", }) + return fmt.Errorf("API key setup failed: %w", err) } // Step 4: Grants @@ -443,6 +445,7 @@ func stepGrantSync(status *SetupStatus) { // Multiple grants, prompt. defaultID, _ := PromptDefaultGrant(grantStore, result.ValidGrants) if defaultID != "" { + result.DefaultGrantID = defaultID for _, g := range result.ValidGrants { if g.ID == defaultID { _, _ = common.Green.Printf(" ✓ Set %s as default account\n", g.Email) From 66768006391cb9fa133c06161f425b053bbf7086 Mon Sep 17 00:00:00 2001 From: Aaron de Mello Date: Tue, 24 Mar 2026 17:04:16 -0400 Subject: [PATCH 13/20] fix(dashboard): never print client secret to stdout Client secret was printed via fmt.Printf on app creation, leaking it to terminal scrollback, CI logs, and screen captures. Apply the same secure delivery pattern used for API keys: clipboard or temp file with 0600 permissions. The secret is never written to stdout. Co-Authored-By: Claude Opus 4.6 (1M context) --- internal/cli/dashboard/apps.go | 8 +++++-- internal/cli/dashboard/keys.go | 38 ++++++++++++++++++++++++++++++++++ 2 files changed, 44 insertions(+), 2 deletions(-) diff --git a/internal/cli/dashboard/apps.go b/internal/cli/dashboard/apps.go index 1e4d168..5313c7f 100644 --- a/internal/cli/dashboard/apps.go +++ b/internal/cli/dashboard/apps.go @@ -141,8 +141,12 @@ func newAppsCreateCmd() *cobra.Command { fmt.Printf(" Environment: %s\n", app.Environment) } - _, _ = common.Yellow.Println("\n Client Secret (shown once — save it now):") - fmt.Printf(" %s\n", app.ClientSecret) + if app.ClientSecret != "" { + _, _ = common.Yellow.Println("\n Client Secret (available once — save it now):") + if err := handleSecretDelivery(app.ClientSecret, "Client Secret"); err != nil { + return err + } + } fmt.Println("\nTo configure the CLI with this application:") fmt.Printf(" nylas auth config --api-key --region %s\n", app.Region) diff --git a/internal/cli/dashboard/keys.go b/internal/cli/dashboard/keys.go index bad694d..2b0fdc3 100644 --- a/internal/cli/dashboard/keys.go +++ b/internal/cli/dashboard/keys.go @@ -226,6 +226,44 @@ func handleAPIKeyDelivery(apiKey, appID, region string) error { return nil } +// handleSecretDelivery prompts the user to choose how to receive a secret. +// Secrets are never printed to stdout to prevent leaking in terminal history or logs. +func handleSecretDelivery(secret, label string) error { + fmt.Printf("\nHow would you like to receive the %s?\n", label) + fmt.Println() + _, _ = common.Cyan.Println(" [1] Copy to clipboard (recommended)") + fmt.Println(" [2] Save to file") + fmt.Println() + + choice, err := readLine("Choose [1-2]: ") + if err != nil { + return wrapDashboardError(err) + } + + switch choice { + case "1", "": + if err := common.CopyToClipboard(secret); err != nil { + _, _ = common.Yellow.Printf(" Clipboard unavailable: %v\n", err) + _, _ = common.Dim.Println(" Try option [2] to save to a file instead") + return nil + } + _, _ = common.Green.Printf("✓ %s copied to clipboard\n", label) + + case "2": + keyFile := filepath.Join(os.TempDir(), "nylas-client-secret.txt") + if err := os.WriteFile(keyFile, []byte(secret+"\n"), 0o600); err != nil { // #nosec G306 + return wrapDashboardError(fmt.Errorf("failed to write file: %w", err)) + } + _, _ = common.Green.Printf("✓ %s saved to: %s\n", label, keyFile) + _, _ = common.Dim.Println(" Read it, then delete the file") + + default: + return dashboardError("invalid selection", "Choose 1-2") + } + + return nil +} + // activateAPIKey stores the API key and configures the CLI to use it. func activateAPIKey(apiKey, clientID, region string) error { configStore := config.NewDefaultFileStore() From 9b3354bd804b8afe966c9051eb7dd4cb6044c44f Mon Sep 17 00:00:00 2001 From: Aaron de Mello Date: Tue, 24 Mar 2026 22:25:35 -0400 Subject: [PATCH 14/20] feat(cli): add interactive arrow-key prompts with charmbracelet/huh MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace all numbered-menu prompts with huh Select widgets that support arrow-key navigation, and replace raw readLine/readPassword calls with huh Input/Password fields. Converted prompts: - Account path selection (wizard) - SSO provider selection (wizard + dashboard) - Application selection (wizard) - Region selection (wizard) - Auth method selection (dashboard login/register) - Organization selection (dashboard) - API key delivery (dashboard) - Client secret delivery (dashboard) - Default grant selection (setup) - Privacy policy confirmation (dashboard) - Email/password/MFA inputs (dashboard login) New shared helpers in common/prompt.go: - Select[T]() — generic arrow-key select menu - ConfirmPrompt() — yes/no with arrow keys - InputPrompt() — text input with placeholder - PasswordPrompt() — masked input All prompts fall back gracefully in non-TTY environments. Co-Authored-By: Claude Opus 4.6 (1M context) --- go.mod | 21 ++++++ go.sum | 44 ++++++++++++ internal/cli/common/prompt.go | 101 +++++++++++++++++++++++++++ internal/cli/dashboard/exports.go | 3 +- internal/cli/dashboard/helpers.go | 92 +++++------------------- internal/cli/dashboard/keys.go | 84 +++++++++++----------- internal/cli/dashboard/login.go | 6 +- internal/cli/dashboard/sso.go | 2 +- internal/cli/setup/grants.go | 39 +++-------- internal/cli/setup/wizard.go | 43 +++--------- internal/cli/setup/wizard_helpers.go | 57 +++++---------- 11 files changed, 270 insertions(+), 222 deletions(-) create mode 100644 internal/cli/common/prompt.go diff --git a/go.mod b/go.mod index f9ca83e..c5451cf 100644 --- a/go.mod +++ b/go.mod @@ -22,8 +22,21 @@ require ( require ( al.essio.dev/pkg/shellescape v1.6.0 // indirect + github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect + github.com/catppuccin/go v0.3.0 // indirect + github.com/charmbracelet/bubbles v0.21.1-0.20250623103423-23b8fd6302d7 // indirect + github.com/charmbracelet/bubbletea v1.3.6 // indirect + github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect + github.com/charmbracelet/huh v1.0.0 // indirect + github.com/charmbracelet/lipgloss v1.1.0 // indirect + github.com/charmbracelet/x/ansi v0.9.3 // indirect + github.com/charmbracelet/x/cellbuf v0.0.13 // indirect + github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0 // indirect + github.com/charmbracelet/x/term v0.2.1 // indirect github.com/danieljoos/wincred v1.2.3 // indirect github.com/davecgh/go-spew v1.1.1 // indirect + github.com/dustin/go-humanize v1.0.1 // indirect + github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect github.com/gdamore/encoding v1.0.1 // indirect github.com/godbus/dbus/v5 v5.2.1 // indirect github.com/gorilla/websocket v1.5.3 // indirect @@ -31,12 +44,20 @@ require ( github.com/lucasb-eyer/go-colorful v1.3.0 // indirect github.com/mattn/go-colorable v0.1.14 // indirect github.com/mattn/go-isatty v0.0.20 // indirect + github.com/mattn/go-localereader v0.0.1 // indirect + github.com/mattn/go-runewidth v0.0.16 // indirect + github.com/mitchellh/hashstructure/v2 v2.0.2 // indirect + github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect + github.com/muesli/cancelreader v0.2.2 // indirect + github.com/muesli/termenv v0.16.0 // indirect github.com/ncruces/julianday v1.0.0 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect github.com/rivo/uniseg v0.4.7 // indirect github.com/spf13/pflag v1.0.5 // indirect github.com/tetratelabs/wazero v1.11.0 // indirect + github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect golang.org/x/crypto v0.46.0 // indirect + golang.org/x/sync v0.19.0 // indirect golang.org/x/sys v0.39.0 // indirect lukechampine.com/adiantum v1.1.1 // indirect ) diff --git a/go.sum b/go.sum index 36f2d59..ab25656 100644 --- a/go.sum +++ b/go.sum @@ -2,11 +2,37 @@ al.essio.dev/pkg/shellescape v1.6.0 h1:NxFcEqzFSEVCGN2yq7Huv/9hyCEGVa/TncnOOBBeX al.essio.dev/pkg/shellescape v1.6.0/go.mod h1:6sIqp7X2P6mThCQ7twERpZTuigpr6KbZWtls1U8I890= github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4= github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI= +github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= +github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= +github.com/catppuccin/go v0.3.0 h1:d+0/YicIq+hSTo5oPuRi5kOpqkVA5tAsU6dNhvRu+aY= +github.com/catppuccin/go v0.3.0/go.mod h1:8IHJuMGaUUjQM82qBrGNBv7LFq6JI3NnQCF6MOlZjpc= +github.com/charmbracelet/bubbles v0.21.1-0.20250623103423-23b8fd6302d7 h1:JFgG/xnwFfbezlUnFMJy0nusZvytYysV4SCS2cYbvws= +github.com/charmbracelet/bubbles v0.21.1-0.20250623103423-23b8fd6302d7/go.mod h1:ISC1gtLcVilLOf23wvTfoQuYbW2q0JevFxPfUzZ9Ybw= +github.com/charmbracelet/bubbletea v1.3.6 h1:VkHIxPJQeDt0aFJIsVxw8BQdh/F/L2KKZGsK6et5taU= +github.com/charmbracelet/bubbletea v1.3.6/go.mod h1:oQD9VCRQFF8KplacJLo28/jofOI2ToOfGYeFgBBxHOc= +github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc h1:4pZI35227imm7yK2bGPcfpFEmuY1gc2YSTShr4iJBfs= +github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc/go.mod h1:X4/0JoqgTIPSFcRA/P6INZzIuyqdFY5rm8tb41s9okk= +github.com/charmbracelet/huh v1.0.0 h1:wOnedH8G4qzJbmhftTqrpppyqHakl/zbbNdXIWJyIxw= +github.com/charmbracelet/huh v1.0.0/go.mod h1:5YVc+SlZ1IhQALxRPpkGwwEKftN/+OlJlnJYlDRFqN4= +github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY= +github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30= +github.com/charmbracelet/x/ansi v0.9.3 h1:BXt5DHS/MKF+LjuK4huWrC6NCvHtexww7dMayh6GXd0= +github.com/charmbracelet/x/ansi v0.9.3/go.mod h1:3RQDQ6lDnROptfpWuUVIUG64bD2g2BgntdxH0Ya5TeE= +github.com/charmbracelet/x/cellbuf v0.0.13 h1:/KBBKHuVRbq1lYx5BzEHBAFBP8VcQzJejZ/IA3iR28k= +github.com/charmbracelet/x/cellbuf v0.0.13/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs= +github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0 h1:qko3AQ4gK1MTS/de7F5hPGx6/k1u0w4TeYmBFwzYVP4= +github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0/go.mod h1:pBhA0ybfXv6hDjQUZ7hk1lVxBiUbupdw5R31yPUViVQ= +github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ= +github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg= github.com/cpuguy83/go-md2man/v2 v2.0.4/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= github.com/danieljoos/wincred v1.2.3 h1:v7dZC2x32Ut3nEfRH+vhoZGvN72+dQ/snVXo/vMFLdQ= github.com/danieljoos/wincred v1.2.3/go.mod h1:6qqX0WNrS4RzPZ1tnroDzq9kY3fu1KwE7MRLQK4X0bs= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= +github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= +github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4= +github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM= github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM= github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU= github.com/gdamore/encoding v1.0.1 h1:YzKZckdBL6jVt2Gc+5p82qhrGiqMdG/eNs6Wy0u3Uhw= @@ -31,6 +57,18 @@ github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHP github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4= +github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88= +github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc= +github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= +github.com/mitchellh/hashstructure/v2 v2.0.2 h1:vGKWl0YJqUNxE8d+h8f6NJLcCJrgbhC4NcD46KavDd4= +github.com/mitchellh/hashstructure/v2 v2.0.2/go.mod h1:MG3aRVU/N29oo/V/IhBX8GR/zz4kQkprJgF2EVszyDE= +github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI= +github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo= +github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= +github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= +github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc= +github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk= github.com/ncruces/go-sqlite3 v0.30.4 h1:j9hEoOL7f9ZoXl8uqXVniaq1VNwlWAXihZbTvhqPPjA= github.com/ncruces/go-sqlite3 v0.30.4/go.mod h1:7WR20VSC5IZusKhUdiR9y1NsUqnZgqIYCmKKoMEYg68= github.com/ncruces/julianday v1.0.0 h1:fH0OKwa7NWvniGQtxdJRxAgkBMolni2BjDHaWTxqt7M= @@ -39,6 +77,7 @@ github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZb github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/rivo/tview v0.42.0 h1:b/ftp+RxtDsHSaynXTbJb+/n/BxDEi+W3UfF5jILK6c= github.com/rivo/tview v0.42.0/go.mod h1:cSfIYfhpSGCjp3r/ECJb+GKS7cGJnqV8vfjQPwoXyfY= +github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= @@ -54,6 +93,8 @@ github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/tetratelabs/wazero v1.11.0 h1:+gKemEuKCTevU4d7ZTzlsvgd1uaToIDtlQlmNbwqYhA= github.com/tetratelabs/wazero v1.11.0/go.mod h1:eV28rsN8Q+xwjogd7f4/Pp4xFxO7uOGbLcD/LzB1wiU= +github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= +github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= github.com/zalando/go-keyring v0.2.6 h1:r7Yc3+H+Ux0+M72zacZoItR3UDxeWfKTcabvkI8ua9s= github.com/zalando/go-keyring v0.2.6/go.mod h1:2TCrxYrbUNYfNS/Kgy/LSrkSQzZ5UPVH85RwfczwvcI= @@ -72,9 +113,12 @@ golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= +golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= diff --git a/internal/cli/common/prompt.go b/internal/cli/common/prompt.go new file mode 100644 index 0000000..8808001 --- /dev/null +++ b/internal/cli/common/prompt.go @@ -0,0 +1,101 @@ +// Package common provides shared CLI utilities. +package common + +import ( + "os" + + "github.com/charmbracelet/huh" + "golang.org/x/term" +) + +// SelectOption represents a labeled option for Select prompts. +type SelectOption[T comparable] struct { + Label string + Value T +} + +// Select presents an interactive select menu with arrow-key navigation. +// Falls back to the first option if stdin is not a TTY. +func Select[T comparable](title string, options []SelectOption[T]) (T, error) { + if len(options) == 0 { + var zero T + return zero, nil + } + + // Non-interactive fallback + if !term.IsTerminal(int(os.Stdin.Fd())) { + return options[0].Value, nil + } + + var result T + huhOpts := make([]huh.Option[T], len(options)) + for i, opt := range options { + huhOpts[i] = huh.NewOption(opt.Label, opt.Value) + } + + err := huh.NewSelect[T](). + Title(title). + Options(huhOpts...). + Value(&result). + Run() + + return result, err +} + +// ConfirmPrompt presents an interactive yes/no confirmation with arrow-key navigation. +func ConfirmPrompt(title string, defaultYes bool) (bool, error) { + if !term.IsTerminal(int(os.Stdin.Fd())) { + return defaultYes, nil + } + + result := defaultYes + err := huh.NewConfirm(). + Title(title). + Affirmative("Yes"). + Negative("No"). + Value(&result). + Run() + + return result, err +} + +// InputPrompt presents an interactive text input. +func InputPrompt(title, placeholder string) (string, error) { + if !term.IsTerminal(int(os.Stdin.Fd())) { + return placeholder, nil + } + + var result string + field := huh.NewInput(). + Title(title). + Value(&result) + + if placeholder != "" { + field.Placeholder(placeholder) + } + + err := field.Run() + if err != nil { + return "", err + } + if result == "" && placeholder != "" { + return placeholder, nil + } + return result, nil +} + +// PasswordPrompt presents an interactive masked password input. +func PasswordPrompt(title string) (string, error) { + if !term.IsTerminal(int(os.Stdin.Fd())) { + return "", nil + } + + var result string + err := huh.NewInput(). + Title(title). + EchoMode(huh.EchoModePassword). + Value(&result). + Run() + + return result, err +} diff --git a/internal/cli/dashboard/exports.go b/internal/cli/dashboard/exports.go index 92738a8..e212c67 100644 --- a/internal/cli/dashboard/exports.go +++ b/internal/cli/dashboard/exports.go @@ -2,6 +2,7 @@ package dashboard import ( dashboardapp "github.com/nylas/cli/internal/app/dashboard" + "github.com/nylas/cli/internal/cli/common" "github.com/nylas/cli/internal/ports" ) @@ -37,5 +38,5 @@ func GetActiveOrgID() (string, error) { // ReadLine prompts for a line of text input (exported for setup wizard). func ReadLine(prompt string) (string, error) { - return readLine(prompt) + return common.InputPrompt(prompt, "") } diff --git a/internal/cli/dashboard/helpers.go b/internal/cli/dashboard/helpers.go index 4552daa..64efea6 100644 --- a/internal/cli/dashboard/helpers.go +++ b/internal/cli/dashboard/helpers.go @@ -1,13 +1,9 @@ package dashboard import ( - "bufio" "errors" "fmt" "os" - "strings" - - "golang.org/x/term" "github.com/nylas/cli/internal/adapters/config" "github.com/nylas/cli/internal/adapters/dashboard" @@ -72,28 +68,6 @@ func getDashboardAccountBaseURL(secrets ports.SecretStore) string { return domain.DefaultDashboardAccountBaseURL } -// readPassword prompts for a password without terminal echo. -func readPassword(prompt string) (string, error) { - fmt.Print(prompt) - pwBytes, err := term.ReadPassword(int(os.Stdin.Fd())) - fmt.Println() - if err != nil { - return "", fmt.Errorf("failed to read input: %w", err) - } - return strings.TrimSpace(string(pwBytes)), nil -} - -// readLine prompts for a line of text input. -func readLine(prompt string) (string, error) { - fmt.Print(prompt) - reader := bufio.NewReader(os.Stdin) - input, err := reader.ReadString('\n') - if err != nil { - return "", fmt.Errorf("failed to read input: %w", err) - } - return strings.TrimSpace(input), nil -} - // wrapDashboardError wraps a dashboard error as a CLIError, preserving // the actual error message. func wrapDashboardError(err error) error { @@ -167,42 +141,16 @@ func resolveAuthMethod(google, microsoft, github, email bool, action string) (st // chooseAuthMethod presents an interactive menu. SSO first. // Email/password registration is temporarily disabled. func chooseAuthMethod(action string) (string, error) { - allowEmail := action != "register" - - fmt.Printf("\nHow would you like to %s?\n\n", action) - _, _ = common.Cyan.Println(" [1] Google (recommended)") - fmt.Println(" [2] Microsoft") - fmt.Println(" [3] GitHub") - if allowEmail { - _, _ = common.Dim.Println(" [4] Email and password") + opts := []common.SelectOption[string]{ + {Label: "Google (recommended)", Value: methodGoogle}, + {Label: "Microsoft", Value: methodMicrosoft}, + {Label: "GitHub", Value: methodGitHub}, } - fmt.Println() - - maxChoice := "3" - if allowEmail { - maxChoice = "4" + if action != "register" { + opts = append(opts, common.SelectOption[string]{Label: "Email and password", Value: methodEmailPassword}) } - choice, err := readLine(fmt.Sprintf("Choose [1-%s]: ", maxChoice)) - if err != nil { - return "", err - } - - switch strings.TrimSpace(choice) { - case "1", "": - return methodGoogle, nil - case "2": - return methodMicrosoft, nil - case "3": - return methodGitHub, nil - case "4": - if allowEmail { - return methodEmailPassword, nil - } - return "", dashboardError("invalid selection", "Choose 1-3") - default: - return "", dashboardError("invalid selection", fmt.Sprintf("Choose 1-%s", maxChoice)) - } + return common.Select(fmt.Sprintf("How would you like to %s?", action), opts) } // selectOrg prompts the user to select an organization if multiple are available. @@ -214,26 +162,20 @@ func selectOrg(orgs []domain.DashboardOrganization) string { return "" } - fmt.Println("\nAvailable organizations:") + opts := make([]common.SelectOption[string], len(orgs)) for i, org := range orgs { - name := org.Name - if name == "" { - name = org.PublicID + label := org.Name + if label == "" { + label = org.PublicID } - fmt.Printf(" [%d] %s\n", i+1, name) + opts[i] = common.SelectOption[string]{Label: label, Value: org.PublicID} } - fmt.Println() - choice, err := readLine(fmt.Sprintf("Select organization [1-%d]: ", len(orgs))) + selected, err := common.Select("Select organization", opts) if err != nil { return orgs[0].PublicID } - - var selected int - if _, err := fmt.Sscanf(choice, "%d", &selected); err != nil || selected < 1 || selected > len(orgs) { - return orgs[0].PublicID - } - return orgs[selected-1].PublicID + return selected } // printAuthSuccess prints the standard post-login success message. @@ -246,7 +188,11 @@ func printAuthSuccess(auth *domain.DashboardAuthResponse) { // acceptPrivacyPolicy prompts for or validates privacy policy acceptance. func acceptPrivacyPolicy() error { - if !common.Confirm("Accept Nylas Privacy Policy?", true) { + accepted, err := common.ConfirmPrompt("Accept Nylas Privacy Policy?", true) + if err != nil { + return err + } + if !accepted { return dashboardError("privacy policy must be accepted to continue", "") } return nil diff --git a/internal/cli/dashboard/keys.go b/internal/cli/dashboard/keys.go index 2b0fdc3..9fb346b 100644 --- a/internal/cli/dashboard/keys.go +++ b/internal/cli/dashboard/keys.go @@ -182,20 +182,24 @@ Set an active app with: nylas dashboard apps use --region `, // handleAPIKeyDelivery prompts the user to choose how to handle the newly created key. // The API key is never printed to stdout to prevent leaking it in terminal history or logs. func handleAPIKeyDelivery(apiKey, appID, region string) error { - fmt.Println("\nWhat would you like to do with this API key?") - fmt.Println() - _, _ = common.Cyan.Println(" [1] Activate for this CLI (recommended)") - fmt.Println(" [2] Copy to clipboard") - fmt.Println(" [3] Save to file") - fmt.Println() - - choice, err := readLine("Choose [1-3]: ") + type deliveryChoice string + const ( + choiceActivate deliveryChoice = "activate" + choiceClipboard deliveryChoice = "clipboard" + choiceFile deliveryChoice = "file" + ) + + choice, err := common.Select("What would you like to do with this API key?", []common.SelectOption[deliveryChoice]{ + {Label: "Activate for this CLI (recommended)", Value: choiceActivate}, + {Label: "Copy to clipboard", Value: choiceClipboard}, + {Label: "Save to file", Value: choiceFile}, + }) if err != nil { return wrapDashboardError(err) } switch choice { - case "1", "": + case choiceActivate: if err := activateAPIKey(apiKey, appID, region); err != nil { _, _ = common.Yellow.Printf(" Could not activate: %v\n", err) return nil @@ -203,24 +207,16 @@ func handleAPIKeyDelivery(apiKey, appID, region string) error { _, _ = common.Green.Println("✓ API key activated — CLI is ready to use") _, _ = common.Dim.Println(" Try: nylas auth status") - case "2": + case choiceClipboard: if err := common.CopyToClipboard(apiKey); err != nil { _, _ = common.Yellow.Printf(" Clipboard unavailable: %v\n", err) - _, _ = common.Dim.Println(" Try option [3] to save to a file instead") - return nil + _, _ = common.Dim.Println(" Falling back to file save") + return saveSecretToFile(apiKey, "nylas-api-key.txt", "API key") } _, _ = common.Green.Println("✓ API key copied to clipboard") - case "3": - keyFile := filepath.Join(os.TempDir(), "nylas-api-key.txt") - if err := os.WriteFile(keyFile, []byte(apiKey+"\n"), 0o600); err != nil { // #nosec G306 - return wrapDashboardError(fmt.Errorf("failed to write key file: %w", err)) - } - _, _ = common.Green.Printf("✓ API key saved to: %s\n", keyFile) - _, _ = common.Dim.Println(" Read it, then delete the file") - - default: - return dashboardError("invalid selection", "Choose 1-3") + case choiceFile: + return saveSecretToFile(apiKey, "nylas-api-key.txt", "API key") } return nil @@ -229,41 +225,47 @@ func handleAPIKeyDelivery(apiKey, appID, region string) error { // handleSecretDelivery prompts the user to choose how to receive a secret. // Secrets are never printed to stdout to prevent leaking in terminal history or logs. func handleSecretDelivery(secret, label string) error { - fmt.Printf("\nHow would you like to receive the %s?\n", label) - fmt.Println() - _, _ = common.Cyan.Println(" [1] Copy to clipboard (recommended)") - fmt.Println(" [2] Save to file") - fmt.Println() + type deliveryChoice string + const ( + choiceClipboard deliveryChoice = "clipboard" + choiceFile deliveryChoice = "file" + ) - choice, err := readLine("Choose [1-2]: ") + choice, err := common.Select(fmt.Sprintf("How would you like to receive the %s?", label), []common.SelectOption[deliveryChoice]{ + {Label: "Copy to clipboard (recommended)", Value: choiceClipboard}, + {Label: "Save to file", Value: choiceFile}, + }) if err != nil { return wrapDashboardError(err) } switch choice { - case "1", "": + case choiceClipboard: if err := common.CopyToClipboard(secret); err != nil { _, _ = common.Yellow.Printf(" Clipboard unavailable: %v\n", err) - _, _ = common.Dim.Println(" Try option [2] to save to a file instead") - return nil + _, _ = common.Dim.Println(" Falling back to file save") + return saveSecretToFile(secret, "nylas-client-secret.txt", label) } _, _ = common.Green.Printf("✓ %s copied to clipboard\n", label) - case "2": - keyFile := filepath.Join(os.TempDir(), "nylas-client-secret.txt") - if err := os.WriteFile(keyFile, []byte(secret+"\n"), 0o600); err != nil { // #nosec G306 - return wrapDashboardError(fmt.Errorf("failed to write file: %w", err)) - } - _, _ = common.Green.Printf("✓ %s saved to: %s\n", label, keyFile) - _, _ = common.Dim.Println(" Read it, then delete the file") - - default: - return dashboardError("invalid selection", "Choose 1-2") + case choiceFile: + return saveSecretToFile(secret, "nylas-client-secret.txt", label) } return nil } +// saveSecretToFile writes a secret to a temp file with restrictive permissions. +func saveSecretToFile(secret, filename, label string) error { + keyFile := filepath.Join(os.TempDir(), filename) + if err := os.WriteFile(keyFile, []byte(secret+"\n"), 0o600); err != nil { // #nosec G306 + return wrapDashboardError(fmt.Errorf("failed to write file: %w", err)) + } + _, _ = common.Green.Printf("✓ %s saved to: %s\n", label, keyFile) + _, _ = common.Dim.Println(" Read it, then delete the file") + return nil +} + // activateAPIKey stores the API key and configures the CLI to use it. func activateAPIKey(apiKey, clientID, region string) error { configStore := config.NewDefaultFileStore() diff --git a/internal/cli/dashboard/login.go b/internal/cli/dashboard/login.go index 43df717..d95b58a 100644 --- a/internal/cli/dashboard/login.go +++ b/internal/cli/dashboard/login.go @@ -71,7 +71,7 @@ func runEmailLogin(userFlag, passFlag, orgPublicID string) error { email := userFlag if email == "" { - email, err = readLine("Email: ") + email, err = common.InputPrompt("Email", "") if err != nil { return wrapDashboardError(err) } @@ -82,7 +82,7 @@ func runEmailLogin(userFlag, passFlag, orgPublicID string) error { password := passFlag if password == "" { - password, err = readPassword("Password: ") + password, err = common.PasswordPrompt("Password") if err != nil { return wrapDashboardError(err) } @@ -106,7 +106,7 @@ func runEmailLogin(userFlag, passFlag, orgPublicID string) error { } if mfa != nil { - code, readErr := readPassword("MFA code: ") + code, readErr := common.PasswordPrompt("MFA code") if readErr != nil { return wrapDashboardError(readErr) } diff --git a/internal/cli/dashboard/sso.go b/internal/cli/dashboard/sso.go index b544b15..88b77b3 100644 --- a/internal/cli/dashboard/sso.go +++ b/internal/cli/dashboard/sso.go @@ -161,7 +161,7 @@ func pollSSO(ctx context.Context, authSvc *dashboardapp.AuthService, flowID, org if resp.MFA == nil { return nil, fmt.Errorf("unexpected empty MFA response") } - code, readErr := readPassword("MFA code: ") + code, readErr := common.PasswordPrompt("MFA code") if readErr != nil { return nil, readErr } diff --git a/internal/cli/setup/grants.go b/internal/cli/setup/grants.go index f8ab7f8..10096ec 100644 --- a/internal/cli/setup/grants.go +++ b/internal/cli/setup/grants.go @@ -1,10 +1,7 @@ package setup import ( - "bufio" "fmt" - "os" - "strings" nylasadapter "github.com/nylas/cli/internal/adapters/nylas" "github.com/nylas/cli/internal/cli/common" @@ -68,37 +65,19 @@ func SyncGrants(grantStore ports.GrantStore, apiKey, clientID, region string) (* // PromptDefaultGrant presents an interactive menu for the user to select a default grant. func PromptDefaultGrant(grantStore ports.GrantStore, grants []domain.Grant) (string, error) { - fmt.Println() - fmt.Println("Select default account:") + opts := make([]common.SelectOption[string], len(grants)) for i, grant := range grants { - fmt.Printf(" [%d] %s (%s)\n", i+1, grant.Email, grant.Provider.DisplayName()) + opts[i] = common.SelectOption[string]{ + Label: fmt.Sprintf("%s (%s)", grant.Email, grant.Provider.DisplayName()), + Value: grant.ID, + } } - fmt.Println() - choice, err := readLine(fmt.Sprintf("Choose [1-%d]: ", len(grants))) + chosen, err := common.Select("Select default account", opts) if err != nil { - return grants[0].ID, nil - } - - var selected int - if _, err := fmt.Sscanf(choice, "%d", &selected); err != nil || selected < 1 || selected > len(grants) { - _, _ = common.Yellow.Printf("Invalid selection, defaulting to %s\n", grants[0].Email) - _ = grantStore.SetDefaultGrant(grants[0].ID) - return grants[0].ID, nil + chosen = grants[0].ID } - chosen := grants[selected-1] - _ = grantStore.SetDefaultGrant(chosen.ID) - return chosen.ID, nil -} - -// readLine prompts for a line of text input. -func readLine(prompt string) (string, error) { - fmt.Print(prompt) - reader := bufio.NewReader(os.Stdin) - input, err := reader.ReadString('\n') - if err != nil { - return "", fmt.Errorf("failed to read input: %w", err) - } - return strings.TrimSpace(input), nil + _ = grantStore.SetDefaultGrant(chosen) + return chosen, nil } diff --git a/internal/cli/setup/wizard.go b/internal/cli/setup/wizard.go index 948dc8d..8374b90 100644 --- a/internal/cli/setup/wizard.go +++ b/internal/cli/setup/wizard.go @@ -3,7 +3,6 @@ package setup import ( "fmt" "os" - "strings" "time" "golang.org/x/term" @@ -170,29 +169,11 @@ func chooseAccountPath(opts wizardOpts) (pathChoice, error) { return pathLogin, nil } - fmt.Println(" Do you have a Nylas account?") - fmt.Println() - _, _ = common.Cyan.Println(" [1] No, create one (free)") - fmt.Println(" [2] Yes, log me in") - fmt.Println(" [3] I already have an API key") - fmt.Println() - - choice, err := readLine(" Choose [1-3]: ") - if err != nil { - return 0, fmt.Errorf("failed to read choice: %w", err) - } - - switch strings.TrimSpace(choice) { - case "1", "": - return pathRegister, nil - case "2": - return pathLogin, nil - case "3": - return pathAPIKey, nil - default: - common.PrintError("Invalid selection") - return 0, fmt.Errorf("invalid selection: %s", choice) - } + return common.Select("Do you have a Nylas account?", []common.SelectOption[pathChoice]{ + {Label: "No, create one (free)", Value: pathRegister}, + {Label: "Yes, log me in", Value: pathLogin}, + {Label: "I already have an API key", Value: pathAPIKey}, + }) } // accountSSO handles SSO registration or login. @@ -217,25 +198,23 @@ func accountSSO(opts wizardOpts, mode string) error { // accountAPIKey handles the "I have an API key" path. func accountAPIKey(status *SetupStatus) error { - fmt.Print(" API Key (hidden): ") - apiKeyBytes, err := term.ReadPassword(int(os.Stdin.Fd())) - fmt.Println() + apiKeyRaw, err := common.PasswordPrompt("API Key") if err != nil { return fmt.Errorf("failed to read API key: %w", err) } - apiKey := sanitizeAPIKey(string(apiKeyBytes)) + apiKey := sanitizeAPIKey(apiKeyRaw) if apiKey == "" { return fmt.Errorf("API key is required") } - region, err := readLine(" Region [us/eu] (default: us): ") + region, err := common.Select("Region", []common.SelectOption[string]{ + {Label: "US", Value: "us"}, + {Label: "EU", Value: "eu"}, + }) if err != nil { return err } - if region == "" { - region = "us" - } var verifyErr error _ = common.RunWithSpinner("Verifying API key...", func() error { diff --git a/internal/cli/setup/wizard_helpers.go b/internal/cli/setup/wizard_helpers.go index 0802673..5f5adc9 100644 --- a/internal/cli/setup/wizard_helpers.go +++ b/internal/cli/setup/wizard_helpers.go @@ -55,50 +55,25 @@ func resolveProvider(opts wizardOpts) string { // chooseProvider presents an SSO provider menu. func chooseProvider() (string, error) { - fmt.Println() - fmt.Println(" How would you like to authenticate?") - fmt.Println() - _, _ = common.Cyan.Println(" [1] Google (recommended)") - fmt.Println(" [2] Microsoft") - fmt.Println(" [3] GitHub") - fmt.Println() - - choice, err := readLine(" Choose [1-3]: ") - if err != nil { - return "google", nil - } - - switch strings.TrimSpace(choice) { - case "1", "": - return "google", nil - case "2": - return "microsoft", nil - case "3": - return "github", nil - default: - return "google", nil - } + return common.Select("How would you like to authenticate?", []common.SelectOption[string]{ + {Label: "Google (recommended)", Value: "google"}, + {Label: "Microsoft", Value: "microsoft"}, + {Label: "GitHub", Value: "github"}, + }) } // selectApp prompts the user to select from multiple applications. func selectApp(apps []domain.GatewayApplication) (domain.GatewayApplication, error) { - fmt.Printf(" Found %d applications:\n\n", len(apps)) + opts := make([]common.SelectOption[int], len(apps)) for i, app := range apps { - fmt.Printf(" [%d] %s\n", i+1, appDisplayName(app)) + opts[i] = common.SelectOption[int]{Label: appDisplayName(app), Value: i} } - fmt.Println() - choice, err := readLine(fmt.Sprintf(" Select application [1-%d]: ", len(apps))) + idx, err := common.Select("Select application", opts) if err != nil { return apps[0], nil } - - var selected int - if _, err := fmt.Sscanf(choice, "%d", &selected); err != nil || selected < 1 || selected > len(apps) { - _, _ = common.Yellow.Println(" Invalid selection, using first application") - return apps[0], nil - } - return apps[selected-1], nil + return apps[idx], nil } // createDefaultApp creates a new application with defaults. @@ -106,16 +81,16 @@ func createDefaultApp(appSvc *dashboardapp.AppService, orgID string) (*domain.Ga fmt.Println(" No applications found. Creating one for you...") fmt.Println() - name, err := readLine(" App name [My First App]: ") - if err != nil || name == "" { + name, err := common.InputPrompt("App name", "My First App") + if err != nil { name = "My First App" } - region, err := readLine(" Region [us/eu] (default: us): ") - if err != nil || region == "" { - region = "us" - } - if region != "us" && region != "eu" { + region, err := common.Select("Region", []common.SelectOption[string]{ + {Label: "US", Value: "us"}, + {Label: "EU", Value: "eu"}, + }) + if err != nil { region = "us" } From 71334ba2229fce10f31a88572f671ff052aeba34 Mon Sep 17 00:00:00 2001 From: Aaron de Mello Date: Tue, 24 Mar 2026 22:38:36 -0400 Subject: [PATCH 15/20] fix(cli): replace verbose help text with concise quick start The Long description duplicated every subcommand above Cobra's auto-generated Available Commands list. Replace with a 4-line quick start guide and let Cobra handle the command listing. Co-Authored-By: Claude Opus 4.6 (1M context) --- internal/cli/root.go | 63 ++++++-------------------------------------- 1 file changed, 8 insertions(+), 55 deletions(-) diff --git a/internal/cli/root.go b/internal/cli/root.go index 1878511..6353cf5 100644 --- a/internal/cli/root.go +++ b/internal/cli/root.go @@ -12,62 +12,15 @@ import ( var rootCmd = &cobra.Command{ Use: "nylas", - Short: "Nylas CLI - Email, Authentication, and OTP management", + Short: "Nylas CLI - Email, calendar, and contacts from your terminal", Version: Version, - Long: `nylas is a command-line tool for managing emails, Nylas API authentication, -and retrieving OTP codes from email. - -AUTHENTICATION: - nylas auth login Authenticate with an email provider - nylas auth logout Logout from current account - nylas auth status Check authentication status - nylas auth list List all authenticated accounts - nylas auth switch Switch between accounts - nylas auth add Manually add an existing grant - nylas auth whoami Show current user info - -EMAIL MANAGEMENT: - nylas email list List recent emails - nylas email read Read a specific email - nylas email send Send an email - nylas email search Search emails - nylas email folders list List folders - nylas email threads list List email threads - nylas email drafts list List drafts - -CALENDAR MANAGEMENT: - nylas calendar list List calendars - nylas calendar events list List upcoming events - nylas calendar events show Show event details - nylas calendar events create Create a new event - nylas calendar events delete Delete an event - nylas calendar availability check Check free/busy status - nylas calendar availability find Find available meeting times - -CONTACTS MANAGEMENT: - nylas contacts list List contacts - nylas contacts show Show contact details - nylas contacts create Create a new contact - nylas contacts delete Delete a contact - nylas contacts groups List contact groups - -WEBHOOK MANAGEMENT: - nylas webhook list List all webhooks - nylas webhook show Show webhook details - nylas webhook create Create a new webhook - nylas webhook update Update a webhook - nylas webhook delete Delete a webhook - nylas webhook triggers List available trigger types - nylas webhook test send Send a test event - nylas webhook test payload Get mock payload for trigger - -OTP MANAGEMENT: - nylas otp get Get the latest OTP code - nylas otp watch Watch for new OTP codes - nylas otp list List configured accounts - -INTERACTIVE TUI: - nylas tui Launch k9s-style terminal UI for emails + Long: `Email, calendar, and contacts from your terminal. + +Quick start: + nylas init Guided setup (first time) + nylas email list List recent emails + nylas calendar events Upcoming events + nylas contacts list List contacts Documentation: https://cli.nylas.com/`, SilenceUsage: true, From ee46dfdcf780ec49e81fb50f6462290442bb72fc Mon Sep 17 00:00:00 2001 From: Aaron de Mello Date: Tue, 24 Mar 2026 22:39:50 -0400 Subject: [PATCH 16/20] feat(cli): add ASCII art header to root help output Shows a compact "Nylas - CLI" banner in box-drawing characters when running `nylas` with no args. Only renders on the root command, not on subcommand help. Co-Authored-By: Claude Opus 4.6 (1M context) --- internal/cli/root.go | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/internal/cli/root.go b/internal/cli/root.go index 6353cf5..abc2334 100644 --- a/internal/cli/root.go +++ b/internal/cli/root.go @@ -14,9 +14,7 @@ var rootCmd = &cobra.Command{ Use: "nylas", Short: "Nylas CLI - Email, calendar, and contacts from your terminal", Version: Version, - Long: `Email, calendar, and contacts from your terminal. - -Quick start: + Long: `Quick start: nylas init Guided setup (first time) nylas email list List recent emails nylas calendar events Upcoming events @@ -30,10 +28,21 @@ Documentation: https://cli.nylas.com/`, printWelcome() return nil } + printHelpHeader() return cmd.Help() }, } +// printHelpHeader prints the branded ASCII art header. +func printHelpHeader() { + fmt.Println() + _, _ = common.BoldCyan.Println(" ┳┓ ┓ ┏┓┓ ┳") + _, _ = common.BoldCyan.Println(" ┃┃┓┏┃┏┓┏┃ ╺━╸ ┃ ┃ ┃") + _, _ = common.BoldCyan.Println(" ┛┗┗┫┗┗┻┛┗ ┗┛┗┛┻") + _, _ = common.BoldCyan.Println(" ┛") + fmt.Println() +} + // printWelcome displays the first-run welcome message. func printWelcome() { // Banner From 4f740ddf493872cf481319b74c46eced2574cf8c Mon Sep 17 00:00:00 2001 From: Aaron de Mello Date: Tue, 24 Mar 2026 22:42:46 -0400 Subject: [PATCH 17/20] feat(cli): add unified Nylas theme for all interactive prompts Create a consistent visual identity across all CLI prompts using a custom huh theme with the Nylas brand color palette: - Primary (Cyan #00BCD4): titles, selectors, cursors, active buttons - Success (Green #4CAF50): selected options, checkmarks - Warning (Amber #FFC107): caution messages - Error (Red #F44336): error indicators - Muted (Gray #6B7280): descriptions, placeholders, navigation hints The theme is applied to all Select, Confirm, Input, and Password prompts via the shared helpers in common/prompt.go. Co-Authored-By: Claude Opus 4.6 (1M context) --- internal/cli/common/prompt.go | 10 ++- internal/cli/common/theme.go | 118 ++++++++++++++++++++++++++++++++++ 2 files changed, 126 insertions(+), 2 deletions(-) create mode 100644 internal/cli/common/theme.go diff --git a/internal/cli/common/prompt.go b/internal/cli/common/prompt.go index 8808001..1751b3f 100644 --- a/internal/cli/common/prompt.go +++ b/internal/cli/common/prompt.go @@ -8,6 +8,9 @@ import ( "golang.org/x/term" ) +// theme is the shared huh theme applied to all prompts. +var theme = NylasTheme() + // SelectOption represents a labeled option for Select prompts. type SelectOption[T comparable] struct { Label string @@ -37,6 +40,7 @@ func Select[T comparable](title string, options []SelectOption[T]) (T, error) { Title(title). Options(huhOpts...). Value(&result). + WithTheme(theme). Run() return result, err @@ -54,6 +58,7 @@ func ConfirmPrompt(title string, defaultYes bool) (bool, error) { Affirmative("Yes"). Negative("No"). Value(&result). + WithTheme(theme). Run() return result, err @@ -71,10 +76,10 @@ func InputPrompt(title, placeholder string) (string, error) { Value(&result) if placeholder != "" { - field.Placeholder(placeholder) + field = field.Placeholder(placeholder) } - err := field.Run() + err := field.WithTheme(theme).Run() if err != nil { return "", err } @@ -95,6 +100,7 @@ func PasswordPrompt(title string) (string, error) { Title(title). EchoMode(huh.EchoModePassword). Value(&result). + WithTheme(theme). Run() return result, err diff --git a/internal/cli/common/theme.go b/internal/cli/common/theme.go new file mode 100644 index 0000000..5247c14 --- /dev/null +++ b/internal/cli/common/theme.go @@ -0,0 +1,118 @@ +package common + +import ( + "github.com/charmbracelet/huh" + "github.com/charmbracelet/lipgloss" +) + +// Nylas brand color palette — used consistently across all CLI output. +const ( + ColorPrimary = lipgloss.Color("#00BCD4") // Cyan — brand accent + ColorSuccess = lipgloss.Color("#4CAF50") // Green + ColorWarning = lipgloss.Color("#FFC107") // Amber + ColorError = lipgloss.Color("#F44336") // Red + ColorMuted = lipgloss.Color("#6B7280") // Gray + ColorText = lipgloss.Color("#E0E0E0") // Light gray + ColorDim = lipgloss.Color("#4A4A4A") // Dark gray +) + +// NylasTheme returns the huh theme used for all interactive prompts. +func NylasTheme() *huh.Theme { + t := huh.ThemeBase() + + // Focused field styles + t.Focused.Base = lipgloss.NewStyle(). + PaddingLeft(1). + BorderStyle(lipgloss.ThickBorder()). + BorderLeft(true). + BorderForeground(ColorPrimary) + + t.Focused.Title = lipgloss.NewStyle(). + Foreground(ColorPrimary). + Bold(true) + + t.Focused.Description = lipgloss.NewStyle(). + Foreground(ColorMuted) + + t.Focused.ErrorIndicator = lipgloss.NewStyle(). + Foreground(ColorError). + SetString(" *") + + t.Focused.ErrorMessage = lipgloss.NewStyle(). + Foreground(ColorError) + + // Select + t.Focused.SelectSelector = lipgloss.NewStyle(). + Foreground(ColorPrimary). + SetString("❯ ") + + t.Focused.Option = lipgloss.NewStyle(). + Foreground(ColorText) + + t.Focused.NextIndicator = lipgloss.NewStyle(). + Foreground(ColorMuted). + SetString(" →") + + t.Focused.PrevIndicator = lipgloss.NewStyle(). + Foreground(ColorMuted). + SetString("← ") + + // MultiSelect + t.Focused.MultiSelectSelector = lipgloss.NewStyle(). + Foreground(ColorPrimary). + SetString("❯ ") + + t.Focused.SelectedOption = lipgloss.NewStyle(). + Foreground(ColorSuccess) + + t.Focused.SelectedPrefix = lipgloss.NewStyle(). + Foreground(ColorSuccess). + SetString("✓ ") + + t.Focused.UnselectedOption = lipgloss.NewStyle(). + Foreground(ColorText) + + t.Focused.UnselectedPrefix = lipgloss.NewStyle(). + Foreground(ColorMuted). + SetString("○ ") + + // Confirm buttons + t.Focused.FocusedButton = lipgloss.NewStyle(). + Foreground(lipgloss.Color("#FFFFFF")). + Background(ColorPrimary). + Padding(0, 2). + Bold(true) + + t.Focused.BlurredButton = lipgloss.NewStyle(). + Foreground(ColorMuted). + Background(ColorDim). + Padding(0, 2) + + // Text input + t.Focused.TextInput.Cursor = lipgloss.NewStyle(). + Foreground(ColorPrimary) + + t.Focused.TextInput.Placeholder = lipgloss.NewStyle(). + Foreground(ColorMuted) + + t.Focused.TextInput.Prompt = lipgloss.NewStyle(). + Foreground(ColorPrimary). + SetString("❯ ") + + t.Focused.TextInput.Text = lipgloss.NewStyle(). + Foreground(ColorText) + + // Card / Note + t.Focused.Card = t.Focused.Base + t.Focused.NoteTitle = t.Focused.Title + t.Focused.Next = t.Focused.FocusedButton + + // Blurred state — same styles but hidden border + t.Blurred = t.Focused + t.Blurred.Base = t.Focused.Base.BorderStyle(lipgloss.HiddenBorder()) + t.Blurred.Card = t.Blurred.Base + t.Blurred.NextIndicator = lipgloss.NewStyle() + t.Blurred.PrevIndicator = lipgloss.NewStyle() + + return t +} From 49a3756d5fec45de7b6c73fa06986c31647e2945 Mon Sep 17 00:00:00 2001 From: Aaron de Mello Date: Tue, 24 Mar 2026 22:43:55 -0400 Subject: [PATCH 18/20] feat(cli): switch to ANSI shadow ASCII art style Replace the formal box-drawing ASCII art with a more casual ANSI shadow block style for the help header. Co-Authored-By: Claude Opus 4.6 (1M context) --- internal/cli/root.go | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/internal/cli/root.go b/internal/cli/root.go index abc2334..5fe3663 100644 --- a/internal/cli/root.go +++ b/internal/cli/root.go @@ -36,10 +36,9 @@ Documentation: https://cli.nylas.com/`, // printHelpHeader prints the branded ASCII art header. func printHelpHeader() { fmt.Println() - _, _ = common.BoldCyan.Println(" ┳┓ ┓ ┏┓┓ ┳") - _, _ = common.BoldCyan.Println(" ┃┃┓┏┃┏┓┏┃ ╺━╸ ┃ ┃ ┃") - _, _ = common.BoldCyan.Println(" ┛┗┗┫┗┗┻┛┗ ┗┛┗┛┻") - _, _ = common.BoldCyan.Println(" ┛") + _, _ = common.BoldCyan.Println(" ░█▀█░█░█░█░░░█▀█░█▀▀") + _, _ = common.BoldCyan.Println(" ░█░█░░█░░█░░░█▀█░▀▀█") + _, _ = common.BoldCyan.Println(" ░▀░▀░░▀░░▀▀▀░▀░▀░▀▀▀") fmt.Println() } From c0cc75035c80a14d2234c06c7d5642567cd5f231 Mon Sep 17 00:00:00 2001 From: Aaron de Mello Date: Tue, 24 Mar 2026 22:44:40 -0400 Subject: [PATCH 19/20] feat(cli): update primary theme color to Royal Blue (#4169E1) Co-Authored-By: Claude Opus 4.6 (1M context) --- internal/cli/common/theme.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/cli/common/theme.go b/internal/cli/common/theme.go index 5247c14..55b4ec0 100644 --- a/internal/cli/common/theme.go +++ b/internal/cli/common/theme.go @@ -7,7 +7,7 @@ import ( // Nylas brand color palette — used consistently across all CLI output. const ( - ColorPrimary = lipgloss.Color("#00BCD4") // Cyan — brand accent + ColorPrimary = lipgloss.Color("#4169E1") // Royal Blue — brand accent ColorSuccess = lipgloss.Color("#4CAF50") // Green ColorWarning = lipgloss.Color("#FFC107") // Amber ColorError = lipgloss.Color("#F44336") // Red From 4d8f53fc9913a5c6a23186cf6a726bcec7a34262 Mon Sep 17 00:00:00 2001 From: Aaron de Mello Date: Tue, 24 Mar 2026 22:46:00 -0400 Subject: [PATCH 20/20] fix(cli): use theme Brand color for ASCII art and welcome screen The ASCII art and welcome screen were using hardcoded fatih/color Cyan instead of the theme's ColorPrimary. Add a lipgloss Brand style derived from ColorPrimary and use it for all branded elements so changing the theme color in one place updates everything. Co-Authored-By: Claude Opus 4.6 (1M context) --- internal/cli/common/colors.go | 9 ++++++++- internal/cli/root.go | 12 ++++++------ 2 files changed, 14 insertions(+), 7 deletions(-) diff --git a/internal/cli/common/colors.go b/internal/cli/common/colors.go index feb96ba..a4a4bc7 100644 --- a/internal/cli/common/colors.go +++ b/internal/cli/common/colors.go @@ -1,6 +1,9 @@ package common -import "github.com/fatih/color" +import ( + "github.com/charmbracelet/lipgloss" + "github.com/fatih/color" +) // Common color definitions used across CLI commands. // Import these instead of defining package-local color vars. @@ -29,4 +32,8 @@ var ( // Reset (no formatting) Reset = color.New(color.Reset) + + // Brand — matches the Nylas theme primary color. + // Use for ASCII art, banners, and branded elements. + Brand = lipgloss.NewStyle().Foreground(ColorPrimary).Bold(true) ) diff --git a/internal/cli/root.go b/internal/cli/root.go index 5fe3663..65ff8b8 100644 --- a/internal/cli/root.go +++ b/internal/cli/root.go @@ -36,9 +36,9 @@ Documentation: https://cli.nylas.com/`, // printHelpHeader prints the branded ASCII art header. func printHelpHeader() { fmt.Println() - _, _ = common.BoldCyan.Println(" ░█▀█░█░█░█░░░█▀█░█▀▀") - _, _ = common.BoldCyan.Println(" ░█░█░░█░░█░░░█▀█░▀▀█") - _, _ = common.BoldCyan.Println(" ░▀░▀░░▀░░▀▀▀░▀░▀░▀▀▀") + fmt.Println(common.Brand.Render(" ░█▀█░█░█░█░░░█▀█░█▀▀")) + fmt.Println(common.Brand.Render(" ░█░█░░█░░█░░░█▀█░▀▀█")) + fmt.Println(common.Brand.Render(" ░▀░▀░░▀░░▀▀▀░▀░▀░▀▀▀")) fmt.Println() } @@ -51,7 +51,7 @@ func printWelcome() { fmt.Print(" ") _, _ = common.Dim.Print("│") fmt.Print(" ") - _, _ = common.BoldCyan.Print("◈ N Y L A S C L I") + fmt.Print(common.Brand.Render("◈ N Y L A S C L I")) fmt.Print(" ") _, _ = common.Dim.Println("│") _, _ = common.Dim.Println(" │ │") @@ -71,7 +71,7 @@ func printWelcome() { _, _ = common.Bold.Println(" Get started in under a minute:") fmt.Println() fmt.Print(" ") - _, _ = common.BoldCyan.Print("❯ nylas init") + fmt.Print(common.Brand.Render("❯ nylas init")) fmt.Println(" Guided setup") fmt.Print(" ") _, _ = common.Dim.Println(" nylas init --api-key Quick setup with existing key") @@ -105,7 +105,7 @@ func printCapability(name, desc string) { fmt.Print(" ") _, _ = common.Dim.Print("│") fmt.Print(" ") - _, _ = common.Cyan.Printf("%-12s", name) + fmt.Print(common.Brand.Render(fmt.Sprintf("%-12s", name))) fmt.Printf("%-28s", desc) _, _ = common.Dim.Println("│") }