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
34 changes: 34 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,39 @@ cio auth logout
When you use `CIO_TOKEN` or `--token` directly on normal commands, you may
also need `CIO_REGION=us|eu` or `--api-url`.

### Profiles

Keep multiple credential sets — production, staging, several client accounts —
in one config file and switch between them. Each profile has its own token,
region, and optional custom base URL.

```bash
# Log into a named profile (custom --api-url is remembered; switches to it)
echo "$STAGING_TOKEN" | cio auth login --profile staging --with-token --api-url https://<staging-host>

# List profiles (the saved default is marked current)
cio profile list

# Use a profile for one command…
cio --profile staging api /v1/campaigns
# …or a whole session
export CIO_PROFILE=staging

# Change the default, or remove a profile
cio profile use default
cio profile remove staging
```

Profile resolution: `--profile` flag → `CIO_PROFILE` → stored `current_profile`
→ `default`. An existing single-credential config is migrated to a `default`
profile automatically. Profile names may contain letters, digits, `.`, `-`,
and `_`.

`cio auth login` without `--profile` re-authenticates whichever profile is
currently selected, not always `default` — pass `--profile <name>` to target a
specific one. `cio profile list` marks the saved default (`current_profile`),
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)
Expand Down Expand Up @@ -189,6 +222,7 @@ cio api /v1/environments/{environment_id}/campaigns \
| Flag | Env Var | Description |
|---|---|---|
| `--token <value>` | `CIO_TOKEN` | Service account token override |
| `--profile <name>` | `CIO_PROFILE` | Configuration profile to use |
| `-X, --method` | | HTTP method override (default: GET, or POST if --json) |
| `--json <payload>` | | Raw JSON request body or `@filename` to read from file |
| `--params <json>` | | Query parameters as JSON → query string |
Expand Down
27 changes: 24 additions & 3 deletions cmd/auth.go
Original file line number Diff line number Diff line change
Expand Up @@ -131,7 +131,11 @@ moment you copy it:

For CI or non-interactive use:
$ echo "$TOKEN" | cio auth login --with-token
$ cio auth login <token>`,
$ cio auth login <token>

Credentials are saved to the active profile and that profile becomes current.
Pass --profile <name> to log into a specific profile; without it, login
re-authenticates whichever profile is currently selected (see 'cio profile').`,
Args: cobra.MaximumNArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
withToken, _ := cmd.Flags().GetBool("with-token")
Expand Down Expand Up @@ -226,14 +230,28 @@ For CI or non-interactive use:
AccessTokenExpiresAt: time.Now().Add(time.Duration(result.ExpiresIn) * time.Second),
}

// Persist a custom base URL (e.g. staging) so the profile reuses it
// without re-passing --api-url on every command.
if apiURL := resolveLoginAPIURL(cmd); apiURL != "" {
creds.APIURL = apiURL
}

if err := client.WriteCredentials(creds); err != nil {
output.PrintError(output.CodeGeneralError, err.Error(), nil)
return err
}

// Logging into a profile makes it the active one for later commands.
profile := client.ActiveProfileName()
if err := client.SetCurrentProfile(profile); err != nil {
output.PrintError(output.CodeGeneralError, err.Error(), nil)
return err
}

