diff --git a/README.md b/README.md index e1f256d..7753840 100644 --- a/README.md +++ b/README.md @@ -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:// + +# 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 ` 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) @@ -189,6 +222,7 @@ cio api /v1/environments/{environment_id}/campaigns \ | Flag | Env Var | Description | |---|---|---| | `--token ` | `CIO_TOKEN` | Service account token override | +| `--profile ` | `CIO_PROFILE` | Configuration profile to use | | `-X, --method` | | HTTP method override (default: GET, or POST if --json) | | `--json ` | | Raw JSON request body or `@filename` to read from file | | `--params ` | | Query parameters as JSON → query string | diff --git a/cmd/auth.go b/cmd/auth.go index 9df2ba0..2b062bc 100644 --- a/cmd/auth.go +++ b/cmd/auth.go @@ -131,7 +131,11 @@ moment you copy it: For CI or non-interactive use: $ echo "$TOKEN" | cio auth login --with-token - $ cio auth login `, + $ cio auth login + +Credentials are saved to the active profile and that profile becomes current. +Pass --profile 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") @@ -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, @@ -250,9 +268,10 @@ 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 @@ -260,7 +279,8 @@ var authLogoutCmd = &cobra.Command{ 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, }) }, } @@ -310,6 +330,7 @@ Token resolution order: statusResult := map[string]any{ "status": "authenticated", + "profile": client.ActiveProfileName(), "token_source": tokenSource, "token": client.MaskToken(token), } diff --git a/cmd/auth_test.go b/cmd/auth_test.go index e698cce..9ea63d6 100644 --- a/cmd/auth_test.go +++ b/cmd/auth_test.go @@ -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 { @@ -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. @@ -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) } } @@ -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) } } @@ -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) } } @@ -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) } } @@ -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) } } @@ -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) } } @@ -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) } } diff --git a/cmd/profile.go b/cmd/profile.go new file mode 100644 index 0000000..7750716 --- /dev/null +++ b/cmd/profile.go @@ -0,0 +1,94 @@ +package cmd + +import ( + "encoding/json" + "errors" + "fmt" + "os" + + "github.com/customerio/cli/internal/client" + "github.com/customerio/cli/internal/output" + "github.com/spf13/cobra" +) + +var profileCmd = &cobra.Command{ + Use: "profile", + Short: "Manage named configuration profiles", + Long: `Manage named configuration profiles. + +Each profile holds its own credentials, region, and optional API base URL, +letting you switch between accounts (e.g. production, staging, a client) without +re-authenticating. Select a profile per command with --profile, or set a default +with 'cio profile use'. + +Profiles are stored in ~/.cio/config.json.`, + RunE: func(cmd *cobra.Command, args []string) error { + return cmd.Help() + }, +} + +var profileListCmd = &cobra.Command{ + Use: "list", + Short: "List configured profiles", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, args []string) error { + profiles, err := client.ListProfiles() + if err != nil { + if !errors.Is(err, os.ErrNotExist) { + // A corrupt or unreadable config is a real error — surface it + // rather than masquerading as "no profiles configured". + output.PrintError(output.CodeGeneralError, err.Error(), nil) + return err + } + // No config yet — report an empty list rather than an error. + profiles = []client.ProfileInfo{} + } + data, _ := json.Marshal(map[string]any{"profiles": profiles}) + return output.FprintProcess(cmd.OutOrStdout(), json.RawMessage(data), GetJQFlag(cmd)) + }, +} + +var profileUseCmd = &cobra.Command{ + Use: "use ", + Short: "Set the default profile", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + name := args[0] + if err := client.SetCurrentProfile(name); err != nil { + output.PrintError(output.CodeValidationError, err.Error(), map[string]any{ + "hint": "Run 'cio profile list' to see available profiles, or 'cio auth login --profile " + name + "' to create one.", + }) + return err + } + return output.FprintJSON(cmd.OutOrStdout(), map[string]any{ + "status": "ok", + "message": fmt.Sprintf("Switched current profile to %q", name), + "profile": name, + }) + }, +} + +var profileRemoveCmd = &cobra.Command{ + Use: "remove ", + Short: "Remove a profile", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + name := args[0] + if err := client.RemoveProfile(name); err != nil { + output.PrintError(output.CodeValidationError, err.Error(), nil) + return err + } + return output.FprintJSON(cmd.OutOrStdout(), map[string]any{ + "status": "ok", + "message": fmt.Sprintf("Removed profile %q", name), + "profile": name, + }) + }, +} + +func init() { + profileCmd.AddCommand(profileListCmd) + profileCmd.AddCommand(profileUseCmd) + profileCmd.AddCommand(profileRemoveCmd) + rootCmd.AddCommand(profileCmd) +} diff --git a/cmd/profile_test.go b/cmd/profile_test.go new file mode 100644 index 0000000..b99fc80 --- /dev/null +++ b/cmd/profile_test.go @@ -0,0 +1,107 @@ +package cmd + +import ( + "encoding/json" + "testing" + + "github.com/customerio/cli/internal/client" +) + +func TestProfileUse_UnknownProfileErrors(t *testing.T) { + tmpDir := t.TempDir() + t.Setenv("HOME", tmpDir) + t.Setenv("CIO_TOKEN", "") + t.Setenv("CIO_PROFILE", "") + + _, _, err := executeCommand("profile", "use", "nope") + if err == nil { + t.Fatal("expected error switching to unknown profile") + } +} + +func TestAuthLoginProfile_CreatesAndSwitches(t *testing.T) { + tmpDir := t.TempDir() + t.Setenv("HOME", tmpDir) + t.Setenv("CIO_TOKEN", "") + t.Setenv("CIO_PROFILE", "") + + defaultSrv := oauthServer(t, "sa_live_default") + defer defaultSrv.Close() + stagingSrv := oauthServer(t, "sa_live_staging") + defer stagingSrv.Close() + + // Log into the default profile. + if _, _, err := executeCommand("auth", "login", "sa_live_default", "--api-url", defaultSrv.URL); err != nil { + t.Fatalf("default login: %v", err) + } + + // Log into a named "staging" profile with a custom URL — should switch to it. + stdout, _, err := executeCommand("auth", "login", "sa_live_staging", + "--profile", "staging", "--api-url", stagingSrv.URL) + if err != nil { + t.Fatalf("staging login: %v", err) + } + var loginResult map[string]any + if err := json.Unmarshal([]byte(stdout), &loginResult); err != nil { + t.Fatalf("invalid JSON: %v\n%s", err, stdout) + } + if loginResult["profile"] != "staging" { + t.Errorf("expected profile staging in login output, got %v", loginResult["profile"]) + } + + // current_profile should now be staging, with the custom URL persisted. + if got := client.CurrentProfileName(); got != "staging" { + t.Errorf("expected current profile staging, got %q", got) + } + client.SetActiveProfile("staging") + creds, err := client.ReadCredentials() + if err != nil { + t.Fatalf("read staging: %v", err) + } + if creds.ServiceAccountToken != "sa_live_staging" { + t.Errorf("staging token: got %q", creds.ServiceAccountToken) + } + if creds.APIURL != stagingSrv.URL { + t.Errorf("expected persisted api_url %q, got %q", stagingSrv.URL, creds.APIURL) + } + client.SetActiveProfile("") + + // profile list shows both, with staging current. + stdout, _, err = executeCommand("profile", "list") + if err != nil { + t.Fatalf("list: %v", err) + } + var listResult struct { + Profiles []client.ProfileInfo `json:"profiles"` + } + if err := json.Unmarshal([]byte(stdout), &listResult); err != nil { + t.Fatalf("invalid list JSON: %v\n%s", err, stdout) + } + if len(listResult.Profiles) != 2 { + t.Fatalf("expected 2 profiles, got %+v", listResult.Profiles) + } + for _, p := range listResult.Profiles { + if p.Name == "staging" && !p.Current { + t.Errorf("expected staging marked current") + } + if p.Name == "default" && p.Current { + t.Errorf("default should not be current") + } + } + + // Switch back to default. + if _, _, err := executeCommand("profile", "use", "default"); err != nil { + t.Fatalf("use default: %v", err) + } + if got := client.CurrentProfileName(); got != "default" { + t.Errorf("expected current default after use, got %q", got) + } + + // Remove staging. + if _, _, err := executeCommand("profile", "remove", "staging"); err != nil { + t.Fatalf("remove staging: %v", err) + } + if client.ProfileExists("staging") { + t.Errorf("staging should be removed") + } +} diff --git a/cmd/root.go b/cmd/root.go index c8d062c..29344f7 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -73,12 +73,25 @@ func init() { flags.StringSlice("scope", nil, "Additional OAuth scope(s) to request during token exchange") flags.String("api-url", "", "API base URL (default: derived from region)") flags.String("token", "", "Service account token (overrides stored credentials and CIO_TOKEN)") + flags.String("profile", "", "Configuration profile to use (overrides CIO_PROFILE; default: current profile)") flags.Duration("timeout", client.DefaultTimeout, "HTTP request timeout") flags.Int("page", 0, "Page number") flags.Int("limit", 0, "Page size") flags.Bool("page-all", false, "Auto-paginate, emit NDJSON") rootCmd.PersistentPreRunE = func(cmd *cobra.Command, args []string) error { + // Select the active profile before any credential access so that auth + // commands and credential lookups all resolve against the same profile. + if profile, _ := cmd.Flags().GetString("profile"); profile != "" { + if err := client.ValidateProfileName(profile); err != nil { + output.PrintError(output.CodeValidationError, err.Error(), map[string]string{ + "flag": "--profile", + }) + return err + } + client.SetActiveProfile(profile) + } + // Bind environment variables as fallback defaults. if !cmd.Flags().Changed("timeout") { if v := os.Getenv("CIO_TIMEOUT"); v != "" { @@ -132,16 +145,9 @@ func init() { tokenFlag, _ := cmd.Flags().GetString("token") saToken := client.ResolveServiceAccountToken(tokenFlag) - // Resolve base URL: explicit flag > env var > region. - apiURL, _ := cmd.Flags().GetString("api-url") - if apiURL == "" { - if envURL := os.Getenv("CIO_API_URL"); envURL != "" { - apiURL = envURL - } else { - region := client.ResolveRegion(apiURL, cmd.Flags().Changed("api-url")) - apiURL = client.BaseURLForRegion(region) - } - } + // Resolve base URL: explicit flag > env var > profile URL > region. + apiURLFlag, _ := cmd.Flags().GetString("api-url") + apiURL := client.ResolveBaseURL(apiURLFlag, cmd.Flags().Changed("api-url")) timeout, _ := cmd.Flags().GetDuration("timeout") readOnly, _ := cmd.Flags().GetBool("read-only") @@ -193,7 +199,11 @@ func isAuthCommand(cmd *cobra.Command) bool { "cio auth logout", "cio auth signup", "cio auth signup start", - "cio auth signup verify": + "cio auth signup verify", + "cio profile", + "cio profile list", + "cio profile use", + "cio profile remove": return true } // Track API send commands authenticate directly with the sa_live_ token diff --git a/internal/client/auth.go b/internal/client/auth.go index 24d55e9..380f17f 100644 --- a/internal/client/auth.go +++ b/internal/client/auth.go @@ -1,7 +1,6 @@ package client import ( - "encoding/json" "fmt" "os" "path/filepath" @@ -39,6 +38,9 @@ type Credentials struct { AccountID string `json:"account_id,omitempty"` // Region is "us" or "eu" — determines the base URL. Region string `json:"region,omitempty"` + // APIURL is an explicit base URL override (e.g. staging). When set it takes + // precedence over the region-derived URL. + APIURL string `json:"api_url,omitempty"` // AccessToken is the cached short-lived JWT (from OAuth exchange). AccessToken string `json:"access_token,omitempty"` // AccessTokenExpiresAt is when the cached JWT expires. @@ -153,6 +155,27 @@ func ResolveRegion(apiURL string, apiURLChanged bool) string { return "us" } +// ResolveBaseURL determines the API base URL in priority order: +// 1. --api-url flag (when explicitly set) +// 2. CIO_API_URL environment variable +// 3. Active profile's stored APIURL +// 4. URL derived from the resolved region (CIO_REGION > profile region > "us") +func ResolveBaseURL(apiURLFlag string, apiURLChanged bool) string { + if apiURLChanged && apiURLFlag != "" { + return apiURLFlag + } + + if v := os.Getenv("CIO_API_URL"); v != "" { + return v + } + + if creds, err := ReadCredentials(); err == nil && creds.APIURL != "" { + return creds.APIURL + } + + return BaseURLForRegion(ResolveRegion(apiURLFlag, apiURLChanged)) +} + // CachedAccessToken returns the cached JWT if it's still valid (with 60s buffer). // If readOnly is true, only returns a cached token that was minted with read-only scope. // If readOnly is false, only returns a cached token that was NOT read-only (to avoid @@ -238,9 +261,10 @@ func cacheAccessToken(serviceAccountToken, accessToken string, expiresIn int, re } defer unlock() - creds, err := ReadCredentials() - if err != nil { - // No existing config — can't cache without stored credentials. + cfg := readConfigOrEmpty() + creds := cfg.Profiles[resolveProfileName(cfg)] + if creds == nil { + // No stored credentials for the active profile — nothing to cache onto. return nil } if serviceAccountToken != "" && creds.ServiceAccountToken != serviceAccountToken { @@ -251,86 +275,50 @@ func cacheAccessToken(serviceAccountToken, accessToken string, expiresIn int, re creds.AccessTokenExpiresAt = time.Now().Add(time.Duration(expiresIn) * time.Second) creds.ReadOnly = readOnly creds.Scopes = scopes - return writeCredentialsLocked(creds) + return writeConfigLocked(cfg) } -// ReadCredentials reads credentials from ~/.cio/config.json. +// ReadCredentials reads the active profile's credentials from ~/.cio/config.json. +// Returns an error wrapping os.ErrNotExist when the profile is absent. func ReadCredentials() (*Credentials, error) { - path, err := configFilePath() + cfg, err := readConfig() if err != nil { return nil, err } - - data, err := os.ReadFile(path) - if err != nil { - return nil, fmt.Errorf("read config file: %w", err) - } - - var creds Credentials - if err := json.Unmarshal(data, &creds); err != nil { - return nil, fmt.Errorf("parse config file: %w", err) + name := resolveProfileName(cfg) + creds := cfg.Profiles[name] + if creds == nil { + return nil, fmt.Errorf("profile %q not found: %w", name, os.ErrNotExist) } - - return &creds, nil + return creds, nil } -// WriteCredentials writes credentials to ~/.cio/config.json with 0600 permissions. -// Holds an exclusive lock for the duration of the write. +// WriteCredentials writes the active profile's credentials to ~/.cio/config.json +// with 0600 permissions. Holds an exclusive lock for the duration of the write. func WriteCredentials(creds *Credentials) error { unlock, err := lockConfigDir() if err != nil { return err } defer unlock() - return writeCredentialsLocked(creds) -} -// writeCredentialsLocked performs an atomic write (temp file + rename) of the -// config file. Callers must already hold the config-dir lock. -func writeCredentialsLocked(creds *Credentials) error { - dir, err := configDirPath() - if err != nil { + cfg := readConfigOrEmpty() + name := resolveProfileName(cfg) + // Validate at the central create point so a bad --profile / CIO_PROFILE value + // can't persist an odd profile key into the config. + if err := ValidateProfileName(name); err != nil { return err } - - if err := os.MkdirAll(dir, configDirMode); err != nil { - return fmt.Errorf("create config dir: %w", err) - } - - data, err := json.MarshalIndent(creds, "", " ") - if err != nil { - return fmt.Errorf("marshal config: %w", err) - } - data = append(data, '\n') - - tmp, err := os.CreateTemp(dir, configFileName+".tmp.*") - if err != nil { - return fmt.Errorf("create temp config: %w", err) - } - tmpName := tmp.Name() - // Cleans up on any error path; a no-op after a successful Rename. - defer os.Remove(tmpName) - - if err := tmp.Chmod(configFileMode); err != nil { - tmp.Close() - return fmt.Errorf("chmod temp config: %w", err) - } - if _, err := tmp.Write(data); err != nil { - tmp.Close() - return fmt.Errorf("write temp config: %w", err) + cfg.Profiles[name] = creds + if cfg.CurrentProfile == "" { + cfg.CurrentProfile = name } - if err := tmp.Close(); err != nil { - return fmt.Errorf("close temp config: %w", err) - } - - if err := os.Rename(tmpName, filepath.Join(dir, configFileName)); err != nil { - return fmt.Errorf("rename temp config: %w", err) - } - - return nil + return writeConfigLocked(cfg) } -// DeleteCredentials removes the config file. +// DeleteCredentials removes the active profile. If it was current_profile, that +// is repointed to another profile; when the last profile is removed the config +// file is deleted entirely. func DeleteCredentials() error { unlock, err := lockConfigDir() if err != nil { @@ -338,16 +326,24 @@ func DeleteCredentials() error { } defer unlock() - path, err := configFilePath() + cfg, err := readConfig() if err != nil { - return err + // No readable config — nothing to remove. + return nil } - - if err := os.Remove(path); err != nil && !os.IsNotExist(err) { - return fmt.Errorf("remove config file: %w", err) + name := resolveProfileName(cfg) + if _, ok := cfg.Profiles[name]; !ok { + return nil } + delete(cfg.Profiles, name) - return nil + if len(cfg.Profiles) == 0 { + return removeConfigFile() + } + if cfg.CurrentProfile == name { + cfg.CurrentProfile = anyProfileName(cfg.Profiles) + } + return writeConfigLocked(cfg) } // lockConfigDir acquires an exclusive file lock on the config directory. diff --git a/internal/client/config.go b/internal/client/config.go new file mode 100644 index 0000000..7f3e533 --- /dev/null +++ b/internal/client/config.go @@ -0,0 +1,326 @@ +package client + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + "regexp" + "sort" + "strings" + "sync" +) + +// DefaultProfileName is the profile used when none is selected. A legacy flat +// config.json is migrated under this name on first read. +const DefaultProfileName = "default" + +// maxProfileNameLen bounds a profile name. Names are config-map keys and appear +// in JSON and CLI output, so they're kept short and to a conservative charset. +const maxProfileNameLen = 64 + +// validProfileName matches an allowed profile name: letters, digits, dot, dash, +// and underscore. This keeps names safe to use as map keys, JSON keys, and shell +// arguments without surprising quoting or lookalike characters. +var validProfileName = regexp.MustCompile(`^[A-Za-z0-9._-]+$`) + +// ValidateProfileName reports whether name is an acceptable profile name. It is +// enforced wherever a profile can be created (the active profile on write) so an +// adversarial --profile or CIO_PROFILE value can't land odd keys in the config. +func ValidateProfileName(name string) error { + if name == "" { + return fmt.Errorf("profile name must not be empty") + } + if len(name) > maxProfileNameLen { + return fmt.Errorf("profile name must be at most %d characters", maxProfileNameLen) + } + if !validProfileName.MatchString(name) { + return fmt.Errorf("profile name %q is invalid: use only letters, digits, '.', '-', and '_'", name) + } + return nil +} + +// storedConfig is the on-disk shape of ~/.cio/config.json. It holds one or more +// named profiles, each with its own credentials, region, and optional base URL. +type storedConfig struct { + // CurrentProfile is the profile used when --profile / CIO_PROFILE are unset. + CurrentProfile string `json:"current_profile,omitempty"` + // Profiles maps a profile name to its credentials. + Profiles map[string]*Credentials `json:"profiles"` +} + +// activeProfile is the profile name selected for this invocation (from the +// --profile flag). Empty means "fall back to CIO_PROFILE, then current_profile, +// then default". Guarded because tests and concurrent goroutines may touch it. +var ( + activeProfileMu sync.RWMutex + activeProfile string +) + +// SetActiveProfile records the profile selected by the --profile flag. Called +// once early in the root PersistentPreRunE, before any credential access. +func SetActiveProfile(name string) { + activeProfileMu.Lock() + activeProfile = strings.TrimSpace(name) + activeProfileMu.Unlock() +} + +func explicitActiveProfile() string { + activeProfileMu.RLock() + defer activeProfileMu.RUnlock() + return activeProfile +} + +// ActiveProfileName resolves the active profile name in priority order: +// --profile flag > CIO_PROFILE env > stored current_profile > default. +func ActiveProfileName() string { + cfg, _ := readConfig() + return resolveProfileName(cfg) +} + +func resolveProfileName(cfg *storedConfig) string { + if v := explicitActiveProfile(); v != "" { + return v + } + + if v := strings.TrimSpace(os.Getenv("CIO_PROFILE")); v != "" { + return v + } + + return storedCurrentProfile(cfg) +} + +// storedCurrentProfile returns the profile persisted as current_profile, ignoring +// any session override from --profile / CIO_PROFILE. Used where the persisted +// default matters (e.g. 'cio profile list'), not the per-invocation selection. +func storedCurrentProfile(cfg *storedConfig) string { + if cfg != nil && cfg.CurrentProfile != "" { + return cfg.CurrentProfile + } + return DefaultProfileName +} + +// readConfig reads ~/.cio/config.json, migrating a legacy flat credentials file +// into a single "default" profile. Returns the underlying read error (including +// os.ErrNotExist) when the file is absent. +func readConfig() (*storedConfig, error) { + path, err := configFilePath() + if err != nil { + return nil, err + } + data, err := os.ReadFile(path) + if err != nil { + return nil, fmt.Errorf("read config file: %w", err) + } + return parseConfig(data) +} + +// parseConfig turns raw config bytes into a Config, handling both the new +// multi-profile shape and the legacy flat Credentials object. +func parseConfig(data []byte) (*storedConfig, error) { + var probe map[string]json.RawMessage + if err := json.Unmarshal(data, &probe); err != nil { + return nil, fmt.Errorf("parse config file: %w", err) + } + + // New format carries a "profiles" key. Anything else is a legacy flat file. + if _, ok := probe["profiles"]; ok { + var cfg storedConfig + if err := json.Unmarshal(data, &cfg); err != nil { + return nil, fmt.Errorf("parse config file: %w", err) + } + if cfg.Profiles == nil { + cfg.Profiles = map[string]*Credentials{} + } + return &cfg, nil + } + + var legacy Credentials + if err := json.Unmarshal(data, &legacy); err != nil { + return nil, fmt.Errorf("parse config file: %w", err) + } + return &storedConfig{ + CurrentProfile: DefaultProfileName, + Profiles: map[string]*Credentials{DefaultProfileName: &legacy}, + }, nil +} + +// readConfigOrEmpty is the write-path counterpart to readConfig: a missing or +// unreadable file yields an empty Config rather than an error, so the first +// write bootstraps the file. +func readConfigOrEmpty() *storedConfig { + cfg, err := readConfig() + if err != nil { + return &storedConfig{Profiles: map[string]*Credentials{}} + } + if cfg.Profiles == nil { + cfg.Profiles = map[string]*Credentials{} + } + return cfg +} + +// writeConfigLocked performs an atomic write (temp file + rename) of the config +// file. Callers must already hold the config-dir lock. +func writeConfigLocked(cfg *storedConfig) error { + dir, err := configDirPath() + if err != nil { + return err + } + + if err := os.MkdirAll(dir, configDirMode); err != nil { + return fmt.Errorf("create config dir: %w", err) + } + + data, err := json.MarshalIndent(cfg, "", " ") + if err != nil { + return fmt.Errorf("marshal config: %w", err) + } + data = append(data, '\n') + + tmp, err := os.CreateTemp(dir, configFileName+".tmp.*") + if err != nil { + return fmt.Errorf("create temp config: %w", err) + } + tmpName := tmp.Name() + // Cleans up on any error path; a no-op after a successful Rename. + defer os.Remove(tmpName) + + if err := tmp.Chmod(configFileMode); err != nil { + tmp.Close() + return fmt.Errorf("chmod temp config: %w", err) + } + if _, err := tmp.Write(data); err != nil { + tmp.Close() + return fmt.Errorf("write temp config: %w", err) + } + if err := tmp.Close(); err != nil { + return fmt.Errorf("close temp config: %w", err) + } + + if err := os.Rename(tmpName, filepath.Join(dir, configFileName)); err != nil { + return fmt.Errorf("rename temp config: %w", err) + } + + return nil +} + +// ProfileInfo is a redacted view of a stored profile for display. +type ProfileInfo struct { + Name string `json:"name"` + Current bool `json:"current"` + Token string `json:"token,omitempty"` + Region string `json:"region,omitempty"` + APIURL string `json:"api_url,omitempty"` + AccountID string `json:"account_id,omitempty"` +} + +// ListProfiles returns all stored profiles, sorted by name, with tokens masked. +func ListProfiles() ([]ProfileInfo, error) { + cfg, err := readConfig() + if err != nil { + return nil, err + } + + // Mark the persisted default, not the per-invocation selection, so the list + // reflects what 'cio profile use' set regardless of any --profile/CIO_PROFILE + // override active for this command. + current := storedCurrentProfile(cfg) + out := make([]ProfileInfo, 0, len(cfg.Profiles)) + for name, creds := range cfg.Profiles { + info := ProfileInfo{Name: name, Current: name == current} + if creds != nil { + info.Token = MaskToken(creds.ServiceAccountToken) + info.Region = creds.Region + info.APIURL = creds.APIURL + info.AccountID = creds.AccountID + } + out = append(out, info) + } + sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name }) + return out, nil +} + +// CurrentProfileName returns the profile persisted as current_profile, ignoring +// any --profile / CIO_PROFILE override for this invocation. Use ActiveProfileName +// for the profile actually selected for the current command. +func CurrentProfileName() string { + cfg, _ := readConfig() + return storedCurrentProfile(cfg) +} + +// ProfileExists reports whether a profile with the given name is stored. +func ProfileExists(name string) bool { + cfg, err := readConfig() + if err != nil { + return false + } + _, ok := cfg.Profiles[name] + return ok +} + +// SetCurrentProfile points current_profile at an existing profile. +func SetCurrentProfile(name string) error { + unlock, err := lockConfigDir() + if err != nil { + return err + } + defer unlock() + + cfg := readConfigOrEmpty() + if _, ok := cfg.Profiles[name]; !ok { + return fmt.Errorf("profile %q not found", name) + } + cfg.CurrentProfile = name + return writeConfigLocked(cfg) +} + +// RemoveProfile deletes a profile. If it was the current one, current_profile is +// repointed to another profile (or cleared). When the last profile is removed, +// the config file is deleted entirely. +func RemoveProfile(name string) error { + unlock, err := lockConfigDir() + if err != nil { + return err + } + defer unlock() + + cfg := readConfigOrEmpty() + if _, ok := cfg.Profiles[name]; !ok { + return fmt.Errorf("profile %q not found", name) + } + delete(cfg.Profiles, name) + + if len(cfg.Profiles) == 0 { + return removeConfigFile() + } + + if cfg.CurrentProfile == name { + cfg.CurrentProfile = anyProfileName(cfg.Profiles) + } + return writeConfigLocked(cfg) +} + +// anyProfileName returns a deterministic profile name from the map (lowest by +// sort order) so repointing current_profile is stable. +func anyProfileName(profiles map[string]*Credentials) string { + names := make([]string, 0, len(profiles)) + for n := range profiles { + names = append(names, n) + } + if len(names) == 0 { + return "" + } + sort.Strings(names) + return names[0] +} + +func removeConfigFile() error { + path, err := configFilePath() + if err != nil { + return err + } + if err := os.Remove(path); err != nil && !os.IsNotExist(err) { + return fmt.Errorf("remove config file: %w", err) + } + return nil +} diff --git a/internal/client/config_test.go b/internal/client/config_test.go new file mode 100644 index 0000000..c9bab6e --- /dev/null +++ b/internal/client/config_test.go @@ -0,0 +1,268 @@ +package client + +import ( + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" +) + +// resetActiveProfile clears the package-level profile selection so tests don't +// leak state into each other. +func resetActiveProfile(t *testing.T) { + t.Helper() + SetActiveProfile("") + t.Cleanup(func() { SetActiveProfile("") }) +} + +func TestReadConfig_MigratesLegacyFlatFile(t *testing.T) { + tmpDir := t.TempDir() + t.Setenv("HOME", tmpDir) + t.Setenv("CIO_PROFILE", "") + resetActiveProfile(t) + + // Write a legacy flat credentials file (no "profiles" key). + dir := filepath.Join(tmpDir, ".cio") + if err := os.MkdirAll(dir, 0700); err != nil { + t.Fatal(err) + } + legacy := Credentials{ServiceAccountToken: "sa_live_legacy", Region: "eu", AccountID: "7"} + data, _ := json.Marshal(legacy) + if err := os.WriteFile(filepath.Join(dir, "config.json"), data, 0600); err != nil { + t.Fatal(err) + } + + creds, err := ReadCredentials() + if err != nil { + t.Fatalf("read: %v", err) + } + if creds.ServiceAccountToken != "sa_live_legacy" || creds.Region != "eu" { + t.Errorf("legacy file not migrated: %+v", creds) + } + + profiles, err := ListProfiles() + if err != nil { + t.Fatalf("list: %v", err) + } + if len(profiles) != 1 || profiles[0].Name != DefaultProfileName || !profiles[0].Current { + t.Errorf("expected single current default profile, got %+v", profiles) + } +} + +func TestWriteCredentials_MigratesFileToNewFormat(t *testing.T) { + tmpDir := t.TempDir() + t.Setenv("HOME", tmpDir) + t.Setenv("CIO_PROFILE", "") + resetActiveProfile(t) + + if err := WriteCredentials(&Credentials{ServiceAccountToken: "sa_live_x"}); err != nil { + t.Fatalf("write: %v", err) + } + + raw, err := os.ReadFile(filepath.Join(tmpDir, ".cio", "config.json")) + if err != nil { + t.Fatal(err) + } + var onDisk map[string]json.RawMessage + if err := json.Unmarshal(raw, &onDisk); err != nil { + t.Fatal(err) + } + if _, ok := onDisk["profiles"]; !ok { + t.Errorf("expected new format with profiles key, got %s", raw) + } + if _, ok := onDisk["current_profile"]; !ok { + t.Errorf("expected current_profile to be initialized, got %s", raw) + } +} + +func TestProfiles_IsolatedByName(t *testing.T) { + tmpDir := t.TempDir() + t.Setenv("HOME", tmpDir) + t.Setenv("CIO_PROFILE", "") + resetActiveProfile(t) + + SetActiveProfile(DefaultProfileName) + if err := WriteCredentials(&Credentials{ServiceAccountToken: "sa_live_default", Region: "us"}); err != nil { + t.Fatalf("write default: %v", err) + } + + SetActiveProfile("staging") + if err := WriteCredentials(&Credentials{ServiceAccountToken: "sa_live_staging", APIURL: "https://staging.example"}); err != nil { + t.Fatalf("write staging: %v", err) + } + + // Each profile resolves its own token and base URL. + SetActiveProfile("staging") + if got := ResolveServiceAccountToken(""); got != "sa_live_staging" { + t.Errorf("staging token: got %q", got) + } + if got := ResolveBaseURL("", false); got != "https://staging.example" { + t.Errorf("staging base URL: got %q", got) + } + + SetActiveProfile(DefaultProfileName) + if got := ResolveServiceAccountToken(""); got != "sa_live_default" { + t.Errorf("default token: got %q", got) + } + if got := ResolveBaseURL("", false); got != BaseURLForRegion("us") { + t.Errorf("default base URL: got %q", got) + } +} + +func TestActiveProfileName_Precedence(t *testing.T) { + tmpDir := t.TempDir() + t.Setenv("HOME", tmpDir) + resetActiveProfile(t) + + // Seed two profiles with current_profile=default. + SetActiveProfile(DefaultProfileName) + if err := WriteCredentials(&Credentials{ServiceAccountToken: "sa_live_default"}); err != nil { + t.Fatal(err) + } + SetActiveProfile("alt") + if err := WriteCredentials(&Credentials{ServiceAccountToken: "sa_live_alt"}); err != nil { + t.Fatal(err) + } + if err := SetCurrentProfile(DefaultProfileName); err != nil { + t.Fatal(err) + } + + // current_profile wins when no flag/env. + SetActiveProfile("") + t.Setenv("CIO_PROFILE", "") + if got := ActiveProfileName(); got != DefaultProfileName { + t.Errorf("expected current_profile default, got %q", got) + } + + // CIO_PROFILE overrides current_profile. + t.Setenv("CIO_PROFILE", "alt") + if got := ActiveProfileName(); got != "alt" { + t.Errorf("expected env alt, got %q", got) + } + + // Explicit --profile (SetActiveProfile) wins over env. + SetActiveProfile("default") + if got := ActiveProfileName(); got != "default" { + t.Errorf("expected explicit default, got %q", got) + } +} + +func TestRemoveProfile_RepointsCurrent(t *testing.T) { + tmpDir := t.TempDir() + t.Setenv("HOME", tmpDir) + t.Setenv("CIO_PROFILE", "") + resetActiveProfile(t) + + SetActiveProfile("a") + if err := WriteCredentials(&Credentials{ServiceAccountToken: "sa_live_a"}); err != nil { + t.Fatal(err) + } + SetActiveProfile("b") + if err := WriteCredentials(&Credentials{ServiceAccountToken: "sa_live_b"}); err != nil { + t.Fatal(err) + } + if err := SetCurrentProfile("b"); err != nil { + t.Fatal(err) + } + + // Removing the current profile repoints current_profile to the survivor. + SetActiveProfile("") + if err := RemoveProfile("b"); err != nil { + t.Fatalf("remove: %v", err) + } + if got := CurrentProfileName(); got != "a" { + t.Errorf("expected current repointed to a, got %q", got) + } + if ProfileExists("b") { + t.Errorf("profile b should be gone") + } +} + +func TestRemoveProfile_LastDeletesFile(t *testing.T) { + tmpDir := t.TempDir() + t.Setenv("HOME", tmpDir) + t.Setenv("CIO_PROFILE", "") + resetActiveProfile(t) + + SetActiveProfile("only") + if err := WriteCredentials(&Credentials{ServiceAccountToken: "sa_live_only"}); err != nil { + t.Fatal(err) + } + if err := RemoveProfile("only"); err != nil { + t.Fatalf("remove: %v", err) + } + + if _, err := os.Stat(filepath.Join(tmpDir, ".cio", "config.json")); !os.IsNotExist(err) { + t.Errorf("expected config file deleted after last profile removed, got err=%v", err) + } +} + +func TestValidateProfileName(t *testing.T) { + valid := []string{"default", "staging", "account-47", "acct_49", "us.prod", "A1"} + for _, name := range valid { + if err := ValidateProfileName(name); err != nil { + t.Errorf("expected %q to be valid, got %v", name, err) + } + } + + invalid := []string{"", "has space", "../escape", "name/slash", "emoji😀", strings.Repeat("a", maxProfileNameLen+1)} + for _, name := range invalid { + if err := ValidateProfileName(name); err == nil { + t.Errorf("expected %q to be rejected", name) + } + } +} + +func TestWriteCredentials_RejectsInvalidProfileName(t *testing.T) { + tmpDir := t.TempDir() + t.Setenv("HOME", tmpDir) + t.Setenv("CIO_PROFILE", "") + resetActiveProfile(t) + + SetActiveProfile("bad name") + if err := WriteCredentials(&Credentials{ServiceAccountToken: "sa_live_x"}); err == nil { + t.Fatal("expected write to reject an invalid active profile name") + } + + // Nothing should have been persisted for the rejected name. + if _, err := os.Stat(filepath.Join(tmpDir, ".cio", "config.json")); !os.IsNotExist(err) { + t.Errorf("expected no config file after rejected write, got err=%v", err) + } +} + +func TestListProfiles_CurrentTracksStoredNotOverride(t *testing.T) { + tmpDir := t.TempDir() + t.Setenv("HOME", tmpDir) + t.Setenv("CIO_PROFILE", "") + resetActiveProfile(t) + + SetActiveProfile(DefaultProfileName) + if err := WriteCredentials(&Credentials{ServiceAccountToken: "sa_live_default"}); err != nil { + t.Fatal(err) + } + SetActiveProfile("staging") + if err := WriteCredentials(&Credentials{ServiceAccountToken: "sa_live_staging"}); err != nil { + t.Fatal(err) + } + if err := SetCurrentProfile(DefaultProfileName); err != nil { + t.Fatal(err) + } + + // A per-invocation override (flag or env) must not change which profile the + // listing marks as current — that reflects the persisted current_profile. + SetActiveProfile("staging") + t.Setenv("CIO_PROFILE", "staging") + profiles, err := ListProfiles() + if err != nil { + t.Fatalf("list: %v", err) + } + for _, p := range profiles { + if p.Name == DefaultProfileName && !p.Current { + t.Errorf("expected stored current %q to be marked current", DefaultProfileName) + } + if p.Name == "staging" && p.Current { + t.Errorf("session override %q should not be marked current", "staging") + } + } +}