Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 14 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -144,7 +144,7 @@ CIO_TOKEN=sa_live_xxx cio api /v1/environments/{environment_id}/campaigns --para

Use `cio api <path>` for any API endpoint. Path placeholders are resolved from `--params`. The HTTP method defaults to GET (or POST if `--json` is provided); override with `-X`:

`--params` takes a JSON object; URL-query syntax (`--params 'type=event&size=200'`) is also accepted. In the query form a bare `+` is rejected as ambiguous — write `%20` for a space and `%2B` for a plus (`email=a%2Btag@b.com`), or use the JSON form, where both are literal.
`--params` takes a JSON object; URL-query syntax (`--params 'type=event&size=200'`) is also accepted. Repeat `--params` to split values across flags (`--params type=event --params size=200`); a key given more than once is an error. In the query form a bare `+` is rejected as ambiguous — write `%20` for a space and `%2B` for a plus (`email=a%2Btag@b.com`), or use the JSON form, where both are literal.

```bash
# List campaigns in workspace 123
Expand Down Expand Up @@ -237,6 +237,19 @@ cio api /v1/environments/{environment_id}/campaigns \
| `--limit <n>` | | Page size |
| `--page-all` | | Auto-paginate, emit NDJSON |

`cio api` retries timeouts for GET and HEAD, and for writes only when the request
was not sent. Writes whose response times out after sending are not retried because
they may already have applied. `--timeout` still bounds each HTTP attempt and
defaults to 30s.

Timeouts share the existing retry limit and exponential backoff with HTTP 429 and
retryable 5xx responses. `CIO_MAX_RETRIES` defaults to 3 retries, or 4 total
attempts; set it to 0 to disable these retries. Caller cancellation or an expired
caller deadline stops retries. Repeated timeouts can therefore take longer than
`--timeout` overall. If `--page-all` exhausts its retries, it exits with an error
and leaves any earlier pages in stdout; callers must check its exit status before
using the output as a complete result.

## Exit Codes

| Code | Meaning |
Expand Down
10 changes: 5 additions & 5 deletions cmd/api.go
Original file line number Diff line number Diff line change
Expand Up @@ -86,7 +86,7 @@ func runAPI(cmd *cobra.Command, args []string) error {
httpMethod := resolveMethod(methodFlag, jsonBody != nil || len(fileParts) > 0)

// Parse --params: separate path params from query params.
paramsRaw, _ := cmd.Flags().GetString("params")
paramsRaw, _ := cmd.Flags().GetStringArray("params")
pathParams, queryParams, err := parseAPIParams(pathTemplate, paramsRaw)
if err != nil {
output.PrintError(output.CodeValidationError, err.Error(), nil)
Expand Down Expand Up @@ -377,20 +377,20 @@ func extractPathParamNames(pathTemplate string) map[string]bool {

// parseAPIParams separates path template params from query params.
// Path params are those matching {placeholder} in the path template.
// Input is validated through validate.ValidateParams (JSON object or
// Each --params value is validated and merged through validate.MergeParams (JSON object or
// query-string sugar; keys must match [a-zA-Z0-9_]+ with an optional []
// suffix, values must not contain control characters, see
// MaxParamValueLength). Path params are validated as safe URL path segments;
// the API remains the source of truth for endpoint-specific ID semantics.
func parseAPIParams(pathTemplate, paramsJSON string) (pathParams, queryParams map[string]string, err error) {
func parseAPIParams(pathTemplate string, paramsRaw []string) (pathParams, queryParams map[string]string, err error) {
pathParams = make(map[string]string)
queryParams = make(map[string]string)

if strings.TrimSpace(paramsJSON) == "" {
if len(paramsRaw) == 0 {
return pathParams, queryParams, nil
}

params, err := validate.ValidateParams(paramsJSON)
params, err := validate.MergeParams(paramsRaw)
if err != nil {
return nil, nil, err
}
Expand Down
53 changes: 53 additions & 0 deletions cmd/api_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -193,6 +193,59 @@ func TestAPI_DryRun(t *testing.T) {
}
}

func TestAPI_RepeatedParamsMerge(t *testing.T) {
server, cleanup := setupAPITest(t)
defer cleanup()

stdout, _, err := executeCommand("api", "/v1/environments/{environment_id}/campaigns",
"--api-url", server.URL,
"--params", "environment_id=123",
"--params", "action_id=87",
"--params", `{"size":500}`,
"--params", "end_ts=5",
"--dry-run")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}

var result struct {
URL string `json:"url"`
Params map[string]string `json:"params"`
}
if err := json.Unmarshal([]byte(stdout), &result); err != nil {
t.Fatalf("invalid JSON: %v", err)
}
if result.URL != server.URL+"/v1/environments/123/campaigns" {
t.Errorf("expected resolved URL, got %v", result.URL)
}
want := map[string]string{"action_id": "87", "size": "500", "end_ts": "5"}
if len(result.Params) != len(want) {
t.Fatalf("expected params %v, got %v", want, result.Params)
}
for k, v := range want {
if result.Params[k] != v {
t.Errorf("param %q: want %q, got %q", k, v, result.Params[k])
}
}
}

func TestAPI_RepeatedParamsDuplicateKey(t *testing.T) {
server, cleanup := setupAPITest(t)
defer cleanup()

_, _, err := executeCommand("api", "/v1/environments/{environment_id}/campaigns",
"--api-url", server.URL,
"--params", "environment_id=123&id=1",
"--params", "id=2",
"--dry-run")
if err == nil {
t.Fatal("expected error for a key repeated across --params flags")
}
if !strings.Contains(err.Error(), "appears more than once") {
t.Errorf("expected duplicate-key error, got: %v", err)
}
}

func TestAPI_MissingPathParam(t *testing.T) {
server, cleanup := setupAPITest(t)
defer cleanup()
Expand Down
2 changes: 1 addition & 1 deletion cmd/auth.go
Original file line number Diff line number Diff line change
Expand Up @@ -631,7 +631,7 @@ func runLoginCLILink(cmd *cobra.Command, saToken string) error {
}
timeout, _ := cmd.Flags().GetDuration("timeout")

resp, err := client.MintLoginCLILink(cmd.Context(), baseURL, saToken, timeout)
resp, err := client.MintLoginCLILink(cmd.Context(), baseURL, saToken, "", timeout)
if err != nil {
return handleAPIError(err)
}
Expand Down
10 changes: 9 additions & 1 deletion cmd/auth_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,11 @@ func executeCommand(args ...string) (stdout, stderr string, err error) {

// Reset persistent flags before each test.
_ = rootCmd.PersistentFlags().Set("jq", "")
_ = rootCmd.PersistentFlags().Set("params", "")
_ = rootCmd.PersistentFlags().Set("raw-output", "false")
_ = rootCmd.PersistentFlags().Set("read-only", "false")
if f, ok := rootCmd.PersistentFlags().Lookup("params").Value.(pflag.SliceValue); ok {
_ = f.Replace(nil)
}
_ = rootCmd.PersistentFlags().Set("json", "")
_ = rootCmd.PersistentFlags().Set("dry-run", "false")
_ = rootCmd.PersistentFlags().Set("api-url", "")
Expand Down Expand Up @@ -489,6 +493,10 @@ func TestAuthLogin_StoredTokenTriggersWebHandoff(t *testing.T) {
if got := r.Header.Get("Authorization"); got != "Bearer sa_live_existing" {
t.Errorf("expected Bearer auth with stored token, got %q", got)
}
// No destination, so the link keeps landing on the dashboard home.
if body, _ := io.ReadAll(r.Body); string(body) != "{}" {
t.Errorf("expected an empty JSON body, got %q", body)
}
_, _ = w.Write([]byte(`{"handoff_token":"handoff-jwt-abc","expires_in":60}`))
return
}
Expand Down
23 changes: 23 additions & 0 deletions cmd/billing.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
package cmd

import (
"github.com/spf13/cobra"
)

var billingCmd = &cobra.Command{
Use: "billing",
Short: "Add funds to a Flex account",
// Hidden until the Flex plan launches. The commands still run.
Hidden: true,
Long: `Billing commands for Flex accounts, which pay for usage from a prepaid balance.

Examples:
cio billing add-funds`,
RunE: func(cmd *cobra.Command, args []string) error {
return cmd.Help()
},
}

func init() {
rootCmd.AddCommand(billingCmd)
}
197 changes: 197 additions & 0 deletions cmd/billing_add_funds.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,197 @@
package cmd

import (
"encoding/json"
"errors"
"fmt"
"net/http"
"net/url"
"strings"

"github.com/customerio/cli/internal/client"
"github.com/customerio/cli/internal/output"
"github.com/spf13/cobra"
)

var billingAddFundsCmd = &cobra.Command{
Use: "add-funds",
Short: "Print a sign-in link that opens Add funds",
Long: `Print a one-time link that signs the browser in to Customer.io and opens
Add funds on the billing page. The CLI never charges anything: the person
picks an amount and pays on that page.

The link is valid for 60 seconds and works once. If it expires, run the
command again for a fresh one.

Flex accounts only. The person who created the token needs permission to
manage billing.

An account that requires SSO can't use sign-in links, so the command prints
the billing page URL instead, with sign_in_required set. The person opens it
and signs in with SSO there.

Output:
{"status":"ok","url":"https://fly.customer.io/cli?token=...","expires_in":60}
{"status":"ok","url":"https://fly.customer.io/settings/billing/...","sign_in_required":true}

Examples:
cio billing add-funds
cio billing add-funds --jq .url -r`,
Args: cobra.NoArgs,
RunE: runBillingAddFunds,
}

func init() {
billingCmd.AddCommand(billingAddFundsCmd)
}

// flexBillingVendor is the billing_vendor the account read reports for Flex.
const flexBillingVendor = "modern_stripe"

// addFundsPagePath is the billing page with Add funds open, where an add_funds
// sign-in link lands. from=cli reports the purchase as one started from the CLI.
const addFundsPagePath = "/settings/billing/your-plan?purchaseModal=account-funds&from=cli"

func runBillingAddFunds(cmd *cobra.Command, args []string) error {
c := clientFromCmd(cmd)
if c == nil {
return errNoClient(cmd)
}

// The link is minted outside the client's request path, which is where
// --read-only is otherwise enforced, and the session it opens can spend.
if c.ReadOnly() {
err := fmt.Errorf("read-only mode: add-funds creates a sign-in link (use without --read-only to allow it)")
output.PrintError(output.CodeValidationError, err.Error(), nil)
return err
}

// Only a service account token can mint a link, and a pre-exchanged access
// token may belong to a different account than the one it would mint for.
if client.ResolveAccessToken() != "" {
err := fmt.Errorf("add-funds can't run with CIO_ACCESS_TOKEN set: the sign-in link needs a service account token")
output.PrintError(output.CodeAuthError, err.Error(), map[string]any{
"hint": "Unset CIO_ACCESS_TOKEN and sign in with 'cio auth login', or set CIO_TOKEN to a service account token.",
})
return err
}
saToken := c.ServiceAccountToken()
if saToken == "" {
err := fmt.Errorf("not authenticated. Run 'cio auth login' or set CIO_TOKEN")
output.PrintError(output.CodeAuthError, err.Error(), nil)
return err
}

raw, err := c.Do(cmd.Context(), "GET", "/v1/accounts/current", nil, nil)
if err != nil {
return handleAPIError(err)
}
var resp struct {
Account struct {
BillingVendor string `json:"billing_vendor"`
SSOSetting string `json:"sso_setting"`
} `json:"account"`
}
if err := json.Unmarshal(raw, &resp); err != nil {
output.PrintError(output.CodeGeneralError, "failed to parse account response", nil)
return err
}

if resp.Account.BillingVendor != flexBillingVendor {
err := fmt.Errorf("add funds is only available on the Flex plan")
output.PrintError(output.CodeValidationError, err.Error(), map[string]any{
"hint": "This account isn't on Flex, so it has no balance to add funds to. Its plan is under Settings → Billing in Customer.io.",
})
return err
}

// The sign-in link refuses an account that requires SSO, so send the
// person to the page itself to sign in there.
if resp.Account.SSOSetting == "required" {
pageURL := resolveUIBase(c.BaseURL()) + addFundsPagePath
if GetDryRun(cmd) {
return output.FprintJSON(cmd.OutOrStdout(), map[string]any{
"dry_run": true,
"url": pageURL,
"sign_in_required": true,
})
}
fmt.Fprintf(cmd.ErrOrStderr(), "This account signs in with SSO, so the CLI can't sign you in. Open this URL in your browser and sign in with SSO to add funds:\n\n %s\n\n", pageURL)
return printAddFundsResult(cmd, map[string]any{
"status": "ok",
"message": "Open the URL in your browser and sign in with SSO to add funds.",
"url": pageURL,
"sign_in_required": true,
})
}

body := map[string]string{"destination": client.LoginCLILinkDestinationAddFunds}
if GetDryRun(cmd) {
return output.FprintJSON(cmd.OutOrStdout(), map[string]any{
"dry_run": true,
"method": "POST",
"url": c.BaseURL() + "/v1/login_cli/link",
"headers": map[string]string{
"Authorization": "Bearer [REDACTED]",
"Content-Type": "application/json",
},
"body": body,
})
}

timeout, _ := cmd.Flags().GetDuration("timeout")
link, err := client.MintLoginCLILink(cmd.Context(), c.BaseURL(), saToken, client.LoginCLILinkDestinationAddFunds, timeout)
if err != nil {
return handleAddFundsError(err)
}

linkURL := resolveCLILoginURL(c.BaseURL()) + "?token=" + url.QueryEscape(link.HandoffToken)

fmt.Fprintf(cmd.ErrOrStderr(), "Open this URL in your browser to add funds to your Customer.io account:\n\n %s\n\n", linkURL)
fmt.Fprintf(cmd.ErrOrStderr(), "This link is valid for %d seconds and works once. If it expires, run 'cio billing add-funds' again.\n", link.ExpiresIn)

return printAddFundsResult(cmd, map[string]any{
"status": "ok",
"message": "Open the URL in your browser to add funds.",
"url": linkURL,
"expires_in": link.ExpiresIn,
})
}

func printAddFundsResult(cmd *cobra.Command, result map[string]any) error {
data, _ := json.Marshal(result)
return output.FprintProcess(cmd.OutOrStdout(), json.RawMessage(data), GetJQFlag(cmd), GetRawFlag(cmd))
}

// handleAddFundsError passes on the server's reason for refusing a link, which
// says what plan and permission adding funds needs, where handleAPIError
// would reduce a 403 to "Insufficient permissions."
func handleAddFundsError(err error) error {
var apiErr *client.APIError
if errors.As(err, &apiErr) && (apiErr.StatusCode == http.StatusBadRequest || apiErr.StatusCode == http.StatusForbidden) {
if detail := apiErrorDetail(apiErr.Body); detail != "" {
code := output.CodeValidationError
if apiErr.StatusCode == http.StatusForbidden {
code = output.CodeAuthzError
}
output.PrintError(code, detail, map[string]any{
"status_code": apiErr.StatusCode,
})
return errors.New(detail)
}
}
return handleAPIError(err)
}

// apiErrorDetail reads the first message from an {"errors":[{"detail":...}]} body.
func apiErrorDetail(body []byte) string {
var parsed struct {
Errors []struct {
Detail string `json:"detail"`
} `json:"errors"`
}
if json.Unmarshal(body, &parsed) != nil || len(parsed.Errors) == 0 {
return ""
}
return strings.TrimSpace(parsed.Errors[0].Detail)
}
Loading
Loading