From c71f75f0bbb2f397f09a909c4c0a25ff2372debb Mon Sep 17 00:00:00 2001 From: Qasim Date: Fri, 26 Jun 2026 22:03:27 -0400 Subject: [PATCH 1/4] =?UTF-8?q?TW-5729:=20improve=20`nylas=20init`=20?= =?UTF-8?q?=E2=80=94=20app=20reuse/create=20choice=20and=20free=20Agent=20?= =?UTF-8?q?Account=20domain=20registration?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Step 2 (Application): when exactly one application exists, prompt to use it or create a new one instead of silently auto-selecting. Shared createNewApp helper backs both the zero-app and create-new paths; the "No applications found" line now only prints in the genuine zero-app case. Step 5 (Agent Account Domain): new optional step that registers a free .nylas.email domain for Agent Accounts. Gated on a dashboard session (skipped on the --api-key path with a `nylas dashboard login` hint), validates the subdomain locally, picks the region from the active app, and treats incomplete/mismatched API responses as failure. Prompt errors are surfaced rather than silently swallowed. On success the completion summary prints a ready-to-run `nylas agent account create user@`. Adds exported dashboard.CreateDomainService (delegating to a shared newDomainService), an AgentDomain field on SetupStatus, test seams for the prompts and domain service, and unit tests covering the success path, all rejection paths, the region prompt, and both completion-summary branches. Docs and command help updated. --- docs/COMMANDS.md | 1 + internal/cli/dashboard/exports.go | 5 + internal/cli/dashboard/helpers.go | 7 + internal/cli/setup/detect.go | 1 + internal/cli/setup/setup.go | 1 + internal/cli/setup/wizard.go | 40 ++- internal/cli/setup/wizard_helpers.go | 150 +++++++++++- internal/cli/setup/wizard_helpers_test.go | 286 ++++++++++++++++++++++ 8 files changed, 471 insertions(+), 20 deletions(-) diff --git a/docs/COMMANDS.md b/docs/COMMANDS.md index cda70ec..c477a38 100644 --- a/docs/COMMANDS.md +++ b/docs/COMMANDS.md @@ -87,6 +87,7 @@ The `init` command walks you through: 2. Selecting or creating an application 3. Generating and activating an API key 4. Syncing existing email accounts +5. Optionally registering a free `.nylas.email` domain for Agent Accounts (SSO login required) Run `nylas init` again after partial setup — it skips completed steps. diff --git a/internal/cli/dashboard/exports.go b/internal/cli/dashboard/exports.go index c7c99f4..c1e1614 100644 --- a/internal/cli/dashboard/exports.go +++ b/internal/cli/dashboard/exports.go @@ -35,3 +35,8 @@ func ActivateAPIKey(apiKey, clientID, region, orgID string) error { func GetActiveOrgID() (string, error) { return getActiveOrgID() } + +// CreateDomainService creates the dashboard domain management service (exported for setup wizard). +func CreateDomainService() (*dashboardapp.DomainService, error) { + return newDomainService() +} diff --git a/internal/cli/dashboard/helpers.go b/internal/cli/dashboard/helpers.go index 1571f60..31ebf84 100644 --- a/internal/cli/dashboard/helpers.go +++ b/internal/cli/dashboard/helpers.go @@ -71,6 +71,13 @@ func createAppService() (*dashboardapp.AppService, error) { // createDomainService creates the dashboard domain management service. func createDomainService() (domainService, error) { + return newDomainService() +} + +// newDomainService wires the concrete dashboard domain service. Shared by the +// unexported factory (which adapts it to the domainService interface for test +// mocking) and the exported CreateDomainService used by the setup wizard. +func newDomainService() (*dashboardapp.DomainService, error) { dpopSvc, secretStore, err := createDPoPService() if err != nil { return nil, err diff --git a/internal/cli/setup/detect.go b/internal/cli/setup/detect.go index 646b263..af6ecd8 100644 --- a/internal/cli/setup/detect.go +++ b/internal/cli/setup/detect.go @@ -18,6 +18,7 @@ type SetupStatus struct { HasGrants bool ActiveAppID string ActiveAppRegion string + AgentDomain string } // IsFirstRun returns true when the CLI has never been configured. diff --git a/internal/cli/setup/setup.go b/internal/cli/setup/setup.go index bf7f5d4..2fad43e 100644 --- a/internal/cli/setup/setup.go +++ b/internal/cli/setup/setup.go @@ -18,6 +18,7 @@ This wizard walks you through: 2. Selecting or creating an application 3. Generating and activating an API key 4. Syncing existing email accounts + 5. Optionally registering a free .nylas.email domain for Agent Accounts Already have an API key? Skip the wizard: nylas init --api-key `, diff --git a/internal/cli/setup/wizard.go b/internal/cli/setup/wizard.go index 172d01a..6c5f89d 100644 --- a/internal/cli/setup/wizard.go +++ b/internal/cli/setup/wizard.go @@ -35,7 +35,7 @@ const ( ) const ( - stepTotal = 4 + stepTotal = 5 divider = "──────────────────────────────────────────" ) @@ -95,6 +95,9 @@ func runWizard(opts wizardOpts) error { // Step 4: Grants stepGrantSync(&status) + // Step 5: Agent Account domain (interactive, optional) + stepAgentDomain(&status) + // Done! printComplete(status) return nil @@ -307,20 +310,37 @@ func stepApplication(status *SetupStatus) error { switch len(apps) { case 0: // Create a new application. - app, createErr := createDefaultApp(appSvc, orgID) + fmt.Println(" No applications found. Creating one for you...") + fmt.Println() + newApp, createErr := createNewApp(appSvc, orgID) if createErr != nil { return createErr } - selectedApp = domain.GatewayApplication{ - ApplicationID: app.ApplicationID, - Region: app.Region, - Environment: app.Environment, - Branding: app.Branding, - } + selectedApp = newApp case 1: - selectedApp = apps[0] - name := appDisplayName(selectedApp) + // One application exists — let the user reuse it or create another. + candidate := apps[0] + name := appDisplayName(candidate) _, _ = common.Green.Printf(" ✓ Found application: %s\n", name) + + useExisting, selectErr := common.Select("Use this application?", []common.SelectOption[bool]{ + {Label: "Use " + name, Value: true}, + {Label: "Create a new application", Value: false}, + }) + if selectErr != nil { + return selectErr + } + if useExisting { + selectedApp = candidate + } else { + fmt.Println(" Creating a new application...") + fmt.Println() + newApp, createErr := createNewApp(appSvc, orgID) + if createErr != nil { + return createErr + } + selectedApp = newApp + } default: selected, selectErr := selectApp(apps) if selectErr != nil { diff --git a/internal/cli/setup/wizard_helpers.go b/internal/cli/setup/wizard_helpers.go index 00be9fb..acfe2fa 100644 --- a/internal/cli/setup/wizard_helpers.go +++ b/internal/cli/setup/wizard_helpers.go @@ -1,7 +1,9 @@ package setup import ( + "context" "fmt" + "regexp" "strconv" "strings" @@ -32,14 +34,19 @@ func printComplete(status SetupStatus) { fmt.Println(" nylas calendar events Upcoming events") fmt.Println(" nylas auth status Check configuration") fmt.Println() - fmt.Println(" Register a free Agent Account email domain:") - for _, cmd := range domainRegistrationCommands(status) { - fmt.Printf(" %s\n", cmd) + if status.AgentDomain != "" { + fmt.Println(" Create an Agent Account on your domain:") + fmt.Printf(" nylas agent account create user@%s\n", status.AgentDomain) + } else { + fmt.Println(" Register a free Agent Account email domain:") + for _, cmd := range domainRegistrationCommands(status) { + fmt.Printf(" %s\n", cmd) + } + fmt.Println() + fmt.Println(" Create an Agent Account on that domain:") + fmt.Println(" nylas agent account create user@.nylas.email") } fmt.Println() - fmt.Println(" Create an Agent Account on that domain:") - fmt.Println(" nylas agent account create user@.nylas.email") - fmt.Println() fmt.Println(" Documentation: https://cli.nylas.com/") fmt.Println() } @@ -58,6 +65,116 @@ func domainRegistrationCommands(status SetupStatus) []string { } } +// agentSubdomainPattern validates the user-chosen label of .nylas.email. +var agentSubdomainPattern = regexp.MustCompile(`^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?$`) + +// domainRegistrar is the slice of the dashboard domain service that stepAgentDomain +// needs. Kept as an interface (with the createDomainServiceFn seam) so the success +// path can be unit-tested without a live dashboard session. +type domainRegistrar interface { + CreateDomain(ctx context.Context, input domain.DashboardCreateInboxDomainInput) (*domain.DashboardInboxDomain, error) +} + +var createDomainServiceFn = func() (domainRegistrar, error) { + return dashboard.CreateDomainService() +} + +// Prompt seams — overridable in tests so stepAgentDomain's success path can be +// exercised without a TTY (the real prompts return empty/defaults off-TTY). +var ( + confirmPromptFn = common.ConfirmPrompt + inputPromptFn = common.InputPrompt + selectRegionFn = func() (string, error) { + return common.Select("Region", []common.SelectOption[string]{ + {Label: "US", Value: "us"}, + {Label: "EU", Value: "eu"}, + }) + } +) + +// stepAgentDomain offers to register a free .nylas.email domain for +// Agent Accounts. Registering a managed domain requires a dashboard session, so +// the step is skipped (with a hint) on the API-key-only path. On success it sets +// status.AgentDomain so printComplete can show the ready-to-run create command. +func stepAgentDomain(status *SetupStatus) { + _, _ = common.Dim.Printf(" %s\n", divider) + fmt.Println() + _, _ = common.Bold.Printf(" Step %d of %d: Agent Account Domain\n", stepTotal, stepTotal) + fmt.Println() + + if !status.HasDashboardAuth { + _, _ = common.Dim.Println(" Skipped — log in with 'nylas dashboard login' to register a free domain") + return + } + + // On a prompt error, fail loud (the manual registration commands still print + // from printComplete because status.AgentDomain stays empty) rather than + // vanishing silently. + yes, err := confirmPromptFn("Register a free .nylas.email domain for Agent Accounts?", true) + if err != nil { + _, _ = common.Yellow.Printf(" Skipped — could not read your choice: %v\n", err) + return + } + if !yes { + return + } + + label, err := inputPromptFn("Choose a subdomain", "") + if err != nil { + _, _ = common.Yellow.Printf(" Skipped — could not read the subdomain: %v\n", err) + return + } + label = strings.ToLower(strings.TrimSpace(label)) + if !agentSubdomainPattern.MatchString(label) { + _, _ = common.Yellow.Println(" Skipped — subdomain must be 1-63 characters, start and end with a letter or number, and use only letters, numbers, and hyphens") + return + } + + region := status.ActiveAppRegion + if region != "us" && region != "eu" { + region, err = selectRegionFn() + if err != nil { + _, _ = common.Yellow.Printf(" Skipped — could not read the region: %v\n", err) + return + } + } + + domainAddress := label + ".nylas.email" + + domainSvc, err := createDomainServiceFn() + if err != nil { + _, _ = common.Yellow.Printf(" Could not register domain: %v\n", err) + return + } + + ctx, cancel := common.CreateContext() + defer cancel() + + var created *domain.DashboardInboxDomain + err = common.RunWithSpinner("Registering domain...", func() error { + created, err = domainSvc.CreateDomain(ctx, domain.DashboardCreateInboxDomainInput{ + Name: domainAddress, + DomainAddress: domainAddress, + Region: region, + }) + return err + }) + if err != nil || created == nil || created.ID == "" || + !strings.EqualFold(strings.TrimSpace(created.Region), region) || + !strings.EqualFold(strings.TrimSuffix(created.DomainAddress, "."), domainAddress) { + reason := "dashboard returned an incomplete domain" + if err != nil { + reason = err.Error() + } + _, _ = common.Yellow.Printf(" Could not register %s: %s\n", domainAddress, reason) + _, _ = common.Dim.Printf(" Try later: nylas dashboard domains create %s --region %s\n", domainAddress, region) + return + } + + _, _ = common.Green.Printf(" ✓ Registered %s\n", created.DomainAddress) + status.AgentDomain = created.DomainAddress +} + // printStepRecovery prints manual recovery instructions when a step fails. func printStepRecovery(step string, commands []string) { fmt.Println() @@ -106,11 +223,24 @@ func selectApp(apps []domain.GatewayApplication) (domain.GatewayApplication, err return apps[idx], 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() +// createNewApp creates an application and returns it as a GatewayApplication, +// the shape the wizard tracks as the active app. +func createNewApp(appSvc *dashboardapp.AppService, orgID string) (domain.GatewayApplication, error) { + app, err := createDefaultApp(appSvc, orgID) + if err != nil { + return domain.GatewayApplication{}, err + } + return domain.GatewayApplication{ + ApplicationID: app.ApplicationID, + Region: app.Region, + Environment: app.Environment, + Branding: app.Branding, + }, nil +} +// createDefaultApp creates a new application with defaults. Callers print any +// context line (e.g. "No applications found") before invoking it. +func createDefaultApp(appSvc *dashboardapp.AppService, orgID string) (*domain.GatewayCreatedApplication, error) { name, err := common.InputPrompt("App name", "My First App") if err != nil { name = "My First App" diff --git a/internal/cli/setup/wizard_helpers_test.go b/internal/cli/setup/wizard_helpers_test.go index 45b0eb9..10bb4ba 100644 --- a/internal/cli/setup/wizard_helpers_test.go +++ b/internal/cli/setup/wizard_helpers_test.go @@ -1,10 +1,258 @@ package setup import ( + "bytes" + "context" "errors" + "io" + "os" + "strings" "testing" + + "github.com/nylas/cli/internal/domain" ) +// stubRegistrar is a test double for the dashboard domain service. +type stubRegistrar struct { + created *domain.DashboardInboxDomain + err error + calls int + gotInput domain.DashboardCreateInboxDomainInput +} + +func (s *stubRegistrar) CreateDomain( + _ context.Context, + input domain.DashboardCreateInboxDomainInput, +) (*domain.DashboardInboxDomain, error) { + s.calls++ + s.gotInput = input + return s.created, s.err +} + +// withAgentDomainStubs swaps the prompt + service seams for the duration of a test. +func withAgentDomainStubs(t *testing.T, confirm bool, label string, reg *stubRegistrar) { + t.Helper() + origConfirm, origInput, origSvc := confirmPromptFn, inputPromptFn, createDomainServiceFn + t.Cleanup(func() { + confirmPromptFn, inputPromptFn, createDomainServiceFn = origConfirm, origInput, origSvc + }) + confirmPromptFn = func(string, bool) (bool, error) { return confirm, nil } + inputPromptFn = func(string, string) (string, error) { return label, nil } + createDomainServiceFn = func() (domainRegistrar, error) { return reg, nil } +} + +func TestStepAgentDomain_RegistersDomain(t *testing.T) { + // Why: a successful registration must record the domain on status so the + // completion summary can show a ready-to-run create command for it. + reg := &stubRegistrar{created: &domain.DashboardInboxDomain{ + ID: "dom_1", + DomainAddress: "acme.nylas.email", + Region: "us", + }} + withAgentDomainStubs(t, true, "ACME", reg) // mixed case → normalized to lowercase + + status := SetupStatus{HasDashboardAuth: true, ActiveAppRegion: "us"} + _ = captureStdout(t, func() { stepAgentDomain(&status) }) + + if reg.calls != 1 { + t.Fatalf("expected CreateDomain called once, got %d", reg.calls) + } + if status.AgentDomain != "acme.nylas.email" { + t.Fatalf("expected AgentDomain set to acme.nylas.email, got %q", status.AgentDomain) + } + // The lowercased label + active region must flow into the request unchanged. + if reg.gotInput.DomainAddress != "acme.nylas.email" || reg.gotInput.Region != "us" { + t.Fatalf("unexpected create input: %+v", reg.gotInput) + } +} + +func TestStepAgentDomain_RegionPrompt(t *testing.T) { + // Why: when the active app region is unknown the user is prompted. A prompt + // error must fail loud and skip; a chosen region must flow into the request. + t.Run("prompt error skips and surfaces message", func(t *testing.T) { + reg := &stubRegistrar{} + withAgentDomainStubs(t, true, "acme", reg) + origSel := selectRegionFn + t.Cleanup(func() { selectRegionFn = origSel }) + selectRegionFn = func() (string, error) { return "", errors.New("no tty") } + + // Note: the "Skipped" message uses the color package's cached writer, which + // captureStdout can't intercept, so we assert behavior (no API call, no + // domain recorded) rather than the message text. + status := SetupStatus{HasDashboardAuth: true} // empty region → prompt + _ = captureStdout(t, func() { stepAgentDomain(&status) }) + + if reg.calls != 0 { + t.Fatalf("expected no API call on region error, got %d", reg.calls) + } + if status.AgentDomain != "" { + t.Fatalf("expected no domain registered, got %q", status.AgentDomain) + } + }) + + t.Run("chosen region flows into request", func(t *testing.T) { + reg := &stubRegistrar{created: &domain.DashboardInboxDomain{ + ID: "dom_1", + DomainAddress: "acme.nylas.email", + Region: "eu", + }} + withAgentDomainStubs(t, true, "acme", reg) + origSel := selectRegionFn + t.Cleanup(func() { selectRegionFn = origSel }) + selectRegionFn = func() (string, error) { return "eu", nil } + + status := SetupStatus{HasDashboardAuth: true} + _ = captureStdout(t, func() { stepAgentDomain(&status) }) + + if status.AgentDomain != "acme.nylas.email" { + t.Fatalf("expected AgentDomain set, got %q", status.AgentDomain) + } + if reg.gotInput.Region != "eu" { + t.Fatalf("expected region eu in request, got %q", reg.gotInput.Region) + } + }) +} + +func TestStepAgentDomain_DoesNotRegister(t *testing.T) { + tests := []struct { + name string + confirm bool + label string + reg *stubRegistrar + wantCall bool + }{ + { + name: "user declines", + confirm: false, + label: "acme", + reg: &stubRegistrar{}, + }, + { + name: "invalid subdomain never calls the API", + confirm: true, + label: "-bad-", + reg: &stubRegistrar{}, + }, + { + name: "create error", + confirm: true, + label: "acme", + reg: &stubRegistrar{err: errors.New("boom")}, + wantCall: true, + }, + { + name: "region mismatch in response is rejected", + confirm: true, + label: "acme", + reg: &stubRegistrar{created: &domain.DashboardInboxDomain{ID: "dom_1", DomainAddress: "acme.nylas.email", Region: "eu"}}, + wantCall: true, + }, + { + name: "missing ID in response is rejected", + confirm: true, + label: "acme", + reg: &stubRegistrar{created: &domain.DashboardInboxDomain{DomainAddress: "acme.nylas.email", Region: "us"}}, + wantCall: true, + }, + { + name: "address mismatch in response is rejected", + confirm: true, + label: "acme", + reg: &stubRegistrar{created: &domain.DashboardInboxDomain{ID: "dom_1", DomainAddress: "other.nylas.email", Region: "us"}}, + wantCall: true, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + withAgentDomainStubs(t, tt.confirm, tt.label, tt.reg) + status := SetupStatus{HasDashboardAuth: true, ActiveAppRegion: "us"} + _ = captureStdout(t, func() { stepAgentDomain(&status) }) + + if status.AgentDomain != "" { + t.Fatalf("expected no domain registered, got %q", status.AgentDomain) + } + if (tt.reg.calls > 0) != tt.wantCall { + t.Fatalf("CreateDomain call mismatch: called=%v want=%v", tt.reg.calls > 0, tt.wantCall) + } + }) + } +} + +// captureStdout redirects os.Stdout while fn runs and returns what was written. +// printComplete writes plain text via fmt, so the asserted lines are captured. +func captureStdout(t *testing.T, fn func()) string { + t.Helper() + orig := os.Stdout + r, w, err := os.Pipe() + if err != nil { + t.Fatalf("os.Pipe: %v", err) + } + os.Stdout = w + defer func() { os.Stdout = orig }() + + fn() + _ = w.Close() + + var buf bytes.Buffer + if _, err := io.Copy(&buf, r); err != nil { + t.Fatalf("read captured stdout: %v", err) + } + return buf.String() +} + +func TestPrintComplete_AgentDomainBranch(t *testing.T) { + // Why: when Step 5 registers a domain, the final summary must show a + // ready-to-run create command for that exact domain and drop the generic + // "register a domain" instructions; without a domain it must show the + // fallback instructions instead. + tests := []struct { + name string + status SetupStatus + wantContains []string + wantNotContains []string + }{ + { + name: "registered domain shows ready-to-run create command", + status: SetupStatus{AgentDomain: "acme.nylas.email"}, + wantContains: []string{ + "Create an Agent Account on your domain:", + "nylas agent account create user@acme.nylas.email", + }, + wantNotContains: []string{ + "Register a free Agent Account email domain:", + ".nylas.email", + }, + }, + { + name: "no domain shows manual registration instructions", + status: SetupStatus{ActiveAppRegion: "us"}, + wantContains: []string{ + "Register a free Agent Account email domain:", + "nylas dashboard domains create .nylas.email --region us", + "nylas agent account create user@.nylas.email", + }, + wantNotContains: []string{ + "Create an Agent Account on your domain:", + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + out := captureStdout(t, func() { printComplete(tt.status) }) + for _, s := range tt.wantContains { + if !strings.Contains(out, s) { + t.Errorf("output missing %q\n---\n%s", s, out) + } + } + for _, s := range tt.wantNotContains { + if strings.Contains(out, s) { + t.Errorf("output unexpectedly contains %q\n---\n%s", s, out) + } + } + }) + } +} + func TestEnsureSetupCallbackURI_AllowsManualFallbackWhenProvisioningFails(t *testing.T) { originalProvisioner := setupCallbackProvisioner t.Cleanup(func() { @@ -28,6 +276,44 @@ func TestEnsureSetupCallbackURI_RequiresClientID(t *testing.T) { } } +func TestAgentSubdomainPattern(t *testing.T) { + // Why: the chosen label becomes part of