diff --git a/.gitignore b/.gitignore index a293063..1b85fea 100644 --- a/.gitignore +++ b/.gitignore @@ -108,6 +108,7 @@ apikey* *token* !*_test.go !internal/cli/auth/token.go +!internal/cli/rpc/token.go # Secret files secrets.json 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/docs/RPC.md b/docs/RPC.md index de58936..419c613 100644 --- a/docs/RPC.md +++ b/docs/RPC.md @@ -5,7 +5,7 @@ capability surface to a thin client (for example a desktop app). The CLI binary engine — it holds the credentials, runs the live pollers, and owns all business logic. Clients are thin: they send requests and render the streamed results and notifications. -- **Endpoint:** `ws://127.0.0.1:7368/ws` +- **Endpoint:** `ws://127.0.0.1:7369/ws` - **Protocol:** JSON-RPC 2.0 (bidirectional) over WebSocket - **Auth:** per-session bearer token, loopback-only bind - **Surface:** ~108 methods across 18 domains + live push notifications @@ -33,18 +33,26 @@ Clients are thin: they send requests and render the streamed results and notific Start the server: ```bash -nylas rpc serve # binds 127.0.0.1:7368 +nylas rpc serve # binds 127.0.0.1:7369 nylas rpc serve --addr 127.0.0.1:9000 ``` On first run the server generates a session token, stores it in the OS keyring, and -prints how to authenticate. To use a known token (headless / scripting): +prints how to authenticate. Print the current token (generates one if none exists): + +```bash +nylas rpc token # prints the token +nylas rpc token --json # {"token":"…"} +nylas rpc token --copy # copy to clipboard +``` + +To inject a known token instead (headless / scripting): ```bash NYLAS_WS_TOKEN=my-secret nylas rpc serve ``` -Connect a WebSocket client to `ws://127.0.0.1:7368/ws` with the token, then send a request: +Connect a WebSocket client to `ws://127.0.0.1:7369/ws` with the token, then send a request: ```json { "jsonrpc": "2.0", "id": 1, "method": "email.list", "params": { "limit": 10 } } @@ -128,8 +136,8 @@ The server holds live Nylas credentials, so the local socket is a real trust bou | Flag / Env | Purpose | Default | |---|---|---| -| `--addr` | bind address | `127.0.0.1:7368` | -| `NYLAS_WS_ADDR` | bind address (env; `--addr` wins) | `127.0.0.1:7368` | +| `--addr` | bind address | `127.0.0.1:7369` | +| `NYLAS_WS_ADDR` | bind address (env; `--addr` wins) | `127.0.0.1:7369` | | `--allow-remote` | permit a non-loopback bind (warns) | `false` | | `NYLAS_WS_TOKEN` | inject the session token (headless/CI) | auto-generated, keyring-brokered | | `NYLAS_DISABLE_KEYRING` | store token/creds in `~/.config/nylas` instead of the keyring | `false` | @@ -365,7 +373,7 @@ the `NYLAS_WS_POLL_*` env vars (see [Configuration](#configuration)). Authenticate, list mail, and subscribe to live notifications (pseudocode): ```js -const ws = new WebSocket("ws://127.0.0.1:7368/ws", { +const ws = new WebSocket("ws://127.0.0.1:7369/ws", { headers: { Authorization: `Bearer ${token}` }, }); diff --git a/docs/commands/rpc-examples/README.md b/docs/commands/rpc-examples/README.md new file mode 100644 index 0000000..f104d61 --- /dev/null +++ b/docs/commands/rpc-examples/README.md @@ -0,0 +1,27 @@ +# RPC server — Node examples + +Zero-dependency Node clients for the `nylas rpc serve` JSON-RPC WebSocket server +(see [`../../RPC.md`](../../RPC.md) for the full protocol and method reference). + +These use Node's built-in global `WebSocket` (Node 21+), so there's nothing to install. + +## Run + +Start the server in one terminal: + +```bash +nylas rpc serve # binds 127.0.0.1:7369 +``` + +Then run an example — each script fetches the token itself via `nylas rpc token` +(or honors `NYLAS_WS_TOKEN` if you set it): + +```bash +node list-sweep.js +node read-thread.js +``` + +| Script | What it does | +|--------|--------------| +| `list-sweep.js` | Calls every `*.list` method + a follow-up `get`, prints a pass/fail summary | +| `read-thread.js` | Finds a multi-message thread and expands each message | diff --git a/docs/commands/rpc-examples/list-sweep.js b/docs/commands/rpc-examples/list-sweep.js new file mode 100644 index 0000000..3fc5d2c --- /dev/null +++ b/docs/commands/rpc-examples/list-sweep.js @@ -0,0 +1,82 @@ +// Sweep every list method over one connection and print a pass/fail summary. +// Run: node list-sweep.js (token comes from NYLAS_WS_TOKEN or `nylas rpc token`) +const { execFileSync } = require("node:child_process"); + +const token = process.env.NYLAS_WS_TOKEN || execFileSync("nylas", ["rpc", "token"]).toString().trim(); +const ws = new WebSocket(`ws://127.0.0.1:7369/ws?token=${token}`); + +// [method, params, result-field-to-count] +const calls = [ + ["config.read", null, null], + ["grant.list", null, "grants"], + ["calendar.list", null, "calendars"], + ["email.list", { limit: 3 }, "messages"], + ["thread.list", { limit: 3 }, "threads"], + ["contact.list", { limit: 3 }, "contacts"], + ["contact.group.list", null, "groups"], + ["email.folder.list", null, "folders"], + ["draft.list", { limit: 3 }, "drafts"], + ["notetaker.list", null, "notetakers"], + ["template.list", null, "templates"], + ["audit.list", { limit: 3 }, "entries"], + ["agentAccount.list", null, "accounts"], + ["email.signature.list", null, "signatures"], + ["email.scheduled.list", null, "scheduled"], +]; + +let id = 0; +const pending = new Map(); +function call(method, params) { + return new Promise((resolve) => { + const myId = ++id; + pending.set(myId, resolve); + const req = { jsonrpc: "2.0", id: myId, method }; + if (params) req.params = params; + ws.send(JSON.stringify(req)); + }); +} + +ws.addEventListener("message", (ev) => { + const msg = JSON.parse(ev.data.toString()); + const resolve = pending.get(msg.id); + pending.delete(msg.id); + if (typeof resolve === "function") resolve(msg); +}); + +ws.addEventListener("open", async () => { + console.log("connected\n"); + let firstCalendarId = null; + let firstMessageId = null; + + for (const [method, params, field] of calls) { + const msg = await call(method, params); + if (msg.error) { + console.log(`✗ ${method} ERROR ${msg.error.code}: ${msg.error.message}`); + continue; + } + if (field && Array.isArray(msg.result?.[field])) { + const arr = msg.result[field]; + console.log(`✓ ${method} → ${arr.length} ${field}`); + if (method === "calendar.list" && arr[0]) firstCalendarId = arr[0].id; + if (method === "email.list" && arr[0]) firstMessageId = arr[0].id; + } else { + console.log(`✓ ${method} → { ${Object.keys(msg.result || {}).join(", ")} }`); + } + } + + // follow-up get-by-id calls using ids captured above + for (const [method, params] of [ + ["calendar.get", firstCalendarId && { calendar_id: firstCalendarId }], + ["email.get", firstMessageId && { message_id: firstMessageId }], + ]) { + if (!params) continue; + const msg = await call(method, params); + if (msg.error) console.log(`✗ ${method} ERROR ${msg.error.message}`); + else console.log(`✓ ${method} → { ${Object.keys(msg.result || {}).join(", ")} }`); + } + + ws.close(); +}); + +ws.addEventListener("close", () => console.log("\nclosed")); +ws.addEventListener("error", (e) => { console.log("ws error:", e.message || e); process.exit(1); }); diff --git a/docs/commands/rpc-examples/read-thread.js b/docs/commands/rpc-examples/read-thread.js new file mode 100644 index 0000000..8fd3770 --- /dev/null +++ b/docs/commands/rpc-examples/read-thread.js @@ -0,0 +1,72 @@ +// Find a multi-message thread and expand every message in it. +// Run: node read-thread.js (token comes from NYLAS_WS_TOKEN or `nylas rpc token`) +const { execFileSync } = require("node:child_process"); + +const token = process.env.NYLAS_WS_TOKEN || execFileSync("nylas", ["rpc", "token"]).toString().trim(); +const ws = new WebSocket(`ws://127.0.0.1:7369/ws?token=${token}`); + +let id = 0; +const pending = new Map(); +function call(method, params) { + return new Promise((resolve) => { + const myId = ++id; + pending.set(myId, resolve); + const req = { jsonrpc: "2.0", id: myId, method }; + if (params) req.params = params; + ws.send(JSON.stringify(req)); + }); +} + +ws.addEventListener("message", (ev) => { + const msg = JSON.parse(ev.data.toString()); + const resolve = pending.get(msg.id); + pending.delete(msg.id); + if (typeof resolve === "function") resolve(msg); +}); + +ws.addEventListener("open", async () => { + const list = await call("thread.list", { limit: 50 }); + const threads = list.result?.threads || []; + console.log(`scanned ${threads.length} threads`); + + const multi = threads + .map((t) => ({ t, n: (t.message_ids || []).length })) + .filter((x) => x.n > 1) + .sort((a, b) => b.n - a.n); + + if (multi.length === 0) { + console.log("no multi-message threads in the first 50"); + ws.close(); + return; + } + + const { t, n } = multi[0]; + console.log(`\n=== thread ${t.id} (${n} messages) ===`); + console.log("subject :", t.subject); + console.log("participants:", (t.participants || []).map((p) => p.email || p.name).join(", ")); + console.log("unread :", t.unread, "| starred:", t.starred); + + const got = await call("thread.get", { thread_id: t.id }); + const mids = got.result?.message_ids || t.message_ids || []; + console.log(`\nexpanding ${mids.length} messages:\n`); + + let idx = 0; + for (const mid of mids) { + idx++; + const m = await call("email.get", { message_id: mid }); + if (m.error) { + console.log(` [${idx}] ${mid} ERROR ${m.error.message}`); + continue; + } + const r = m.result; + console.log(` [${idx}/${mids.length}] ${r.date}`); + console.log(` from : ${(r.from || []).map((f) => f.email).join(", ")}`); + console.log(` subject: ${r.subject}`); + console.log(` snippet: ${(r.snippet || "").replace(/\s+/g, " ").slice(0, 130)}`); + console.log(""); + } + ws.close(); +}); + +ws.addEventListener("close", () => console.log("closed")); +ws.addEventListener("error", (e) => { console.log("ws error:", e.message || e); process.exit(1); }); 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/rpc/rpc.go b/internal/cli/rpc/rpc.go index 544d4f6..22bb254 100644 --- a/internal/cli/rpc/rpc.go +++ b/internal/cli/rpc/rpc.go @@ -11,6 +11,7 @@ func NewRPCCmd() *cobra.Command { } cmd.AddCommand(newServeCmd()) + cmd.AddCommand(newTokenCmd()) return cmd } diff --git a/internal/cli/rpc/serve.go b/internal/cli/rpc/serve.go index b4ed1e9..8dcf22c 100644 --- a/internal/cli/rpc/serve.go +++ b/internal/cli/rpc/serve.go @@ -19,7 +19,7 @@ import ( const ( envWSAddr = "NYLAS_WS_ADDR" - defaultAddr = "127.0.0.1:7368" + defaultAddr = "127.0.0.1:7369" envPollFast = "NYLAS_WS_POLL_FAST" envPollIdle = "NYLAS_WS_POLL_IDLE" diff --git a/internal/cli/rpc/token.go b/internal/cli/rpc/token.go new file mode 100644 index 0000000..206fc30 --- /dev/null +++ b/internal/cli/rpc/token.go @@ -0,0 +1,54 @@ +package rpc + +import ( + "fmt" + "os" + + "github.com/spf13/cobra" + + "github.com/nylas/cli/internal/adapters/config" + "github.com/nylas/cli/internal/adapters/keyring" + "github.com/nylas/cli/internal/adapters/rpcserver" + "github.com/nylas/cli/internal/cli/common" +) + +func newTokenCmd() *cobra.Command { + var copyToClipboard bool + + cmd := &cobra.Command{ + Use: "token", + Short: "Show or copy the RPC session token", + Long: "Print the JSON-RPC WebSocket session token used to authenticate against " + + "'nylas rpc serve'. Resolves the same way the server does: NYLAS_WS_TOKEN if set, " + + "otherwise the keyring, generating and persisting one if none exists.", + RunE: func(cmd *cobra.Command, args []string) error { + store, err := keyring.NewSecretStore(config.DefaultConfigDir()) + if err != nil { + return fmt.Errorf("open secret store: %w", err) + } + token, err := rpcserver.ResolveToken(store, os.Getenv) + if err != nil { + return err + } + + if jsonOutput, _ := cmd.Root().PersistentFlags().GetBool("json"); jsonOutput { + return common.PrintJSON(map[string]string{"token": token}) + } + + if copyToClipboard { + if err := common.CopyToClipboard(token); err != nil { + return common.WrapWriteError("clipboard", err) + } + _, _ = common.Green.Println("✓ RPC token copied to clipboard") + return nil + } + + fmt.Println(token) + return nil + }, + } + + cmd.Flags().BoolVarP(©ToClipboard, "copy", "c", false, "Copy to clipboard") + + return cmd +} diff --git a/internal/cli/rpc/token_test.go b/internal/cli/rpc/token_test.go new file mode 100644 index 0000000..0ebb8bd --- /dev/null +++ b/internal/cli/rpc/token_test.go @@ -0,0 +1,21 @@ +package rpc + +import "testing" + +// The token command is a thin wrapper over rpcserver.ResolveToken (covered in +// internal/adapters/rpcserver/auth_test.go). This verifies it's wired into the +// rpc command with the documented --copy flag so scripts can rely on it. +func TestTokenCmd_Wiring(t *testing.T) { + root := NewRPCCmd() + + cmd, _, err := root.Find([]string{"token"}) + if err != nil { + t.Fatalf("find token subcommand: %v", err) + } + if cmd.Use != "token" { + t.Fatalf("Use = %q, want %q", cmd.Use, "token") + } + if cmd.Flags().Lookup("copy") == nil { + t.Fatal("token command missing --copy flag") + } +} 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