diff --git a/README.md b/README.md index e041c41..0039f98 100644 --- a/README.md +++ b/README.md @@ -63,7 +63,29 @@ npx skills add customerio/cli ## Authentication -The CLI uses **service account tokens** for authentication. Production tokens use `sa_live_...`; Builder sandbox signup can temporarily return `sa_sandbox_...` until go-live. These are exchanged for short-lived JWTs via OAuth 2.0 client credentials, just like `gh auth`. +The CLI uses **service account tokens** (`sa_live_...`) for authentication. These are exchanged for short-lived JWTs via OAuth 2.0 client credentials, just like `gh auth`. + +### New to Customer.io? + +Create an account from the CLI. `start` emails you a 6-digit code, and +`verify` checks it, creates the account and saves its credentials, so you +don't need to log in afterward. + +```bash +cio auth signup start --json '{"email":"you@example.com"}' + +cio auth signup verify --json '{ + "email": "you@example.com", + "code": "123456", + "company_name": "Acme", + "first_name": "Ada", + "last_name": "Lovelace", + "data_center": "us" +}' +``` + +`data_center` is `us` or `eu`. AI agents can run this for the user; the full +flow is in `cio skills read cio/auth.md`. ### Login @@ -128,7 +150,7 @@ which a per-command `--profile` / `CIO_PROFILE` override does not change. ### How It Works -1. You provide a service account token (`sa_live_...` from Customer.io UI → Account Settings → Manage API Credentials → Service Accounts, or `sa_sandbox_...` returned by Builder sandbox signup) +1. You provide a service account token (`sa_live_...` from Customer.io UI → Account Settings → Manage API Credentials → Service Accounts), or `cio auth signup verify` saves one for a new account 2. The CLI exchanges it for a JWT via `POST /v1/service_accounts/oauth/token` 3. The JWT is cached locally and refreshed automatically when it expires 4. All API calls use `Authorization: Bearer ` diff --git a/cmd/auth.go b/cmd/auth.go index 3212de2..78e9564 100644 --- a/cmd/auth.go +++ b/cmd/auth.go @@ -13,6 +13,7 @@ import ( "github.com/customerio/cli/internal/client" "github.com/customerio/cli/internal/clipboard" "github.com/customerio/cli/internal/output" + "github.com/mattn/go-isatty" "github.com/spf13/cobra" "golang.org/x/term" ) @@ -21,6 +22,10 @@ var isTerminalInput = func(fd uintptr) bool { return term.IsTerminal(int(fd)) } +// isCygwinTerminalInput reports a mintty, MSYS or Cygwin pty. Windows gives +// one as a named pipe, which term.IsTerminal doesn't count as a terminal. +var isCygwinTerminalInput = isatty.IsCygwinTerminal + var readPasswordInput = func(fd uintptr) ([]byte, error) { return term.ReadPassword(int(fd)) } @@ -64,7 +69,7 @@ func clipboardToken(cmd *cobra.Command, wait bool) (string, error) { if token == "" { err := fmt.Errorf("clipboard is empty") output.PrintError(output.CodeValidationError, err.Error(), map[string]any{ - "hint": "Copy the token from the token page, then re-run. In a remote shell your local clipboard isn't visible — run 'cio auth login' in your own terminal instead.", + "hint": "Copy the token from the token page, then re-run, or add --wait to start watching the clipboard before the token is copied. If the token was already copied, this shell can't see your local clipboard (SSH, containers), so run 'cio auth login' in your own terminal instead.", }) return "", err } @@ -82,7 +87,7 @@ func clipboardToken(cmd *cobra.Command, wait bool) (string, error) { if time.Now().After(deadline) { err := fmt.Errorf("timed out waiting for a token on the clipboard") output.PrintError(output.CodeValidationError, err.Error(), map[string]any{ - "hint": "Copy the token from the token page and re-run. In a remote shell your local clipboard isn't visible — run 'cio auth login' in your own terminal instead.", + "hint": "Re-run the same command to keep waiting. If the token was already copied, this shell can't see your local clipboard (SSH, containers), so run 'cio auth login' in your own terminal instead.", }) return "", err } @@ -100,7 +105,10 @@ var authCmd = &cobra.Command{ Long: `Manage authentication for the Customer.io CLI. Credentials are stored in ~/.cio/config.json. -You can also set the CIO_TOKEN environment variable or pass --token on any command.`, +You can also set the CIO_TOKEN environment variable or pass --token on any command. + +AI agents can sign the user in: see 'cio auth login --help'. If the user +has no Customer.io account, 'cio auth signup' creates one.`, RunE: func(cmd *cobra.Command, args []string) error { return cmd.Help() }, @@ -115,6 +123,19 @@ var authLoginCmd = &cobra.Command{ Short: "Authenticate the Customer.io CLI", Long: `Sign in to the Customer.io CLI. +For AI agents: you can run this login for the user. Start the waiting +login and, in the same message, give the user these steps (they may not +see this command's output): + $ cio auth login --from-clipboard --wait + 1. I've started a login command that watches your clipboard. + 2. Open https://fly.customer.io/cli and sign in. + 3. Copy the token shown. It's picked up automatically. +The command waits up to 90 seconds. On timeout, re-run it right away +without ending your turn. Never have the user paste a token into the +conversation. Plain 'cio auth login' asks for a pasted token when none is +stored, so it fails without a terminal. For signup, remote shells and EU +accounts, run 'cio skills read cio/auth.md'. + If you're already signed in, this prints a link to open Customer.io in your browser — no password needed. @@ -180,6 +201,16 @@ re-authenticates whichever profile is currently selected (see 'cio profile').`, return runLoginCLILink(cmd, existing) } + // Without a terminal, as in an agent's shell, stdin may never + // close, so the prompt would hang. Say what works instead. + if !canPromptForToken(cmd.InOrStdin()) { + err := fmt.Errorf("no terminal to paste a token into") + output.PrintError(output.CodeValidationError, err.Error(), map[string]any{ + "hint": "Run 'cio auth login --from-clipboard --wait' and have the user copy the token from the token page (see 'cio auth login --help'). To pipe a token in, use 'cio auth login --with-token'.", + }) + return err + } + // Print the URL rather than shelling out to a browser — works // under SSH, headless CI, and restrictive sandboxes. loginURL := resolveCLILoginURL(resolveLoginBaseURL(cmd)) @@ -290,6 +321,11 @@ var authLogoutCmd = &cobra.Command{ // auth status // --------------------------------------------------------------------------- +// noTokenHint follows the no-token errors from auth status and auth token. +// Agents run auth status first, so it names signup for a user with no account +// as well as the agent login for one who has an account. +const noTokenHint = "If the user has no Customer.io account, create one with 'cio auth signup start' (see 'cio auth signup --help'). For an existing account, run 'cio auth login --from-clipboard --wait' (see 'cio auth login --help')." + var authStatusCmd = &cobra.Command{ Use: "status", Short: "Display active authentication state", @@ -325,6 +361,7 @@ Token resolution order: err := fmt.Errorf("not authenticated. Run 'cio auth login' or set CIO_TOKEN") output.PrintError(output.CodeAuthError, err.Error(), map[string]any{ "token_source": "none", + "hint": noTokenHint, }) return err } @@ -398,7 +435,9 @@ Token resolution order: if token == "" { err := fmt.Errorf("no token found. Run 'cio auth login' or set CIO_TOKEN") - output.PrintError(output.CodeAuthError, err.Error(), nil) + output.PrintError(output.CodeAuthError, err.Error(), map[string]any{ + "hint": noTokenHint, + }) return err } @@ -716,6 +755,16 @@ func resolveLoginAPIURL(cmd *cobra.Command) string { return "" } +// canPromptForToken reports whether plain login can prompt for a pasted token, +// which needs a terminal. Agent shells give stdin as /dev/null or as a pipe or +// socket that never closes, and piped tokens go through --with-token. A reader +// that isn't a file, as in tests, is read as is. A Git Bash or Cygwin pty +// counts as a terminal, and its prompt reads the pasted line. +func canPromptForToken(input io.Reader) bool { + f, ok := input.(*os.File) + return !ok || isTerminalInput(f.Fd()) || isCygwinTerminalInput(f.Fd()) +} + func readInteractiveToken(input io.Reader, stderr io.Writer) (string, error) { var ( fd uintptr diff --git a/cmd/auth_test.go b/cmd/auth_test.go index 9fee281..a4e1a0b 100644 --- a/cmd/auth_test.go +++ b/cmd/auth_test.go @@ -467,6 +467,73 @@ func TestReadInteractiveTokenWithTTY_FallsBackForNonTerminalInput(t *testing.T) } } +func TestAuthLogin_NoTerminalFailsFast(t *testing.T) { + tmpDir := t.TempDir() + t.Setenv("HOME", tmpDir) + t.Setenv("CIO_TOKEN", "") + + devNull, err := os.Open(os.DevNull) + if err != nil { + t.Fatalf("open %s: %v", os.DevNull, err) + } + defer devNull.Close() + rootCmd.SetIn(devNull) + t.Cleanup(func() { rootCmd.SetIn(nil) }) + + _, stderr, err := executeCommand("auth", "login") + if err == nil || !strings.Contains(err.Error(), "no terminal to paste a token into") { + t.Fatalf("expected the no-terminal error, got: %v", err) + } + if strings.Contains(stderr, "Paste token:") || strings.Contains(stderr, "Open this URL") { + t.Errorf("expected no paste prompt, got stderr:\n%s", stderr) + } + if _, err := os.Stat(filepath.Join(tmpDir, ".cio", "config.json")); !os.IsNotExist(err) { + t.Errorf("expected no config file, stat err: %v", err) + } +} + +func TestCanPromptForToken(t *testing.T) { + devNull, err := os.Open(os.DevNull) + if err != nil { + t.Fatalf("open %s: %v", os.DevNull, err) + } + defer devNull.Close() + if canPromptForToken(devNull) { + t.Errorf("expected no prompt on %s", os.DevNull) + } + + // An agent's shell can hold stdin open without ever writing to it. + r, w, err := os.Pipe() + if err != nil { + t.Fatalf("pipe: %v", err) + } + defer r.Close() + defer w.Close() + if canPromptForToken(r) { + t.Errorf("expected no prompt on a pipe") + } + + if !canPromptForToken(strings.NewReader("sa_live_piped\n")) { + t.Errorf("expected a non-file reader to be read as is") + } + + // Git Bash's mintty and Cygwin give stdin as a named pipe. + origIsCygwinTerminal := isCygwinTerminalInput + t.Cleanup(func() { isCygwinTerminalInput = origIsCygwinTerminal }) + isCygwinTerminalInput = func(fd uintptr) bool { return true } + if !canPromptForToken(r) { + t.Errorf("expected a prompt on a Cygwin or MSYS pty") + } + isCygwinTerminalInput = origIsCygwinTerminal + + origIsTerminal := isTerminalInput + t.Cleanup(func() { isTerminalInput = origIsTerminal }) + isTerminalInput = func(fd uintptr) bool { return true } + if !canPromptForToken(devNull) { + t.Errorf("expected a prompt on a terminal") + } +} + // 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. @@ -656,6 +723,27 @@ func TestAuthLogin_HelpMentionsBrowserFlow(t *testing.T) { } } +func TestAuthLogin_HelpTellsAgentsToRunIt(t *testing.T) { + help := authLoginCmd.Long + for _, want := range []string{ + "For AI agents: you can run this login for the user.", + "$ cio auth login --from-clipboard --wait", + "Open https://fly.customer.io/cli and sign in.", + "The command waits up to 90 seconds.", + "cio skills read cio/auth.md", + } { + if !strings.Contains(help, want) { + t.Errorf("expected login help to contain %q, got:\n%s", want, help) + } + } +} + +func TestAuth_HelpNamesSignup(t *testing.T) { + if !strings.Contains(authCmd.Long, "'cio auth signup'") { + t.Errorf("expected auth help to name signup, got:\n%s", authCmd.Long) + } +} + func TestAuthLogout_RemovesCredentials(t *testing.T) { tmpDir := t.TempDir() t.Setenv("HOME", tmpDir) @@ -701,10 +789,60 @@ func TestAuthStatus_NotAuthenticated(t *testing.T) { t.Setenv("HOME", tmpDir) t.Setenv("CIO_TOKEN", "") - _, _, err := executeCommand("auth", "status") + var err error + stderr := captureStderr(t, func() { + _, _, err = executeCommand("auth", "status") + }) if err == nil { t.Fatal("expected error when not authenticated") } + assertNoTokenHint(t, stderr) +} + +// assertNoTokenHint checks that a no-token error names signup for a user with +// no account and the agent login for one who has an account. +func assertNoTokenHint(t *testing.T, stderr string) { + t.Helper() + var e struct { + Code string `json:"code"` + Details map[string]any `json:"details"` + } + line, _, _ := strings.Cut(stderr, "\n") + if err := json.Unmarshal([]byte(line), &e); err != nil { + t.Fatalf("expected a JSON error on stderr: %v\nstderr: %s", err, stderr) + } + if e.Code != "AUTH_ERROR" { + t.Errorf("expected AUTH_ERROR, got %q", e.Code) + } + hint, _ := e.Details["hint"].(string) + for _, want := range []string{"cio auth signup start", "cio auth login --from-clipboard --wait"} { + if !strings.Contains(hint, want) { + t.Errorf("expected hint to contain %q, got %q", want, hint) + } + } +} + +// captureStderr runs fn with os.Stderr redirected to a pipe and returns what +// was written. output.PrintError writes to os.Stderr, not the command's writer. +func captureStderr(t *testing.T, fn func()) string { + t.Helper() + r, w, err := os.Pipe() + if err != nil { + t.Fatalf("pipe: %v", err) + } + old := os.Stderr + os.Stderr = w + defer func() { os.Stderr = old }() + + done := make(chan string) + go func() { + b, _ := io.ReadAll(r) + done <- string(b) + }() + + fn() + _ = w.Close() + return <-done } func TestAuthStatus_WithEnvToken(t *testing.T) { @@ -844,10 +982,14 @@ func TestAuthToken_NoToken(t *testing.T) { t.Setenv("HOME", tmpDir) t.Setenv("CIO_TOKEN", "") - _, _, err := executeCommand("auth", "token") + var err error + stderr := captureStderr(t, func() { + _, _, err = executeCommand("auth", "token") + }) if err == nil { t.Fatal("expected error when no token available") } + assertNoTokenHint(t, stderr) } // signupServer returns a test server for the agentic signup endpoints. diff --git a/cmd/billing_status.go b/cmd/billing_status.go index 0476652..eee809b 100644 --- a/cmd/billing_status.go +++ b/cmd/billing_status.go @@ -417,7 +417,7 @@ func forecastWallet(now time.Time, usage walletUsage, rates map[string]float64) funds.RunsOutOn = dateOf(now) fundsWarning = &billingWarning{ Code: "FUNDS_LOW", - Message: "No funds left. Usage beyond the free grants fails: metered sends are undeliverable and new active profiles are dropped. Run 'cio billing add-funds' to add funds.", + Message: "No funds left. New active profiles beyond the free grant are dropped, and metered sends may fail. Run 'cio billing add-funds' to add funds.", } } else { slices.SortFunc(later, func(a, b rollover) int { return cmp.Compare(a.day, b.day) }) diff --git a/cmd/bootstrap_skill.md b/cmd/bootstrap_skill.md index b655320..c1bb4ab 100644 --- a/cmd/bootstrap_skill.md +++ b/cmd/bootstrap_skill.md @@ -13,4 +13,11 @@ cio prime task-specific skills (`cio skills read `), global flags, exit codes, and examples. Follow what it returns. -If `cio` is not installed, see https://github.com/customerio/cli. +If the user has no Customer.io account, create one with `cio auth signup`. +`cio prime` explains the flow. + +If `cio` is not installed, install it with npm: + +```bash +npm install -g @customerio/cli +``` diff --git a/cmd/funds_warning.go b/cmd/funds_warning.go new file mode 100644 index 0000000..a4b1323 --- /dev/null +++ b/cmd/funds_warning.go @@ -0,0 +1,240 @@ +package cmd + +import ( + "context" + "encoding/json" + "errors" + "net/http" + "net/url" + "os" + "path/filepath" + "time" + + "github.com/customerio/cli/internal/client" + "github.com/customerio/cli/internal/filelock" + "github.com/customerio/cli/internal/routes" + "github.com/customerio/cli/internal/validate" + "github.com/spf13/cobra" +) + +// meteredSendPaths are the Track API sends Flex charges to the wallet. Push +// and inbox messages are free, so their sends never check funds. +var meteredSendPaths = map[string]bool{ + "/v1/send/email": true, + "/v1/send/sms": true, +} + +const ( + // fundsCheckInterval spaces out the funds checks for one token. A forecast + // moves slowly, and an agent may send in a loop. + fundsCheckInterval = time.Hour + // fundsCheckOffFlexInterval spaces them out further for an account that + // isn't on Flex, where a check only matters again if the plan changes. + fundsCheckOffFlexInterval = 24 * time.Hour +) + +// fundsCheckTimeout bounds the whole check: the token exchange and every read. +// A variable so tests can shorten it. +var fundsCheckTimeout = 3 * time.Second + +// fundsCheckState is what the funds cache keeps for one service account token. +type fundsCheckState struct { + AccountID string `json:"account_id,omitempty"` + NextCheckAt time.Time `json:"next_check_at"` +} + +// warnIfFundsLow prints a FUNDS_LOW warning after a metered send when the +// account's funds are gone or will run out before the billing period ends. +// +// It is best-effort: every failure is swallowed, so the send's output and exit +// code never change. It checks at most once an hour per service account token, +// throttled in ~/.cio/cache/funds under a hash of the token, so a token from +// CIO_TOKEN or --token is throttled the same way as a profile's. +func warnIfFundsLow(cmd *cobra.Command, sendPath, saToken string) { + if !meteredSendPaths[sendPath] { + return + } + cache, err := openFundsCache(saToken) + if err != nil { + return + } + now := time.Now() + state, due := cache.claim(now) + if !due { + return + } + + ctx := cmd.Context() + if ctx == nil { + ctx = context.Background() + } + ctx, cancel := context.WithTimeout(ctx, fundsCheckTimeout) + defer cancel() + c := fundsCheckClient(cmd, saToken) + + if state.AccountID == "" { + accountID, flex, err := readFundsAccount(ctx, c) + if err != nil { + return + } + state.AccountID = accountID + if !flex { + state.NextCheckAt = now.Add(fundsCheckOffFlexInterval) + cache.save(state) + return + } + cache.save(state) + } + + usage, rates, err := readWallet(ctx, c, state.AccountID) + var apiErr *client.APIError + if errors.As(err, &apiErr) && apiErr.StatusCode == http.StatusBadRequest { + // The wallet refuses an account that has left Flex. + state.NextCheckAt = now.Add(fundsCheckOffFlexInterval) + cache.save(state) + return + } + if err != nil { + return + } + _, _, _, warnings, err := forecastWallet(now, usage, rates) + if err != nil { + return + } + for _, w := range warnings { + if w.Code == "FUNDS_LOW" { + printWarning(cmd, w.Code, w.Message) + return + } + } +} + +// fundsCheckClient builds a UI API client with the command's own read-only +// flag and scopes, so a profile token's cached access token is reused and +// refreshed in place. On a send, --api-url names the Track host, so the UI +// API host comes from CIO_API_URL, the profile or the region. It never +// retries: the check gives up before it would hold up the command. +func fundsCheckClient(cmd *cobra.Command, saToken string) *client.Client { + readOnly, _ := cmd.Flags().GetBool("read-only") + scopes, _ := cmd.Flags().GetStringSlice("scope") + return client.New(client.Config{ + BaseURL: client.ResolveBaseURL("", false), + ServiceAccountToken: saToken, + AccessToken: client.ResolveAccessToken(), + ReadOnly: readOnly, + Scopes: scopes, + Timeout: fundsCheckTimeout, + RetryConfig: &client.RetryConfig{}, + }) +} + +// readFundsAccount reads the token's account ID and whether it's on Flex. +func readFundsAccount(ctx context.Context, c *client.Client) (accountID string, flex bool, err error) { + raw, err := c.Do(ctx, http.MethodGet, "/v1/accounts/current", nil, nil) + if err != nil { + return "", false, err + } + var current struct { + Account billingAccount `json:"account"` + } + if err := json.Unmarshal(raw, ¤t); err != nil { + return "", false, err + } + accountID = current.Account.ID.String() + if err := validate.ValidateResourceID(accountID); err != nil { + return "", false, err + } + return accountID, current.Account.BillingVendor == flexBillingVendor, nil +} + +// readWallet makes the two reads forecastWallet needs, as billing status does. +func readWallet(ctx context.Context, c *client.Client, accountID string) (walletUsage, map[string]float64, error) { + walletPath := "/v1/accounts/" + url.PathEscape(accountID) + "/billing/wallet" + get := func(path string, into any) error { + raw, err := c.Do(ctx, http.MethodGet, walletPath+path, nil, nil) + if err != nil { + return err + } + return json.Unmarshal(raw, into) + } + + var usage walletUsage + if err := get("/usage", &usage); err != nil { + return walletUsage{}, nil, err + } + var rates struct { + Rates map[string]float64 `json:"rates"` + } + if err := get("/rates", &rates); err != nil { + return walletUsage{}, nil, err + } + return usage, rates.Rates, nil +} + +// fundsCache is one token's file in ~/.cio/cache/funds. One lock covers the +// directory, held only while a file is read or written, never across a request. +type fundsCache struct { + lockPath string + path string +} + +func openFundsCache(saToken string) (*fundsCache, error) { + configDir, err := client.ConfigDir() + if err != nil { + return nil, err + } + dir := filepath.Join(configDir, "cache", "funds") + if err := os.MkdirAll(dir, 0o700); err != nil { + return nil, err + } + return &fundsCache{ + lockPath: filepath.Join(dir, "funds.lock"), + path: filepath.Join(dir, routes.CacheKeyDir(saToken)+".json"), + }, nil +} + +// claim reports whether the token's check is due, and when it is, records the +// next one before any request goes out. A check that hangs or fails, or a send +// running alongside, then still counts. A lock held by another send means that +// send is checking, so this one isn't due. +func (f *fundsCache) claim(now time.Time) (fundsCheckState, bool) { + unlock, err := filelock.TryLock(f.lockPath, 0o600) + if err != nil { + return fundsCheckState{}, false + } + defer unlock() + + var state fundsCheckState + if data, err := os.ReadFile(f.path); err == nil && json.Unmarshal(data, &state) != nil { + // A damaged file only costs one extra check. + state = fundsCheckState{} + } + if now.Before(state.NextCheckAt) { + return state, false + } + state.NextCheckAt = now.Add(fundsCheckInterval) + // Without a throttle every send would check, so a failed write skips it. + if err := f.write(state); err != nil { + return state, false + } + return state, true +} + +// save records state when the lock is free. Losing a save only costs an +// extra account read, or an earlier check of an account that isn't on Flex. +func (f *fundsCache) save(state fundsCheckState) { + unlock, err := filelock.TryLock(f.lockPath, 0o600) + if err != nil { + return + } + defer unlock() + _ = f.write(state) +} + +func (f *fundsCache) write(state fundsCheckState) error { + data, err := json.Marshal(state) + if err != nil { + return err + } + return os.WriteFile(f.path, data, 0o600) +} diff --git a/cmd/funds_warning_test.go b/cmd/funds_warning_test.go new file mode 100644 index 0000000..7920d73 --- /dev/null +++ b/cmd/funds_warning_test.go @@ -0,0 +1,405 @@ +package cmd + +import ( + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "slices" + "strings" + "sync" + "testing" + "time" + + "github.com/customerio/cli/internal/client" + "github.com/customerio/cli/internal/filelock" + "github.com/customerio/cli/internal/routes" +) + +// stubUIAPI points the UI API at a server that answers every request with a +// 404, so a send's funds check fails fast in tests that aren't about it. +func stubUIAPI(t *testing.T) { + t.Helper() + srv := httptest.NewServer(http.NotFoundHandler()) + t.Cleanup(srv.Close) + t.Setenv("CIO_API_URL", srv.URL) +} + +const ( + flexFundsAccount = `{"account":{"id":1,"billing_vendor":"modern_stripe"}}` + otherFundsAccount = `{"account":{"id":1,"billing_vendor":"stripe"}}` +) + +// fundsServer answers the reads a send's funds check makes, and records each +// request. By default its wallet runs out about five days into a period with +// twenty left. +type fundsServer struct { + *httptest.Server + mu sync.Mutex + paths []string + auth []string + responses map[string]string + statuses map[string]int + block map[string]bool +} + +func newFundsServer(t *testing.T) *fundsServer { + t.Helper() + s := &fundsServer{ + statuses: map[string]int{}, + block: map[string]bool{}, + responses: map[string]string{ + "/v1/service_accounts/oauth/token": `{"access_token":"jwt-funds-check","token_type":"Bearer","expires_in":3600}`, + "/v1/accounts/current": flexFundsAccount, + "/v1/accounts/1/billing/wallet/usage": fundsUsage(10, 5), + "/v1/accounts/1/billing/wallet/rates": `{"rates":{"email_sends":0.002}}`, + }, + } + s.Server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + s.mu.Lock() + s.paths = append(s.paths, r.URL.Path) + s.auth = append(s.auth, r.Header.Get("Authorization")) + body, ok := s.responses[r.URL.Path] + status, block := s.statuses[r.URL.Path], s.block[r.URL.Path] + s.mu.Unlock() + + if block { + select { + case <-r.Context().Done(): + case <-time.After(5 * time.Second): + } + return + } + if !ok { + w.WriteHeader(http.StatusNotFound) + return + } + if status != 0 { + w.WriteHeader(status) + } + _, _ = w.Write([]byte(body)) + })) + t.Cleanup(s.Close) + t.Setenv("CIO_API_URL", s.URL) + return s +} + +// fundsUsage is a wallet nine days into a 30-day period that has spent used +// dollars and has remaining left. +func fundsUsage(used, remaining float64) string { + today := time.Now().UTC() + return fmt.Sprintf(`{"start":%q,"end":%q,"balance":{"used":%g,"remaining":%g},"meters":{ + "email_sends":{"grant_total":0,"grant_used":0,"grant_remaining":0,"balance_used":5000,"balance_cost":%g,"total_used":5000}}}`, + today.AddDate(0, 0, -9).Format(time.DateOnly), today.AddDate(0, 0, 20).Format(time.DateOnly), used, remaining, used) +} + +func (s *fundsServer) requests() []string { + s.mu.Lock() + defer s.mu.Unlock() + return slices.Clone(s.paths) +} + +func (s *fundsServer) set(path string, status int, body string) { + s.mu.Lock() + defer s.mu.Unlock() + s.statuses[path] = status + if body != "" { + s.responses[path] = body + } +} + +func setupFundsTest(t *testing.T, token string) *fundsServer { + t.Helper() + _, cleanup := setupSendTest(t, token, "123") + t.Cleanup(cleanup) + return newFundsServer(t) +} + +func sendEmail(args ...string) (stdout, stderr string, err error) { + return executeCommand(append([]string{"send", "email", + "--environment-id", "123", + "--json", `{"identifiers":{"email":"test@example.com"},"to":"test@example.com","subject":"Hi","body":"Hi"}`, + }, args...)...) +} + +func fundsCachePath(t *testing.T, token string) string { + t.Helper() + return filepath.Join(os.Getenv("HOME"), ".cio", "cache", "funds", routes.CacheKeyDir(token)+".json") +} + +func readFundsState(t *testing.T, token string) fundsCheckState { + t.Helper() + data, err := os.ReadFile(fundsCachePath(t, token)) + if err != nil { + t.Fatal(err) + } + var state fundsCheckState + if err := json.Unmarshal(data, &state); err != nil { + t.Fatal(err) + } + return state +} + +// expectSendSucceeded checks that the send printed its result and exited +// cleanly, whatever the funds check did. +func expectSendSucceeded(t *testing.T, stdout, stderr string, err error) { + t.Helper() + if err != nil { + t.Fatalf("unexpected error: %v\nstderr: %s", err, stderr) + } + if !strings.Contains(stdout, "RKalBAUAAZ21_test==") { + t.Errorf("expected the send result on stdout, got %q", stdout) + } +} + +func TestFundsWarning_WarnsAfterEachMeteredSend(t *testing.T) { + txnJSON := `{"transactional_message_id":1,"identifiers":{"email":"test@example.com"}}` + cases := map[string][]string{ + "send email": {"send", "email", "--environment-id", "123", "--json", txnJSON}, + "transactional send email": {"transactional", "send", "email", "--environment-id", "123", "--json", txnJSON}, + "transactional send sms": {"transactional", "send", "sms", "--environment-id", "123", "--json", txnJSON}, + } + for name, args := range cases { + t.Run(name, func(t *testing.T) { + setupFundsTest(t, "sa_live_funds") + + stdout, stderr, err := executeCommand(args...) + expectSendSucceeded(t, stdout, stderr, err) + if got := strings.Count(stderr, `"code":"FUNDS_LOW"`); got != 1 { + t.Fatalf("expected one FUNDS_LOW warning, got %d in %q", got, stderr) + } + if !strings.Contains(stderr, `"warning":true`) || !strings.Contains(stderr, "Funds run out around") { + t.Errorf("expected the forecast's warning, got %q", stderr) + } + }) + } +} + +func TestFundsWarning_ChecksOnceAnHourPerToken(t *testing.T) { + server := setupFundsTest(t, "sa_live_funds") + + _, stderr, err := sendEmail() + if err != nil || !strings.Contains(stderr, "FUNDS_LOW") { + t.Fatalf("expected the first send to warn, got err=%v stderr=%q", err, stderr) + } + checked := len(server.requests()) + + stdout, stderr, err := sendEmail() + expectSendSucceeded(t, stdout, stderr, err) + if strings.Contains(stderr, "FUNDS_LOW") { + t.Errorf("expected no warning within the hour, got %q", stderr) + } + if got := len(server.requests()); got != checked { + t.Errorf("expected no reads within the hour, got %v", server.requests()[checked:]) + } + + // Another token, here from --token, has its own hour. + other := trackServer(t, "sa_live_other", "123") + defer other.Close() + t.Setenv("CIO_TRACK_URL", other.URL) + _, stderr, err = sendEmail("--token", "sa_live_other") + if err != nil || !strings.Contains(stderr, "FUNDS_LOW") { + t.Fatalf("expected another token's send to warn, got err=%v stderr=%q", err, stderr) + } + + entries, err := os.ReadDir(filepath.Dir(fundsCachePath(t, "sa_live_funds"))) + if err != nil { + t.Fatal(err) + } + for _, e := range entries { + data, _ := os.ReadFile(filepath.Join(filepath.Dir(fundsCachePath(t, "sa_live_funds")), e.Name())) + if strings.Contains(e.Name(), "sa_live") || strings.Contains(string(data), "sa_live") { + t.Errorf("the funds cache must not hold the token, found it in %s", e.Name()) + } + } +} + +func TestFundsWarning_IgnoresTheTrackHostFromAPIURL(t *testing.T) { + server := setupFundsTest(t, "sa_live_funds") + track := trackServer(t, "sa_live_funds", "123") + defer track.Close() + + stdout, stderr, err := sendEmail("--api-url", track.URL) + expectSendSucceeded(t, stdout, stderr, err) + if !strings.Contains(stderr, "FUNDS_LOW") { + t.Errorf("expected a warning from the UI API while --api-url names the Track host, got %q", stderr) + } + if !slices.Contains(server.requests(), "/v1/accounts/current") { + t.Errorf("expected the account read on the UI API, got %v", server.requests()) + } +} + +func TestFundsWarning_ReusesTheProfilesCachedAccessToken(t *testing.T) { + server := setupFundsTest(t, "sa_live_profile") + t.Setenv("CIO_TOKEN", "") + if err := client.WriteCredentials(&client.Credentials{ + ServiceAccountToken: "sa_live_profile", + AccountID: "1", + AccessToken: "jwt-cached", + AccessTokenExpiresAt: time.Now().Add(time.Hour), + }); err != nil { + t.Fatal(err) + } + + _, stderr, err := sendEmail() + if err != nil || !strings.Contains(stderr, "FUNDS_LOW") { + t.Fatalf("expected a warning, got err=%v stderr=%q", err, stderr) + } + if slices.Contains(server.requests(), "/v1/service_accounts/oauth/token") { + t.Errorf("expected the cached access token to be reused, got %v", server.requests()) + } + for _, auth := range server.auth { + if auth != "Bearer jwt-cached" { + t.Errorf("expected the cached access token on every read, got %q", auth) + } + } +} + +func TestFundsWarning_ReusesTheCachedAccountID(t *testing.T) { + server := setupFundsTest(t, "sa_live_funds") + if _, _, err := sendEmail(); err != nil { + t.Fatal(err) + } + if got := readFundsState(t, "sa_live_funds").AccountID; got != "1" { + t.Fatalf("expected the account ID to be cached, got %q", got) + } + + // Make the next check due. + if err := os.WriteFile(fundsCachePath(t, "sa_live_funds"), []byte(`{"account_id":"1","next_check_at":"2026-01-01T00:00:00Z"}`), 0o600); err != nil { + t.Fatal(err) + } + before := len(server.requests()) + _, stderr, err := sendEmail() + if err != nil || !strings.Contains(stderr, "FUNDS_LOW") { + t.Fatalf("expected a warning, got err=%v stderr=%q", err, stderr) + } + if slices.Contains(server.requests()[before:], "/v1/accounts/current") { + t.Errorf("expected the cached account ID to be used, got %v", server.requests()[before:]) + } +} + +func TestFundsWarning_WaitsADayOffFlex(t *testing.T) { + t.Run("account read says another plan", func(t *testing.T) { + server := setupFundsTest(t, "sa_live_funds") + server.set("/v1/accounts/current", 0, otherFundsAccount) + + stdout, stderr, err := sendEmail() + expectSendSucceeded(t, stdout, stderr, err) + if strings.Contains(stderr, "FUNDS_LOW") { + t.Errorf("expected no warning off Flex, got %q", stderr) + } + if slices.Contains(server.requests(), "/v1/accounts/1/billing/wallet/usage") { + t.Errorf("expected no wallet reads off Flex, got %v", server.requests()) + } + if next := readFundsState(t, "sa_live_funds").NextCheckAt; time.Until(next) < 23*time.Hour { + t.Errorf("expected the next check a day out, got %v", next) + } + }) + t.Run("wallet refuses the account", func(t *testing.T) { + server := setupFundsTest(t, "sa_live_funds") + server.set("/v1/accounts/1/billing/wallet/usage", http.StatusBadRequest, `{"errors":[{"detail":"not on Flex"}]}`) + + stdout, stderr, err := sendEmail() + expectSendSucceeded(t, stdout, stderr, err) + if strings.Contains(stderr, "FUNDS_LOW") || strings.Contains(stderr, "not on Flex") { + t.Errorf("expected a silent check, got %q", stderr) + } + if next := readFundsState(t, "sa_live_funds").NextCheckAt; time.Until(next) < 23*time.Hour { + t.Errorf("expected the next check a day out, got %v", next) + } + }) +} + +func TestFundsWarning_StaysSilentWhenTheCheckFails(t *testing.T) { + cases := []struct { + name string + path string + status int + body string + }{ + {"token exchange fails", "/v1/service_accounts/oauth/token", http.StatusUnauthorized, `{"error":"invalid_client"}`}, + {"account read fails", "/v1/accounts/current", http.StatusInternalServerError, "oops"}, + {"wallet forbidden", "/v1/accounts/1/billing/wallet/usage", http.StatusForbidden, `{"errors":[{"detail":"forbidden"}]}`}, + {"rates unreadable", "/v1/accounts/1/billing/wallet/rates", 0, "not json"}, + {"funds last the period", "/v1/accounts/1/billing/wallet/usage", 0, fundsUsage(10, 500)}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + server := setupFundsTest(t, "sa_live_funds") + server.set(tc.path, tc.status, tc.body) + + stdout, stderr, err := sendEmail() + expectSendSucceeded(t, stdout, stderr, err) + if stderr != "" { + t.Errorf("expected nothing on stderr, got %q", stderr) + } + if next := readFundsState(t, "sa_live_funds").NextCheckAt; time.Until(next) < 59*time.Minute { + t.Errorf("expected a failed check to still count for the hour, got %v", next) + } + }) + } +} + +func TestFundsWarning_GivesUpAtTheTimeout(t *testing.T) { + server := setupFundsTest(t, "sa_live_funds") + server.mu.Lock() + server.block["/v1/accounts/1/billing/wallet/usage"] = true + server.mu.Unlock() + saved := fundsCheckTimeout + fundsCheckTimeout = 100 * time.Millisecond + t.Cleanup(func() { fundsCheckTimeout = saved }) + + start := time.Now() + stdout, stderr, err := sendEmail() + expectSendSucceeded(t, stdout, stderr, err) + if elapsed := time.Since(start); elapsed > 2*time.Second { + t.Errorf("expected the check to give up at its timeout, took %v", elapsed) + } + if stderr != "" { + t.Errorf("expected nothing on stderr, got %q", stderr) + } +} + +func TestFundsWarning_SkipsFreeSendsDryRunsAndFailedSends(t *testing.T) { + txnJSON := `{"transactional_message_id":1,"identifiers":{"email":"test@example.com"}}` + cases := map[string][]string{ + "push": {"transactional", "send", "push", "--environment-id", "123", "--json", txnJSON}, + "inbox": {"transactional", "send", "inbox", "--environment-id", "123", "--json", txnJSON}, + "dry run": {"send", "email", "--environment-id", "123", "--json", txnJSON, "--dry-run"}, + "failed send": {"send", "email", "--environment-id", "123", "--json", txnJSON, "--token", "sa_live_wrong"}, + } + for name, args := range cases { + t.Run(name, func(t *testing.T) { + server := setupFundsTest(t, "sa_live_funds") + + _, stderr, _ := executeCommand(args...) + if strings.Contains(stderr, "FUNDS_LOW") { + t.Errorf("expected no warning, got %q", stderr) + } + if got := server.requests(); len(got) != 0 { + t.Errorf("expected no funds check, got %v", got) + } + }) + } +} + +func TestFundsWarning_SkipsWhileAnotherSendHoldsTheLock(t *testing.T) { + server := setupFundsTest(t, "sa_live_funds") + dir := filepath.Dir(fundsCachePath(t, "sa_live_funds")) + if err := os.MkdirAll(dir, 0o700); err != nil { + t.Fatal(err) + } + unlock, err := filelock.TryLock(filepath.Join(dir, "funds.lock"), 0o600) + if err != nil { + t.Fatal(err) + } + defer unlock() + + stdout, stderr, err := sendEmail() + expectSendSucceeded(t, stdout, stderr, err) + if got := server.requests(); len(got) != 0 { + t.Errorf("expected no funds check while the lock is held, got %v", got) + } +} diff --git a/cmd/prime_context.md b/cmd/prime_context.md index 57f233c..5a43e5f 100644 --- a/cmd/prime_context.md +++ b/cmd/prime_context.md @@ -20,6 +20,14 @@ If `cio auth status` fails, read `cio skills read cio/auth.md` before acting. It decides between signup (no account yet) and login (account exists, no usable token). Run either flow yourself; don't ask the user to run commands. +If the user has no Customer.io account, create one for them with +`cio auth signup start`, then `cio auth signup verify` with the code the user +gets by email. Before `signup start`, ask the user which email address to sign +up with, even if you already know one for them, and wait for the answer. Also +ask which data center (US or EU) the account belongs in. Wait for the emailed +code and never guess one. `verify` saves the credentials, so no login is needed +afterward. + For login, run the waiting login and, in the same message, give the user these steps (they may not see the command's output): diff --git a/cmd/prime_test.go b/cmd/prime_test.go index 58a6704..c757987 100644 --- a/cmd/prime_test.go +++ b/cmd/prime_test.go @@ -19,6 +19,20 @@ func TestPrime_UsesDefaultTokenPageURL(t *testing.T) { } } +func TestPrime_NamesSignupForNewAccounts(t *testing.T) { + t.Setenv("HOME", t.TempDir()) + + stdout, _, err := executeCommand("prime") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + for _, want := range []string{"cio auth signup start", "cio auth signup verify", "ask the user which email address"} { + if !strings.Contains(stdout, want) { + t.Errorf("expected prime output to name %q", want) + } + } +} + func TestPrime_UsesResolvedTokenPageURL(t *testing.T) { t.Setenv("HOME", t.TempDir()) t.Setenv("CIO_API_URL", "") diff --git a/cmd/root.go b/cmd/root.go index e45b56e..0781079 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -45,7 +45,8 @@ Base URLs: EU: https://eu.fly.customer.io Use 'cio auth login' to open the browser-based login flow, or set CIO_TOKEN -for direct token-based usage.`, +for direct token-based usage. If you don't have a Customer.io account yet, +'cio auth signup' creates one.`, SilenceUsage: true, SilenceErrors: true, RunE: func(cmd *cobra.Command, args []string) error { diff --git a/cmd/send.go b/cmd/send.go index bf3238a..a2d6394 100644 --- a/cmd/send.go +++ b/cmd/send.go @@ -327,6 +327,8 @@ func runTrackSend(cmd *cobra.Command, sendPath string, body json.RawMessage) err if err != nil { return handleWriteError(cmd, http.MethodPost, err) } + // The send was accepted, so after its output, warn if funds are running low. + defer warnIfFundsLow(cmd, sendPath, saToken) watch, _ := cmd.Flags().GetBool("watch") if !watch { diff --git a/cmd/send_test.go b/cmd/send_test.go index bd4b168..8e887f6 100644 --- a/cmd/send_test.go +++ b/cmd/send_test.go @@ -53,6 +53,7 @@ func setupSendTest(t *testing.T, token, workspaceID string) (*httptest.Server, f t.Setenv("CIO_ENVIRONMENT_ID", "") server := trackServer(t, token, workspaceID) t.Setenv("CIO_TRACK_URL", server.URL) + stubUIAPI(t) return server, func() { server.Close() } } diff --git a/cmd/skills_install_test.go b/cmd/skills_install_test.go index 6c87915..6cfd848 100644 --- a/cmd/skills_install_test.go +++ b/cmd/skills_install_test.go @@ -451,6 +451,16 @@ func TestBootstrapBodyCarriesMarker(t *testing.T) { } } +// An agent reads the installed skill before cio is installed or the user has an +// account, so the body itself names the install command and signup. +func TestBootstrapBodyNamesInstallAndSignup(t *testing.T) { + for _, want := range []string{"npm install -g @customerio/cli", "cio auth signup"} { + if !strings.Contains(bootstrapSkillBody, want) { + t.Errorf("expected the bootstrap body to contain %q, got:\n%s", want, bootstrapSkillBody) + } + } +} + // A target we cannot clean must not strand the others. func TestRemoveLegacyInstallsContinuesPastAFailure(t *testing.T) { if len(installTargets) < 2 { diff --git a/cmd/transactional_test.go b/cmd/transactional_test.go index dcb9d10..3fd3f61 100644 --- a/cmd/transactional_test.go +++ b/cmd/transactional_test.go @@ -22,6 +22,7 @@ func setupTransactionalSendTest(t *testing.T, token, workspaceID string) (*httpt t.Setenv("CIO_ENVIRONMENT_ID", "") server := trackServer(t, token, workspaceID) t.Setenv("CIO_TRACK_URL", server.URL) + stubUIAPI(t) return server, func() { server.Close() } } diff --git a/go.mod b/go.mod index e72ceb0..924cf37 100644 --- a/go.mod +++ b/go.mod @@ -5,6 +5,7 @@ go 1.26.1 require ( github.com/charmbracelet/lipgloss v1.1.0 github.com/itchyny/gojq v0.12.18 + github.com/mattn/go-isatty v0.0.20 github.com/spf13/cobra v1.10.2 github.com/spf13/pflag v1.0.9 golang.org/x/sys v0.43.0 @@ -22,7 +23,6 @@ require ( github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/itchyny/timefmt-go v0.1.7 // indirect github.com/lucasb-eyer/go-colorful v1.2.0 // indirect - github.com/mattn/go-isatty v0.0.20 // indirect github.com/mattn/go-runewidth v0.0.19 // indirect github.com/muesli/termenv v0.16.0 // indirect github.com/rivo/uniseg v0.4.7 // indirect diff --git a/internal/routes/cache.go b/internal/routes/cache.go index 0b5d0b4..01876f2 100644 --- a/internal/routes/cache.go +++ b/internal/routes/cache.go @@ -126,15 +126,15 @@ func (o *LoadRegistryOptions) resolveCacheDir() (string, error) { base = filepath.Join(home, ".cio", specCacheSubdir) } if o.CacheKey != "" { - return filepath.Join(base, cacheKeyDir(o.CacheKey)), nil + return filepath.Join(base, CacheKeyDir(o.CacheKey)), nil } return base, nil } -// cacheKeyDir returns a short, filesystem-safe subdirectory name for a cache -// key: a hash, so a secret used as the key never lands on disk in the clear, -// and distinct keys never share a directory. -func cacheKeyDir(key string) string { +// CacheKeyDir returns a short, filesystem-safe name for a cache key's +// subdirectory or file: a hash, so a secret used as the key never lands on +// disk in the clear, and distinct keys never share an entry. +func CacheKeyDir(key string) string { h := sha256.Sum256([]byte(key)) return "auth-" + hex.EncodeToString(h[:8]) } diff --git a/internal/routes/cache_test.go b/internal/routes/cache_test.go index f52a1ff..3cc75b1 100644 --- a/internal/routes/cache_test.go +++ b/internal/routes/cache_test.go @@ -471,14 +471,14 @@ func TestEnsureSpecs_AuthenticatedCacheIsolation(t *testing.T) { } // Authenticated specs should be in a key-specific subdir. - authDir := filepath.Join(cacheDir, cacheKeyDir(token)) + authDir := filepath.Join(cacheDir, CacheKeyDir(token)) if _, err := os.Stat(filepath.Join(authDir, "openapi.json")); err != nil { t.Errorf("authenticated cache missing: %v", err) } // Different keys should use different subdirs. token2 := "test-service-account-token-b" - if cacheKeyDir(token) == cacheKeyDir(token2) { + if CacheKeyDir(token) == CacheKeyDir(token2) { t.Error("different keys should produce different cache directories") } } diff --git a/skills/cio/SKILL.md b/skills/cio/SKILL.md index a1734c6..5941bea 100644 --- a/skills/cio/SKILL.md +++ b/skills/cio/SKILL.md @@ -20,11 +20,13 @@ skill-reading guidance. cio prime ``` -If `cio` is not installed, fetch current installation instructions from the -README: +If the user has no Customer.io account, create one with `cio auth signup`. +`cio prime` explains the flow. + +If `cio` is not installed, install it with npm: ```bash -curl -fsSL https://raw.githubusercontent.com/customerio/cli/main/README.md +npm install -g @customerio/cli ``` Use runtime references instead of copying large playbooks into this skill: