diff --git a/cmd/auth.go b/cmd/auth.go index ba674ed..f4aef98 100644 --- a/cmd/auth.go +++ b/cmd/auth.go @@ -5,6 +5,7 @@ import ( "encoding/json" "fmt" "io" + "net/url" "os" "strings" "time" @@ -28,14 +29,8 @@ var authCmd = &cobra.Command{ Short: "Authenticate Customer.io CLI with the Customer.io API", Long: `Manage authentication for the Customer.io CLI. -The CLI uses service account tokens (sa_live_...) for authentication. -On login, the CLI exchanges the token for a short-lived JWT via OAuth 2.0 -client credentials grant and caches it. - -Credentials are stored in ~/.cio/config.json with 0600 permissions. - -Alternatively, set the CIO_TOKEN environment variable or pass ---token on any command.`, +Credentials are stored in ~/.cio/config.json. +You can also set the CIO_TOKEN environment variable or pass --token on any command.`, RunE: func(cmd *cobra.Command, args []string) error { return cmd.Help() }, @@ -47,22 +42,18 @@ Alternatively, set the CIO_TOKEN environment variable or pass var authLoginCmd = &cobra.Command{ Use: "login", - Short: "Authenticate with a Customer.io service account token", - Long: `Authenticate the Customer.io CLI by minting a token through the web UI. - -Default flow: the CLI prints a URL, you open it in any browser, log in -with email/password/SSO/2FA as normal, and the page mints a token scoped -to the account you're currently viewing. Copy the token, paste it back -at the prompt, and the CLI stores it in ~/.cio/config.json. - -For CI / automation the existing token-paste flow is unchanged: - $ echo "sa_live_abc123..." | cio auth login --with-token - $ cio auth login sa_live_abc123... - -The token (sa_live_...) is stored in ~/.cio/config.json with 0600 -permissions. The CLI exchanges it for a short-lived JWT via the -OAuth 2.0 client credentials grant at /v1/service_accounts/oauth/token, -then auto-discovers your data center (US or EU) from the account.`, + Short: "Authenticate the Customer.io CLI", + Long: `Sign in to the Customer.io CLI. + +If you're already signed in, this prints a link to open Customer.io in +your browser — no password needed. + +If this is your first time, you'll be guided to sign in at +fly.customer.io and paste a token back into your terminal. + +For CI or non-interactive use: + $ echo "$TOKEN" | cio auth login --with-token + $ cio auth login `, Args: cobra.MaximumNArgs(1), RunE: func(cmd *cobra.Command, args []string) error { withToken, _ := cmd.Flags().GetBool("with-token") @@ -86,6 +77,14 @@ then auto-discovers your data center (US or EU) from the account.`, case len(args) == 1: token = strings.TrimSpace(args[0]) default: + // If we already have a sa_live_ on disk, do the CLI → web + // handoff: print a URL with a short-lived JWT that signs the + // user into fly directly. Skips the password-reset detour for + // users who signed up via CLI. + if existing := loadStoredServiceAccountToken(); existing != "" { + return runLoginCLILink(cmd, existing) + } + // Print the URL rather than shelling out to a browser — works // under SSH, headless CI, and restrictive sandboxes. loginURL := resolveCLILoginURL() @@ -157,7 +156,7 @@ then auto-discovers your data center (US or EU) from the account.`, var authLogoutCmd = &cobra.Command{ Use: "logout", Short: "Remove stored authentication credentials", - Long: "Delete the stored token and cached JWT from ~/.cio/config.json.", + Long: "Delete the stored credentials from ~/.cio/config.json.", Args: cobra.NoArgs, RunE: func(cmd *cobra.Command, args []string) error { if err := client.DeleteCredentials(); err != nil { @@ -179,8 +178,7 @@ var authLogoutCmd = &cobra.Command{ var authStatusCmd = &cobra.Command{ Use: "status", Short: "Display active authentication state", - Long: `Check authentication status by showing the stored/active token source -and verifying it against the API. + Long: `Show which token the CLI is currently using and whether it's valid. Token resolution order: 1. --token flag @@ -258,11 +256,8 @@ Token resolution order: var authTokenCmd = &cobra.Command{ Use: "token", - Short: "Print the active service account token", - Long: `Print the sa_live_ token that Customer.io CLI is currently configured to use. - -This is useful for debugging token resolution. The token is printed to -stdout with no formatting. + Short: "Print the active token", + Long: `Print the active token to stdout. Token resolution order: 1. --token flag @@ -291,15 +286,11 @@ Token resolution order: var authSignupCmd = &cobra.Command{ Use: "signup", - Short: "Provision a new Customer.io account (unauthenticated agentic flow)", - Long: `Two-step unauthenticated signup flow for agents. + Short: "Create a new Customer.io account", + Long: `Create a new Customer.io account from the command line. -Step 1 — 'signup start' emails a 6-digit verification code. -Step 2 — 'signup verify' consumes the code, creates the account, and returns -an Admin-scoped sa_live_ bootstrap token shown ONCE. - -Both subcommands honor --api-url (defaults to https://us.fly.customer.io). -They require no credentials; --token / CIO_TOKEN are ignored.`, +Step 1: 'cio auth signup start' sends a verification code to your email. +Step 2: 'cio auth signup verify' confirms the code and creates your account.`, RunE: func(cmd *cobra.Command, args []string) error { return cmd.Help() }, @@ -308,25 +299,20 @@ They require no credentials; --token / CIO_TOKEN are ignored.`, var authSignupStartCmd = &cobra.Command{ Use: "start", Short: "Email a 6-digit verification code to the given address", - Long: `POST /v1/account_signup. - -Supply the request body via --json: - cio auth signup start --json '{"email":"agent+demo@example.com"}' + Long: `Send a verification code to the given email address. -A 200 response ("check your email") is not proof a code was sent — if one -doesn't arrive within a few minutes, try a different email.`, + cio auth signup start --json '{"email":"you@example.com"}'`, Args: cobra.NoArgs, RunE: runAuthSignupStart, } var authSignupVerifyCmd = &cobra.Command{ Use: "verify", - Short: "Verify the code, create the account, and return the bootstrap sa_live_ token", - Long: `POST /v1/account_signup/code. + Short: "Confirm the verification code and create the account", + Long: `Confirm the verification code and create the account. -Supply the request body via --json: cio auth signup verify --json '{ - "email": "agent+demo@example.com", + "email": "you@example.com", "code": "123456", "company_name": "Acme", "first_name": "Ada", @@ -334,12 +320,8 @@ Supply the request body via --json: "data_center": "us" }' -The returned 'token' is shown ONCE and the server will not return it again. -On success, verify automatically writes the bootstrap token + account_id to -~/.cio/config.json, so the next 'cio api ...' call is already authenticated. - -If persistence fails (rare), capture the 'token' field from stdout and run: - echo "" | cio auth login --with-token`, +On success, your credentials are saved automatically and you're ready +to use the CLI.`, Args: cobra.NoArgs, RunE: runAuthSignupVerify, } @@ -405,12 +387,13 @@ func runAuthSignupVerify(cmd *cobra.Command, args []string) error { // saveSignupCredentials extracts the bootstrap token + account_id from a // successful /v1/account_signup/code response and writes them to -// ~/.cio/config.json. Region is derived from --api-url if recognizable, else -// from the request body's data_center field, else defaults to "us". +// ~/.cio/config.json. Region priority: response data_center (authoritative +// from server), then request body data_center, then --api-url, then "us". func saveSignupCredentials(response json.RawMessage, requestBody []byte, baseURL string) error { var parsed struct { - Token string `json:"token"` - AccountID json.RawMessage `json:"account_id"` + Token string `json:"token"` + AccountID json.RawMessage `json:"account_id"` + DataCenter string `json:"data_center"` } if err := json.Unmarshal(response, &parsed); err != nil { return fmt.Errorf("parse signup response: %w", err) @@ -427,7 +410,7 @@ func saveSignupCredentials(response json.RawMessage, requestBody []byte, baseURL accountID = "" } - region := client.RegionFromBaseURL(baseURL) + region := strings.ToLower(strings.TrimSpace(parsed.DataCenter)) if region == "" { var req struct { DataCenter string `json:"data_center"` @@ -435,6 +418,9 @@ func saveSignupCredentials(response json.RawMessage, requestBody []byte, baseURL _ = json.Unmarshal(requestBody, &req) region = strings.ToLower(strings.TrimSpace(req.DataCenter)) } + if region == "" { + region = client.RegionFromBaseURL(baseURL) + } if region == "" { region = "us" } @@ -487,6 +473,55 @@ func runSignupRequest(cmd *cobra.Command, path string) error { return output.FprintProcess(cmd.OutOrStdout(), result, GetJQFlag(cmd)) } +// loadStoredServiceAccountToken reads the saved sa_live_ token from +// ~/.cio/config.json. It deliberately ignores CIO_TOKEN and the --token +// flag — `cio auth login` is about persisting credentials, so we only +// branch into the handoff flow when we already wrote a config file. +func loadStoredServiceAccountToken() string { + creds, err := client.ReadCredentials() + if err != nil { + return "" + } + if !client.IsServiceAccountToken(creds.ServiceAccountToken) { + return "" + } + return creds.ServiceAccountToken +} + +// runLoginCLILink exchanges a stored sa_live_ for a short-lived JWT and +// prints a one-click URL the user can open to sign into the Customer.io +// web UI. The CLI's stored credentials are unchanged — this flow only +// bootstraps a browser session, it does not refresh the saved token. +func runLoginCLILink(cmd *cobra.Command, saToken string) error { + baseURL := resolveLoginAPIURL(cmd) + if baseURL == "" { + // Use the same default as the rest of the CLI when --api-url isn't set. + region := "us" + if creds, err := client.ReadCredentials(); err == nil && creds.Region != "" { + region = creds.Region + } + baseURL = client.BaseURLForRegion(region) + } + timeout, _ := cmd.Flags().GetDuration("timeout") + + resp, err := client.MintLoginCLILink(cmd.Context(), baseURL, saToken, timeout) + if err != nil { + return handleAPIError(err) + } + + uiURL := resolveCLILoginURL() + "?token=" + url.QueryEscape(resp.HandoffToken) + + fmt.Fprintf(cmd.ErrOrStderr(), "You're already signed in. Open this URL in your browser to access Customer.io:\n\n %s\n\n", uiURL) + fmt.Fprintf(cmd.ErrOrStderr(), "This link is valid for %d seconds.\n", resp.ExpiresIn) + + return output.FprintJSON(cmd.OutOrStdout(), map[string]any{ + "status": "ok", + "message": "Open the URL in your browser to sign into Customer.io.", + "url": uiURL, + "expires_in": resp.ExpiresIn, + }) +} + // resolveCLILoginURL returns the shared hosted CLI login URL. // CIO_UI_URL can override the UI origin for non-production or test flows. // The API URL is intentionally ignored here: it is a backend host and bears no diff --git a/cmd/auth_test.go b/cmd/auth_test.go index 213cf65..c79c730 100644 --- a/cmd/auth_test.go +++ b/cmd/auth_test.go @@ -3,6 +3,7 @@ package cmd import ( "bytes" "encoding/json" + "fmt" "io" "net/http" "net/http/httptest" @@ -397,6 +398,73 @@ func TestReadInteractiveTokenWithTTY_FallsBackForNonTerminalInput(t *testing.T) } } +// When `cio auth login` runs with no args and a sa_live_ already saved, it +// should hit /v1/login_cli/link on the API, get a handoff JWT back, and +// print a one-click URL — without touching the existing stored token. +func TestAuthLogin_StoredTokenTriggersWebHandoff(t *testing.T) { + tmpDir := t.TempDir() + t.Setenv("HOME", tmpDir) + t.Setenv("CIO_TOKEN", "") + t.Setenv("CIO_UI_URL", "https://fly.example.test") + + // Pre-populate ~/.cio/config.json with a sa_live_ token. + creds := &client.Credentials{ + ServiceAccountToken: "sa_live_existing", + AccountID: "42", + Region: "us", + } + if err := client.WriteCredentials(creds); err != nil { + t.Fatalf("seed credentials: %v", err) + } + + mintHits := 0 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/v1/login_cli/link" { + mintHits++ + if got := r.Header.Get("Authorization"); got != "Bearer sa_live_existing" { + t.Errorf("expected Bearer auth with stored token, got %q", got) + } + _, _ = w.Write([]byte(`{"handoff_token":"handoff-jwt-abc","expires_in":60}`)) + return + } + t.Errorf("unexpected request to %s", r.URL.Path) + w.WriteHeader(http.StatusNotFound) + })) + defer server.Close() + + stdout, stderr, err := executeCommand("auth", "login", "--api-url", server.URL) + if err != nil { + t.Fatalf("unexpected error: %v\nstderr: %s", err, stderr) + } + + if mintHits != 1 { + t.Errorf("expected 1 mint request, got %d", mintHits) + } + + var result map[string]any + if err := json.Unmarshal([]byte(stdout), &result); err != nil { + t.Fatalf("invalid JSON output: %v\nstdout: %s", err, stdout) + } + gotURL, _ := result["url"].(string) + wantSubstr := "https://fly.example.test/cli?token=handoff-jwt-abc" + if gotURL != wantSubstr { + t.Errorf("expected URL %q, got %q", wantSubstr, gotURL) + } + if !strings.Contains(stderr, wantSubstr) { + t.Errorf("expected stderr to print the URL, got: %s", stderr) + } + + // Existing credentials must be untouched — the handoff bootstraps the + // browser, not the CLI. + reread, err := client.ReadCredentials() + if err != nil { + t.Fatalf("read credentials: %v", err) + } + if reread.ServiceAccountToken != "sa_live_existing" { + t.Errorf("stored token should not change, got %q", reread.ServiceAccountToken) + } +} + func TestAuthLogin_HelpMentionsBrowserFlow(t *testing.T) { // Inspect `Long` directly instead of calling `--help`: cobra's help path // mutates shared command state on the global rootCmd, which leaks into @@ -555,7 +623,15 @@ func signupServer(t *testing.T) *httptest.Server { } _, _ = w.Write([]byte(`{"message":"check your email"}`)) case "/v1/account_signup/code": - _, _ = w.Write([]byte(`{"account_id":1,"environment_id":2,"user_id":3,"service_account_id":4,"token_id":5,"token":"sa_live_bootstrap","token_hint":"trap","expires_at":0}`)) + var req struct { + DataCenter string `json:"data_center"` + } + _ = json.Unmarshal(body, &req) + dc := req.DataCenter + if dc == "" { + dc = "us" + } + _, _ = fmt.Fprintf(w, `{"account_id":1,"environment_id":2,"user_id":3,"service_account_id":4,"token_id":5,"token":"sa_live_bootstrap","token_hint":"trap","expires_at":0,"data_center":%q}`, dc) default: w.WriteHeader(http.StatusNotFound) } @@ -656,10 +732,49 @@ func TestAuthSignupVerify_ReturnsBootstrapToken(t *testing.T) { if creds["account_id"] != "1" { t.Errorf("expected account_id=1, got %v", creds["account_id"]) } - // data_center=eu in the request body, and server URL has no eu/us hint — - // so region should fall back to the request body's data_center. + // The server response includes data_center=eu (echoed from request body). + if creds["region"] != "eu" { + t.Errorf("expected region=eu (from response data_center), got %v", creds["region"]) + } +} + +// TestAuthSignupVerify_EURegionViaUSEndpoint is a regression test for the bug +// where signing up an EU account through the default US endpoint caused the +// CLI to store region=us. The signup endpoint always runs on us.fly, but the +// response's data_center field is authoritative. +func TestAuthSignupVerify_EURegionViaUSEndpoint(t *testing.T) { + tmpDir := t.TempDir() + t.Setenv("HOME", tmpDir) + t.Setenv("CIO_TOKEN", "") + + server := signupServer(t) + defer server.Close() + + // Simulate production: --api-url contains "us.fly" but data_center is EU. + // A reverse proxy or DNS alias could make the test server reachable via + // a us.fly-like URL, but for unit tests we just verify saveSignupCredentials + // directly. + response := json.RawMessage(`{"account_id":42,"token":"sa_live_eutest","data_center":"eu"}`) + requestBody := []byte(`{"email":"eu@example.com","code":"123456","data_center":"eu"}`) + baseURL := "https://us.fly.customer.io" + + if err := saveSignupCredentials(response, requestBody, baseURL); err != nil { + t.Fatalf("saveSignupCredentials: %v", err) + } + + data, err := os.ReadFile(filepath.Join(tmpDir, ".cio", "config.json")) + if err != nil { + t.Fatalf("expected config written, got: %v", err) + } + var creds map[string]any + if err := json.Unmarshal(data, &creds); err != nil { + t.Fatalf("invalid config JSON: %v", err) + } if creds["region"] != "eu" { - t.Errorf("expected region=eu (from request body data_center), got %v", creds["region"]) + t.Errorf("expected region=eu (response data_center beats URL), got %v", creds["region"]) + } + if creds["account_id"] != "42" { + t.Errorf("expected account_id=42, got %v", creds["account_id"]) } } diff --git a/cmd/root.go b/cmd/root.go index 7d9e9ed..6bd7f7c 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -164,9 +164,11 @@ func isAuthCommand(cmd *cobra.Command) bool { return true } // Track API send commands authenticate directly with the sa_live_ token - // — they don't need the UI API OAuth exchange. + // and don't need the UI API OAuth exchange — unless --watch is set, which + // polls the REST API for delivery status. if isTrackSendCommand(cmd) { - return true + watch, _ := cmd.Flags().GetBool("watch") + return !watch } return false } diff --git a/cmd/send.go b/cmd/send.go index 0b89768..03a1469 100644 --- a/cmd/send.go +++ b/cmd/send.go @@ -1,10 +1,13 @@ package cmd import ( + "context" "encoding/json" "fmt" + "net/http" "os" "strings" + "time" "github.com/customerio/cli/internal/client" "github.com/customerio/cli/internal/output" @@ -72,6 +75,7 @@ func newSendEmailCmd(requireTxnID bool) *cobra.Command { cmd.Flags().String("identifiers", "", `Identifiers as JSON (default: inferred from --to as {"email":"..."})`) cmd.Flags().String("message-data", "", "Template variables as JSON") cmd.Flags().String("transactional-message-id", "", "Transactional message ID or trigger name") + cmd.Flags().BoolP("watch", "w", false, "Poll delivery status after queuing and print the result when complete") return cmd } @@ -325,7 +329,20 @@ func runTrackSend(cmd *cobra.Command, sendPath string, body json.RawMessage) err return handleAPIError(err) } - return output.FprintProcess(cmd.OutOrStdout(), result, jq) + watch, _ := cmd.Flags().GetBool("watch") + if !watch { + return output.FprintProcess(cmd.OutOrStdout(), result, jq) + } + + // Extract the delivery_id so we can poll its status. + var queued struct { + DeliveryID string `json:"delivery_id"` + } + if err := json.Unmarshal(result, &queued); err != nil || queued.DeliveryID == "" { + return fmt.Errorf("--watch: could not extract delivery_id from send response") + } + + return watchDelivery(cmd, envID, queued.DeliveryID) } // isTrackSendCommand returns true for commands that send via the track API @@ -334,3 +351,75 @@ func isTrackSendCommand(cmd *cobra.Command) bool { p := cmd.CommandPath() return strings.HasPrefix(p, "cio send ") || strings.HasPrefix(p, "cio transactional send ") } + +// inProgressDeliveryStates are the delivery states that indicate the delivery +// is still being processed. Sourced from services/deliveries DisplayState logic: +// default (no metrics) → "queued"; intermediate metrics: "drafted", "attempted". +var inProgressDeliveryStates = map[string]bool{ + "queued": true, + "drafted": true, + "attempted": true, +} + +// isTerminalDelivery reports whether the delivery response JSON has reached a +// final state. Checks both top-level "state" and nested "delivery.state". +func isTerminalDelivery(data json.RawMessage) (bool, string) { + var obj map[string]any + if err := json.Unmarshal(data, &obj); err != nil { + return false, "" + } + var state string + if s, ok := obj["state"].(string); ok { + state = s + } else if delivery, ok := obj["delivery"].(map[string]any); ok { + if s, ok := delivery["state"].(string); ok { + state = s + } + } + if state == "" || inProgressDeliveryStates[state] { + return false, "" + } + return true, state +} + +// watchDelivery polls GET /v1/environments/{envID}/deliveries/{deliveryID} +// every 2 seconds until the delivery reaches a terminal status, then prints +// the response to stdout. +func watchDelivery(cmd *cobra.Command, envID, deliveryID string) error { + c := clientFromCmd(cmd) + path := fmt.Sprintf("/v1/environments/%s/deliveries/%s", envID, deliveryID) + jq := GetJQFlag(cmd) + + timeout, _ := cmd.Flags().GetDuration("timeout") + ctx, cancel := context.WithTimeout(cmd.Context(), timeout) + defer cancel() + + stderr := cmd.ErrOrStderr() + fmt.Fprintf(stderr, "delivery queued (ID: %s) — watching for status", deliveryID) + + ticker := time.NewTicker(2 * time.Second) + defer ticker.Stop() + + for { + select { + case <-ctx.Done(): + fmt.Fprintln(stderr) + return ctx.Err() + case <-ticker.C: + result, err := c.Do(ctx, http.MethodGet, path, nil, nil) + if err != nil { + if apiErr, ok := err.(*client.APIError); ok && apiErr.StatusCode == http.StatusNotFound { + fmt.Fprint(stderr, ".") + continue + } + fmt.Fprintln(stderr) + return handleAPIError(err) + } + if terminal, state := isTerminalDelivery(result); terminal { + fmt.Fprintf(stderr, " email %s!\n", state) + return output.FprintProcess(cmd.OutOrStdout(), result, jq) + } + fmt.Fprint(stderr, ".") + } + } +} diff --git a/cmd/send_test.go b/cmd/send_test.go index f134bc4..10164d7 100644 --- a/cmd/send_test.go +++ b/cmd/send_test.go @@ -430,3 +430,168 @@ func TestSend_TrackURLFromEnvVar(t *testing.T) { t.Errorf("expected delivery_id, got %v", result["delivery_id"]) } } + +// --------------------------------------------------------------------------- +// --watch flag +// --------------------------------------------------------------------------- + +// deliveryAPIServer creates a test server that mimics the REST API delivery +// status endpoint. firstResponses are returned in order before the final +// terminal response. +func deliveryAPIServer(t *testing.T, envID, deliveryID string, firstResponses []map[string]any, finalResp map[string]any) *httptest.Server { + t.Helper() + call := 0 + return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + want := "/v1/environments/" + envID + "/deliveries/" + deliveryID + if r.URL.Path != want { + w.WriteHeader(http.StatusNotFound) + return + } + var resp map[string]any + if call < len(firstResponses) { + resp = firstResponses[call] + } else { + resp = finalResp + } + call++ + data, _ := json.Marshal(resp) + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write(data) + })) +} + +func TestSend_Watch_PrintsDeliveryStatus(t *testing.T) { + _, cleanup := setupSendTest(t, "sa_live_test123", "123") + defer cleanup() + + deliveryResp := map[string]any{ + "delivery_id": "RKalBAUAAZ21_test==", + "state": "sent", + "type": "email", + } + apiServer := deliveryAPIServer(t, "123", "RKalBAUAAZ21_test==", nil, deliveryResp) + defer apiServer.Close() + t.Setenv("CIO_API_URL", apiServer.URL) + t.Setenv("CIO_ACCESS_TOKEN", "fake-access-token-for-test") + + stdout, _, err := executeCommand("send", "email", + "--environment-id", "123", + "--token", "sa_live_test123", + "--json", `{"transactional_message_id":1,"identifiers":{"email":"test@example.com"}}`, + "--watch") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + var result map[string]any + if err := json.Unmarshal([]byte(stdout), &result); err != nil { + t.Fatalf("invalid JSON output: %v\nstdout: %s", err, stdout) + } + if result["state"] != "sent" { + t.Errorf("expected state=sent, got %v", result["state"]) + } +} + +func TestSend_Watch_RetriesToGetTerminalStatus(t *testing.T) { + _, cleanup := setupSendTest(t, "sa_live_test123", "123") + defer cleanup() + + pending := map[string]any{"delivery_id": "RKalBAUAAZ21_test=="} + terminal := map[string]any{"delivery_id": "RKalBAUAAZ21_test==", "state": "failed", "failure_message": "invalid address"} + apiServer := deliveryAPIServer(t, "123", "RKalBAUAAZ21_test==", []map[string]any{pending, pending}, terminal) + defer apiServer.Close() + t.Setenv("CIO_API_URL", apiServer.URL) + t.Setenv("CIO_ACCESS_TOKEN", "fake-access-token-for-test") + + stdout, _, err := executeCommand("send", "email", + "--environment-id", "123", + "--token", "sa_live_test123", + "--json", `{"transactional_message_id":1,"identifiers":{"email":"test@example.com"}}`, + "-w") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + var result map[string]any + if err := json.Unmarshal([]byte(stdout), &result); err != nil { + t.Fatalf("invalid JSON output: %v\nstdout: %s", err, stdout) + } + if result["state"] != "failed" { + t.Errorf("expected state=failed, got %v", result["state"]) + } +} + +func TestSend_Watch_Retries404(t *testing.T) { + _, cleanup := setupSendTest(t, "sa_live_test123", "123") + defer cleanup() + + calls := 0 + apiServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + calls++ + if calls < 3 { + w.WriteHeader(http.StatusNotFound) + return + } + data, _ := json.Marshal(map[string]any{"delivery_id": "RKalBAUAAZ21_test==", "state": "sent"}) + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write(data) + })) + defer apiServer.Close() + t.Setenv("CIO_API_URL", apiServer.URL) + t.Setenv("CIO_ACCESS_TOKEN", "fake-access-token-for-test") + + stdout, _, err := executeCommand("send", "email", + "--environment-id", "123", + "--token", "sa_live_test123", + "--json", `{"transactional_message_id":1,"identifiers":{"email":"test@example.com"}}`, + "--watch") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if calls < 3 { + t.Errorf("expected at least 3 calls (2 x 404 + final), got %d", calls) + } + + var result map[string]any + if err := json.Unmarshal([]byte(stdout), &result); err != nil { + t.Fatalf("invalid JSON output: %v\nstdout: %s", err, stdout) + } + if result["state"] != "sent" { + t.Errorf("expected state=sent, got %v", result["state"]) + } +} + +// --------------------------------------------------------------------------- +// isTerminalDelivery unit tests +// --------------------------------------------------------------------------- + +func TestIsTerminalDelivery(t *testing.T) { + cases := []struct { + name string + json string + terminal bool + }{ + {"top-level sent", `{"state":"sent"}`, true}, + {"top-level opened", `{"state":"opened"}`, true}, + {"top-level failed", `{"state":"failed"}`, true}, + {"top-level bounced", `{"state":"bounced"}`, true}, + {"top-level suppressed", `{"state":"suppressed"}`, true}, + {"top-level undeliverable", `{"state":"undeliverable"}`, true}, + {"top-level deferred", `{"state":"deferred"}`, true}, + {"in-progress queued", `{"state":"queued"}`, false}, + {"in-progress drafted", `{"state":"drafted"}`, false}, + {"in-progress attempted", `{"state":"attempted"}`, false}, + {"no state field", `{"delivery_id":"abc"}`, false}, + {"empty state", `{"state":""}`, false}, + {"nested sent", `{"delivery":{"state":"sent"}}`, true}, + {"nested queued", `{"delivery":{"state":"queued"}}`, false}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + got, _ := isTerminalDelivery(json.RawMessage(tc.json)) + if got != tc.terminal { + t.Errorf("isTerminalDelivery(%s) = %v, want %v", tc.json, got, tc.terminal) + } + }) + } +} diff --git a/internal/client/client.go b/internal/client/client.go index 36437e1..3099352 100644 --- a/internal/client/client.go +++ b/internal/client/client.go @@ -610,6 +610,64 @@ func PostAnonymous(ctx context.Context, baseURL, path string, body json.RawMessa return json.RawMessage(respBody), nil } +// LoginCLILinkResponse holds the short-lived JWT minted by /v1/login_cli/link. +type LoginCLILinkResponse struct { + HandoffToken string `json:"handoff_token"` + ExpiresIn int `json:"expires_in"` +} + +// MintLoginCLILink asks the backend for a short-lived JWT that the user can +// click to bootstrap a browser session for fly.customer.io. The CLI's +// existing sa_live_ token is the credential — we send it as a Bearer token, +// not in the URL. +func MintLoginCLILink(ctx context.Context, baseURL, saToken string, timeout time.Duration) (*LoginCLILinkResponse, error) { + if timeout <= 0 { + timeout = defaultTimeout + } + httpClient := &http.Client{Timeout: timeout} + + u := strings.TrimRight(baseURL, "/") + "/v1/login_cli/link" + req, err := http.NewRequestWithContext(ctx, http.MethodPost, u, bytes.NewReader([]byte("{}"))) + if err != nil { + return nil, fmt.Errorf("create request: %w", err) + } + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Accept", "application/json") + req.Header.Set("Authorization", "Bearer "+saToken) + setStandardHeaders(req) + + resp, err := httpClient.Do(req) + if err != nil { + return nil, fmt.Errorf("http request: %w", err) + } + defer func() { _ = resp.Body.Close() }() + + respBody, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("read response: %w", err) + } + + if resp.StatusCode >= http.StatusBadRequest { + apiErr := &APIError{StatusCode: resp.StatusCode} + if len(respBody) > 0 { + apiErr.Body = respBody + } + if resp.StatusCode == http.StatusTooManyRequests { + apiErr.RetryAfter = ParseRetryAfter(resp.Header.Get("Retry-After")) + } + return nil, apiErr + } + + var out LoginCLILinkResponse + if err := json.Unmarshal(respBody, &out); err != nil { + return nil, fmt.Errorf("decode response: %w", err) + } + if out.HandoffToken == "" { + return nil, fmt.Errorf("response missing handoff_token") + } + return &out, nil +} + // parseJWTExpiry extracts the exp claim from a JWT without verifying the // signature. Returns the expiry time or an error if the token is not a // valid 3-part JWT or lacks an exp claim. diff --git a/skills/cio/onboarding.md b/skills/cio/onboarding.md index 38d0a52..517d270 100644 --- a/skills/cio/onboarding.md +++ b/skills/cio/onboarding.md @@ -168,7 +168,8 @@ cio send email --environment-id \ --to \ --from \ --subject "Hello from Customer.io" \ - --body "

It works!

Your Customer.io account is set up and ready to go.

" + --body "

It works!

Your Customer.io account is set up and ready to go.

" \ + --watch ``` Personalize subject/body with the company name. Email may land in spam until DNS records are verified.