return output.FprintJSON(cmd.OutOrStdout(), map[string]any{
"status": "ok",
"message": "Authenticated successfully. Credentials saved to ~/.cio/config.json",
"profile": profile,
"account_id": result.AccountID,
"region": result.Region,
"base_url": result.BaseURL,
Expand All @@ -250,17 +268,19 @@ For CI or non-interactive use:
var authLogoutCmd = &cobra.Command{
Use: "logout",
Short: "Remove stored authentication credentials",
Long: "Delete the stored credentials from ~/.cio/config.json.",
Long: "Delete the active profile's stored credentials from ~/.cio/config.json.",
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, args []string) error {
profile := client.ActiveProfileName()
if err := client.DeleteCredentials(); err != nil {
output.PrintError(output.CodeGeneralError, err.Error(), nil)
return err
}

return output.FprintJSON(cmd.OutOrStdout(), map[string]any{
"status": "ok",
"message": "Credentials removed from ~/.cio/config.json",
"message": fmt.Sprintf("Credentials for profile %q removed from ~/.cio/config.json", profile),
"profile": profile,
})
},
}
Expand Down Expand Up @@ -310,6 +330,7 @@ Token resolution order:

statusResult := map[string]any{
"status": "authenticated",
"profile": client.ActiveProfileName(),
"token_source": tokenSource,
"token": client.MaskToken(token),
}
Expand Down
132 changes: 51 additions & 81 deletions cmd/auth_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,9 @@ func executeCommand(args ...string) (stdout, stderr string, err error) {
_ = rootCmd.PersistentFlags().Set("api-url", "")
_ = rootCmd.PersistentFlags().Set("token", "")
_ = rootCmd.PersistentFlags().Set("scope", "")
_ = rootCmd.PersistentFlags().Set("profile", "")
// Clear the package-level profile selection so it doesn't leak between runs.
client.SetActiveProfile("")

// Reset local flags on subcommands that persist across test runs.
if f := apiCmd.Flags().Lookup("method"); f != nil {
Expand Down Expand Up @@ -150,22 +153,17 @@ func TestAuthLogin_SavesToken(t *testing.T) {
t.Errorf("expected status ok, got %v", result["status"])
}

// Verify file was written.
data, err := os.ReadFile(filepath.Join(tmpDir, ".cio", "config.json"))
// Verify the active profile's credentials were written.
creds, err := client.ReadCredentials()
if err != nil {
t.Fatalf("failed to read config file: %v", err)
t.Fatalf("failed to read credentials: %v", err)
}

var creds map[string]any
if err := json.Unmarshal(data, &creds); err != nil {
t.Fatalf("invalid JSON in config file: %v", err)
}
if creds["service_account_token"] != "sa_live_test123" {
t.Errorf("expected sa_live_test123, got %v", creds["service_account_token"])
if creds.ServiceAccountToken != "sa_live_test123" {
t.Errorf("expected sa_live_test123, got %v", creds.ServiceAccountToken)
}
// Should have cached the JWT.
if creds["access_token"] != "jwt-test-session" {
t.Errorf("expected cached JWT, got %v", creds["access_token"])
if creds.AccessToken != "jwt-test-session" {
t.Errorf("expected cached JWT, got %v", creds.AccessToken)
}

// Verify file permissions.
Expand Down Expand Up @@ -199,19 +197,15 @@ func TestAuthLogin_SavesSandboxToken(t *testing.T) {
t.Errorf("expected status ok, got %v", result["status"])
}

data, err := os.ReadFile(filepath.Join(tmpDir, ".cio", "config.json"))
creds, err := client.ReadCredentials()
if err != nil {
t.Fatalf("failed to read config file: %v", err)
}
var creds map[string]any
if err := json.Unmarshal(data, &creds); err != nil {
t.Fatalf("invalid JSON in config file: %v", err)
t.Fatalf("failed to read credentials: %v", err)
}
if creds["service_account_token"] != "sa_sandbox_test123" {
t.Errorf("expected sandbox token saved, got %v", creds["service_account_token"])
if creds.ServiceAccountToken != "sa_sandbox_test123" {
t.Errorf("expected sandbox token saved, got %v", creds.ServiceAccountToken)
}
if creds["access_token"] != "jwt-test-session" {
t.Errorf("expected cached JWT, got %v", creds["access_token"])
if creds.AccessToken != "jwt-test-session" {
t.Errorf("expected cached JWT, got %v", creds.AccessToken)
}
}

Expand Down Expand Up @@ -300,16 +294,12 @@ func TestAuthLogin_InvalidTokenPreservesExistingCredentials(t *testing.T) {
}

// Previous working credentials must remain intact.
data, err := os.ReadFile(filepath.Join(tmpDir, ".cio", "config.json"))
creds, err := client.ReadCredentials()
if err != nil {
t.Fatalf("prior config file should survive failed login: %v", err)
t.Fatalf("prior credentials should survive failed login: %v", err)
}
var creds map[string]any
if err := json.Unmarshal(data, &creds); err != nil {
t.Fatalf("invalid JSON: %v", err)
}
if creds["service_account_token"] != "sa_live_previous" {
t.Errorf("prior token clobbered by failed login, got %v", creds["service_account_token"])
if creds.ServiceAccountToken != "sa_live_previous" {
t.Errorf("prior token clobbered by failed login, got %v", creds.ServiceAccountToken)
}
}

Expand Down Expand Up @@ -337,17 +327,13 @@ func TestAuthLogin_DiscoverEURegion(t *testing.T) {
t.Errorf("expected data_center 'eu', got %v", result["data_center"])
}

// Verify config file has the discovered region.
data, err := os.ReadFile(filepath.Join(tmpDir, ".cio", "config.json"))
// Verify the stored profile has the discovered region.
creds, err := client.ReadCredentials()
if err != nil {
t.Fatalf("failed to read config: %v", err)
t.Fatalf("failed to read credentials: %v", err)
}
var creds map[string]any
if err := json.Unmarshal(data, &creds); err != nil {
t.Fatalf("invalid JSON: %v", err)
}
if creds["region"] != "eu" {
t.Errorf("expected region 'eu' in config, got %v", creds["region"])
if creds.Region != "eu" {
t.Errorf("expected region 'eu' in config, got %v", creds.Region)
}
}

Expand Down Expand Up @@ -841,23 +827,19 @@ func TestAuthSignupVerify_ReturnsBootstrapToken(t *testing.T) {
}

// verify should persist the bootstrap token + account_id to ~/.cio/config.json.
data, err := os.ReadFile(filepath.Join(tmpDir, ".cio", "config.json"))
creds, err := client.ReadCredentials()
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)
t.Fatalf("expected credentials written, got: %v", err)
}
if creds["service_account_token"] != "sa_live_bootstrap" {
t.Errorf("expected service_account_token saved, got %v", creds["service_account_token"])
if creds.ServiceAccountToken != "sa_live_bootstrap" {
t.Errorf("expected service_account_token saved, got %v", creds.ServiceAccountToken)
}
if creds["account_id"] != "1" {
t.Errorf("expected account_id=1, got %v", creds["account_id"])
if creds.AccountID != "1" {
t.Errorf("expected account_id=1, got %v", creds.AccountID)
}
// 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"])
if creds.Region != "eu" {
t.Errorf("expected region=eu (from response data_center), got %v", creds.Region)
}
}

Expand All @@ -884,22 +866,18 @@ func TestAuthSignupVerify_ReturnsSandboxBootstrapToken(t *testing.T) {
t.Errorf("expected sandbox bootstrap token, got %v", result["token"])
}

data, err := os.ReadFile(filepath.Join(tmpDir, ".cio", "config.json"))
creds, err := client.ReadCredentials()
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)
t.Fatalf("expected credentials written, got: %v", err)
}
if creds["service_account_token"] != "sa_sandbox_bootstrap" {
t.Errorf("expected sandbox token saved, got %v", creds["service_account_token"])
if creds.ServiceAccountToken != "sa_sandbox_bootstrap" {
t.Errorf("expected sandbox token saved, got %v", creds.ServiceAccountToken)
}
if creds["account_id"] != "1" {
t.Errorf("expected account_id=1, got %v", creds["account_id"])
if creds.AccountID != "1" {
t.Errorf("expected account_id=1, got %v", creds.AccountID)
}
if creds["region"] != "us" {
t.Errorf("expected region=us, got %v", creds["region"])
if creds.Region != "us" {
t.Errorf("expected region=us, got %v", creds.Region)
}
}

Expand Down Expand Up @@ -927,19 +905,15 @@ func TestAuthSignupVerify_EURegionViaUSEndpoint(t *testing.T) {
t.Fatalf("saveSignupCredentials: %v", err)
}

data, err := os.ReadFile(filepath.Join(tmpDir, ".cio", "config.json"))
creds, err := client.ReadCredentials()
if err != nil {
t.Fatalf("expected config written, got: %v", err)
t.Fatalf("expected credentials 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 (response data_center beats URL), got %v", creds.Region)
}
if creds["region"] != "eu" {
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"])
if creds.AccountID != "42" {
t.Errorf("expected account_id=42, got %v", creds.AccountID)
}
}

Expand Down Expand Up @@ -993,16 +967,12 @@ func TestAuthLogin_FromClipboard_SavesToken(t *testing.T) {
t.Errorf("expected status ok, got %v", result["status"])
}

data, err := os.ReadFile(filepath.Join(tmpDir, ".cio", "config.json"))
creds, err := client.ReadCredentials()
if err != nil {
t.Fatalf("failed to read config file: %v", err)
}
var creds map[string]any
if err := json.Unmarshal(data, &creds); err != nil {
t.Fatalf("invalid JSON in config file: %v", err)
t.Fatalf("failed to read credentials: %v", err)
}
if creds["service_account_token"] != "sa_live_test123" {
t.Errorf("expected sa_live_test123, got %v", creds["service_account_token"])
if creds.ServiceAccountToken != "sa_live_test123" {
t.Errorf("expected sa_live_test123, got %v", creds.ServiceAccountToken)
}
}

Expand Down
Loading