diff --git a/CHANGELOG.md b/CHANGELOG.md index 65194ac..69a7842 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,14 @@ Earlier entries pre-date this convention and only carry their version's compare ## [Unreleased] +### Changed + +- Add region-aware CLI profile support for `global` and `cn`: `agora login --region` now selects the API/OAuth endpoints, Console/docs links, quickstart URLs, doctor network checks, and project context region used by later commands. +- **BREAKING**: Remove `--region` from `agora init` and `agora project create`; new projects now use the active login region instead of a per-command region flag. +- **BREAKING**: Update public JSON shapes for region-aware profiles: `auth login --json` and `auth status --json` include `data.region`, while project list/show API models no longer expose a project `region` field because the project APIs do not return it. +- **BREAKING**: Stop persisting CLI API/OAuth integration values in `config.json`. `apiBaseUrl`, `oauthBaseUrl`, `oauthClientId`, and `oauthScope` are now derived from the selected login region or from explicit environment variable overrides (`AGORA_API_BASE_URL`, `AGORA_OAUTH_BASE_URL`, `AGORA_OAUTH_CLIENT_ID`, `AGORA_OAUTH_SCOPE`). Existing configs auto-migrate to schema version `4` and drop those legacy keys on first load; users who previously pinned custom endpoints in `config.json` should move those values to environment variables. +- Add `PROJECT_REGION_MISMATCH` when a repo-local `.agora/project.json` binding points to a different region than the active login region. + ## [0.2.5] - 2026-06-05 Installer migration, quickstart scaffold cleanup, and onboarding doc refresh. diff --git a/config.example.json b/config.example.json index 15915ca..b10a0d8 100644 --- a/config.example.json +++ b/config.example.json @@ -1,11 +1,7 @@ { - "version": 3, - "apiBaseUrl": "https://agora-cli.agora.io", + "version": 4, "browserAutoOpen": true, "logLevel": "info", - "oauthBaseUrl": "https://sso2.agora.io", - "oauthClientId": "agora_web_cli", - "oauthScope": "basic_info,console", "output": "pretty", "telemetryEnabled": true, "debug": false diff --git a/docs/automation.md b/docs/automation.md index 5e23ac2..09b328c 100644 --- a/docs/automation.md +++ b/docs/automation.md @@ -753,11 +753,13 @@ When authenticated, this command returns a success envelope with these required - `authenticated` - `status` `authenticated`. +- `region` - `expiresAt` - `scope` Safe branch fields: - `authenticated` +- `region` - `status` - `expiresAt` @@ -801,10 +803,12 @@ Required `data` fields: Always `login`. - `status` Currently `authenticated`. +- `region` - `scope` - `expiresAt` Safe branch fields: +- `region` - `status` - `expiresAt` @@ -851,7 +855,7 @@ Required `data` fields: - `cacheRefreshed` Boolean. `true` only when `--refresh-cache` successfully refreshed the unfiltered first-page project completion cache. -Each item includes: `projectId`, `name`, `appId`, `projectType`, `status`, `region`, `createdAt`, `updatedAt`. +Each item includes: `projectId`, `name`, `appId`, `projectType`, `status`, `createdAt`, `updatedAt`. Safe branch fields: - `items[].projectId` @@ -954,14 +958,23 @@ Example: ``` Returns the current resolved config object. Safe branch fields: -- `apiBaseUrl` -- `oauthBaseUrl` - `output` - `logLevel` - `browserAutoOpen` - `telemetryEnabled` - `debug` (renamed from legacy `verbose` in v0.2.0; legacy key is migrated on first load) +Endpoint and OAuth integration values are derived from the active login +region and may be temporarily overridden with environment variables such as +`AGORA_API_BASE_URL`, `AGORA_OAUTH_BASE_URL`, `AGORA_OAUTH_CLIENT_ID`, and +`AGORA_OAUTH_SCOPE`; they are not persisted in `config.json`. + +Migration note: configs written by older CLI versions may contain +`apiBaseUrl`, `oauthBaseUrl`, `oauthClientId`, or `oauthScope`. Schema version +4 drops those keys on first load. If automation previously depended on those +persisted values, set the corresponding `AGORA_*` environment variable in the +job environment instead. + ### `config update` Example: diff --git a/docs/commands.md b/docs/commands.md index 43c4195..c92b426 100644 --- a/docs/commands.md +++ b/docs/commands.md @@ -44,7 +44,7 @@ Authenticate with Agora Console | Flag | Type | Default | Description | |------|------|---------|-------------| | `--no-browser` | `bool` | — | print the login URL instead of auto-opening a browser | -| `--region` | `string` | — | control plane region for login defaults (global or cn) | +| `--region` | `string` | `global` | control plane region for login (global or cn) | ### `agora auth logout` @@ -82,13 +82,9 @@ Update persisted CLI defaults | Flag | Type | Default | Description | |------|------|---------|-------------| -| `--api-base-url` | `string` | `https://agora-cli.agora.io` | default CLI API base URL | | `--browser-auto-open` | `bool` | — | persist browser auto-open preference; use --browser-auto-open=false to disable | | `--debug` | `bool` | — | persist the --debug preference (echo structured logs to stderr); use --debug=false to disable | | `--log-level` | `string` | `info` | persist default log level | -| `--oauth-base-url` | `string` | `https://sso2.agora.io` | default OAuth base URL | -| `--oauth-client-id` | `string` | `agora_web_cli` | default OAuth client ID | -| `--oauth-scope` | `string` | `basic_info,console` | default OAuth scope | | `--output` | `output` | `pretty` | persist default output mode (pretty or json) | | `--telemetry-enabled` | `bool` | — | persist telemetry preference; use --telemetry-enabled=false to disable | @@ -115,7 +111,6 @@ Create a project, clone a quickstart, and write env in one flow | `--feature` | `stringArray` | `[]` | enable a feature on the newly created project (repeatable); defaults to rtc, rtm, convoai; convoai also enables rtm | | `--new-project` | `bool` | — | always create a new Agora project instead of reusing an existing one | | `--project` | `string` | — | existing project ID or exact project name to bind to | -| `--region` | `string` | — | control plane region for newly created projects (global or cn) | | `--rtm-data-center` | `string` | — | RTM data center to configure when rtm is enabled on a newly created project (CN, NA, EU, or AP); defaults to NA | | `--template` | `string` | — | quickstart template ID to use | @@ -132,7 +127,7 @@ Authenticate with Agora Console | Flag | Type | Default | Description | |------|------|---------|-------------| | `--no-browser` | `bool` | — | print the login URL instead of auto-opening a browser | -| `--region` | `string` | — | control plane region for login defaults (global or cn) | +| `--region` | `string` | `global` | control plane region for login (global or cn) | ### `agora logout` @@ -177,7 +172,6 @@ Create a new remote Agora project | `--dry-run` | `bool` | — | return the planned project create result without creating remote resources | | `--feature` | `stringArray` | `[]` | enable one or more features after creation; defaults to rtc, rtm, convoai; convoai also enables rtm | | `--idempotency-key` | `string` | — | caller-provided key for safe retries when supported by the API | -| `--region` | `string` | — | control plane region for the project context (global or cn) | | `--rtm-data-center` | `string` | — | RTM data center to configure when rtm is enabled (CN, NA, EU, or AP); defaults to NA | | `--template` | `string` | — | apply a higher-level project preset such as voice-agent | @@ -209,6 +203,7 @@ Write project environment variables to a dotenv file |------|------|---------|-------------| | `--append` | `bool` | — | append Agora App ID and App Certificate values when no existing values are present | | `--overwrite` | `bool` | — | replace the target file with only Agora App ID and App Certificate values | +| `--project` | `string` | — | project ID or exact project name; defaults to the current project context | | `--template` | `string` | — | credential key layout: nextjs or standard; if omitted, detect Next.js from the workspace | ### `agora project feature` @@ -379,4 +374,3 @@ Show the current auth status **`outputModes`**: `pretty`, `json` **`doctorStatus`**: `healthy`, `warning`, `not_ready`, `auth_error` - diff --git a/docs/error-codes.md b/docs/error-codes.md index 9d2ffef..c938992 100644 --- a/docs/error-codes.md +++ b/docs/error-codes.md @@ -26,6 +26,7 @@ This catalog is the source of truth for stable codes. CI runs `make snapshot-err | `PROJECT_NOT_SELECTED` | 1 | No explicit, repo-local, or global project context is available. | Pass `--project`, work inside a bound quickstart, or run `agora project use `. | | `PROJECT_NOT_FOUND` | 1 | The requested project ID or exact name was not found. | Run `agora project list` and retry with the project ID. | | `PROJECT_AMBIGUOUS` | 1 | A project name matched multiple projects. | Retry with the project ID. | +| `PROJECT_REGION_MISMATCH` | 1 | A repo-local `.agora/project.json` binding points to a different region than the active login region. | Run `agora login --region ` for the bound project region, or pass `--project` to override the repo-local binding. | | `PROJECT_NO_CERTIFICATE` | 1 | The selected project has no app certificate for env seeding. | Enable an app certificate in Console or select another project. | | `PROJECT_ENV_TEMPLATE_UNKNOWN` | 1 | The `--template` value for `project env write` is not supported. | Use `nextjs` or `standard`. | | `PROJECT_NOT_READY` | 1 | `project doctor` could not surface a more specific issue. | Re-run `project doctor` for details. | diff --git a/internal/cli/app.go b/internal/cli/app.go index a64830b..ea5ba64 100644 --- a/internal/cli/app.go +++ b/internal/cli/app.go @@ -52,7 +52,6 @@ type projectContext struct { CurrentProjectID *string `json:"currentProjectId"` CurrentProjectName *string `json:"currentProjectName"` CurrentRegion string `json:"currentRegion"` - PreferredRegion string `json:"preferredRegion"` } type projectSummary struct { @@ -62,7 +61,6 @@ type projectSummary struct { Name string `json:"name"` ProjectID string `json:"projectId"` ProjectType string `json:"projectType"` - Region *string `json:"region,omitempty"` SignKey *string `json:"signKey"` Stage int `json:"stage"` Status string `json:"status"` @@ -78,7 +76,6 @@ type projectDetail struct { Name string `json:"name"` ProjectID string `json:"projectId"` ProjectType string `json:"projectType"` - Region *string `json:"region,omitempty"` SignKey *string `json:"signKey"` Stage int `json:"stage"` Status string `json:"status"` diff --git a/internal/cli/app_test.go b/internal/cli/app_test.go index d8e9121..42ab12f 100644 --- a/internal/cli/app_test.go +++ b/internal/cli/app_test.go @@ -289,11 +289,11 @@ func TestEnsureAppConfigStateMigratesPreviousDefaults(t *testing.T) { t.Fatal(err) } raw := map[string]any{ - "apiBaseUrl": previousAPIBaseURL, + "apiBaseUrl": "https://agora-cli-bff.staging.la3.agoralab.co", "browserAutoOpen": true, "logLevel": "info", - "oauthBaseUrl": previousOAuthBaseURL, - "oauthClientId": previousOAuthClientID, + "oauthBaseUrl": "https://staging-sso.agora.io", + "oauthClientId": "cli_demo", "oauthScope": "basic_info,console", "output": "pretty", "telemetryEnabled": true, @@ -312,11 +312,18 @@ func TestEnsureAppConfigStateMigratesPreviousDefaults(t *testing.T) { if state.Status != "migrated" { t.Fatalf("expected migrated, got %s", state.Status) } - if state.Config.APIBaseURL != defaultConfig().APIBaseURL { - t.Fatalf("expected prod API base URL, got %s", state.Config.APIBaseURL) + rewritten, err := os.ReadFile(configPath) + if err != nil { + t.Fatal(err) } - if state.Config.OAuthBaseURL != defaultConfig().OAuthBaseURL { - t.Fatalf("expected prod OAuth base URL, got %s", state.Config.OAuthBaseURL) + var rewrittenMap map[string]any + if err := json.Unmarshal(rewritten, &rewrittenMap); err != nil { + t.Fatal(err) + } + for _, key := range []string{"apiBaseUrl", "oauthBaseUrl", "oauthClientId", "oauthScope"} { + if _, ok := rewrittenMap[key]; ok { + t.Fatalf("expected migrated config to drop %s, got %s", key, string(rewritten)) + } } } @@ -463,9 +470,22 @@ func TestEnsureAppConfigStateMigratesPartialAndCustomPreviousConfigs(t *testing. if err != nil { t.Fatal(err) } - if state.Status != "migrated" || state.Config.APIBaseURL != "https://staging.internal.example.com" || state.Config.OAuthClientID != "custom-dev-client" || state.Config.TelemetryEnabled { + if state.Status != "migrated" || state.Config.BrowserAutoOpen || state.Config.TelemetryEnabled || state.Config.Output != outputJSON || !state.Config.Debug { t.Fatalf("unexpected migrated custom config: %+v", state) } + rewritten, err := os.ReadFile(configPath) + if err != nil { + t.Fatal(err) + } + var rewrittenMap map[string]any + if err := json.Unmarshal(rewritten, &rewrittenMap); err != nil { + t.Fatal(err) + } + for _, key := range []string{"apiBaseUrl", "oauthBaseUrl", "oauthClientId", "oauthScope"} { + if _, ok := rewrittenMap[key]; ok { + t.Fatalf("expected migrated custom config to drop %s, got %s", key, string(rewritten)) + } + } } // TestEnsureAppConfigStateMigratesLegacyVerboseKeyToDebug proves that @@ -630,7 +650,6 @@ func TestResolveConfiguredOutputModeAndConfigApplication(t *testing.T) { "AGORA_OUTPUT": "json", } app := &App{env: env, cfg: defaultConfig()} - app.cfg.APIBaseURL = "https://config.example.com" app.cfg.LogLevel = "warn" app.cfg.BrowserAutoOpen = false app.cfg.Debug = true diff --git a/internal/cli/auth.go b/internal/cli/auth.go index d8ec006..46bae71 100644 --- a/internal/cli/auth.go +++ b/internal/cli/auth.go @@ -21,8 +21,22 @@ import ( "time" ) +const ( + apiBaseURL = "https://agora-cli.agora.io" + apiBaseURLCN = "https://cli-cn.agora.io" + oauthBaseURL = "https://sso2.agora.io" + oauthBaseURLCN = "https://sso.shengwang.cn" + defaultOAuthClientID = "agora_web_cli" + defaultOAuthScope = "basic_info,console" +) + func (a *App) login(noBrowser bool, region string, progress progressEmitter) (map[string]any, error) { - config := a.oauthConfig() + loginRegion, err := normalizeLoginRegion(region) + if err != nil { + return nil, err + } + + config := a.oauthConfigForRegion(loginRegion) pair, err := generatePKCE() if err != nil { return nil, err @@ -74,23 +88,32 @@ func (a *App) login(noBrowser bool, region string, progress progressEmitter) (ma return nil, err } progress.emit("oauth:complete", "Session stored", nil) - ctx, err := loadContext(a.env) - if err != nil { + if err := a.resetSessionRuntimeState(loginRegion); err != nil { return nil, err } - nextRegion := ctx.PreferredRegion - if nextRegion == "" { - nextRegion = "global" - } - if region == "global" || region == "cn" { - nextRegion = region + return map[string]any{"action": "login", "expiresAt": token.ExpiresAt, "region": loginRegion, "scope": token.Scope, "status": "authenticated"}, nil +} + +func (a *App) resetSessionRuntimeState(loginRegion string) error { + // Rebuild the session-scoped runtime context from scratch using the + // region resolved at login time (the same value that selected the OAuth + // host), so the persisted region can never disagree with the control + // plane that issued the token. + rebuilt := projectContext{ + CurrentProjectID: nil, + CurrentProjectName: nil, + CurrentRegion: loginRegion, } - ctx.CurrentRegion = nextRegion - ctx.PreferredRegion = nextRegion - if err := saveContext(a.env, ctx); err != nil { - return nil, err + if err := saveContext(a.env, rebuilt); err != nil { + return err } - return map[string]any{"action": "login", "expiresAt": token.ExpiresAt, "scope": token.Scope, "status": "authenticated"}, nil + + // Logging in (or switching regions) discards the previous project + // selection and cached project list so a freshly authenticated session + // never routes commands or tab-completion through stale control-plane + // state. config.json and logs are intentionally left untouched. + _ = clearProjectListCache(a.env) + return nil } func (a *App) logout() (map[string]any, error) { @@ -116,7 +139,14 @@ func (a *App) authStatus() (map[string]any, error) { if s == nil { return map[string]any{"action": "status", "authenticated": false, "expiresAt": nil, "scope": nil, "status": "unauthenticated"}, nil } - return map[string]any{"action": "status", "authenticated": true, "expiresAt": s.ExpiresAt, "scope": s.Scope, "status": "authenticated"}, nil + return map[string]any{ + "action": "status", + "authenticated": true, + "expiresAt": s.ExpiresAt, + "region": a.authRegion(), + "scope": s.Scope, + "status": "authenticated", + }, nil } type oauthConfig struct { @@ -126,14 +156,75 @@ type oauthConfig struct { Scope string } -func (a *App) oauthConfig() oauthConfig { - base := strings.TrimRight(a.env["AGORA_OAUTH_BASE_URL"], "/") +func (a *App) oauthConfigForRegion(region string) oauthConfig { + base := strings.TrimRight(a.oauthBaseURLForRegion(region), "/") return oauthConfig{ AuthorizeURL: base + "/api/v0/oauth/authorize", TokenURL: base + "/api/v0/oauth/token", - ClientID: a.env["AGORA_OAUTH_CLIENT_ID"], - Scope: a.env["AGORA_OAUTH_SCOPE"], + ClientID: valueOrDefault(a.env["AGORA_OAUTH_CLIENT_ID"], defaultOAuthClientID), + Scope: valueOrDefault(a.env["AGORA_OAUTH_SCOPE"], defaultOAuthScope), + } +} + +// oauthBaseURLForRegion resolves the OAuth / SSO base URL for the requested +// region. Resolution order is: +// +// 1. an explicit process-env override (AGORA_OAUTH_BASE_URL), +// 2. the built-in region default (cn vs global). +// +// As with apiBaseURLForRegion, the explicit-env check uses the original +// process environment so only user-provided overrides bypass region defaults. +func (a *App) oauthBaseURLForRegion(region string) string { + if override := strings.TrimSpace(a.explicitEnvValue("AGORA_OAUTH_BASE_URL")); override != "" { + return override + } + if region == regionCN { + return oauthBaseURLCN + } + return oauthBaseURL +} + +// apiBaseURLForRegion resolves the control-plane API base URL for the +// requested region. Resolution order is: +// +// 1. an explicit process-env override (AGORA_API_BASE_URL), +// 2. the built-in region default (cn vs global). +// +// The explicit-env check intentionally uses explicitEnvValue so only +// user-provided overrides bypass region-aware host selection. +func (a *App) apiBaseURLForRegion(region string) string { + if override := strings.TrimSpace(a.explicitEnvValue("AGORA_API_BASE_URL")); override != "" { + return override + } + if region == regionCN { + return apiBaseURLCN + } + return apiBaseURL +} + +// explicitEnvValue returns the value the user explicitly supplied in the +// process environment before the CLI applied config-derived defaults. +// Prefer this over a.env when the code needs to distinguish: +// +// 1. a real user override such as `AGORA_API_BASE_URL=... agora ...`, from +// 2. a default value injected later by applyConfigToEnv(). +// +// That distinction matters for region-aware endpoint selection. +func (a *App) explicitEnvValue(key string) string { + if a.osEnv != nil { + return a.osEnv[key] + } + if a.env != nil { + return a.env[key] + } + return "" +} + +func valueOrDefault(value, fallback string) string { + if trimmed := strings.TrimSpace(value); trimmed != "" { + return trimmed } + return fallback } type pkcePair struct { @@ -308,7 +399,7 @@ func (a *App) exchangeAuthorizationCode(tokenURL, clientID, code, codeVerifier, } func (a *App) refreshAccessToken(refreshToken string) (session, error) { - cfg := a.oauthConfig() + cfg := a.oauthConfigForRegion(a.authRegionFromContext()) values := url.Values{ "client_id": []string{cfg.ClientID}, "grant_type": []string{"refresh_token"}, @@ -470,16 +561,7 @@ func readConfirmYesDefault(in io.Reader, out io.Writer, prompt string) (bool, er } } -func (a *App) loginPromptRegion() string { - ctx, err := loadContext(a.env) - if err != nil { - return "" - } - if ctx.PreferredRegion == "global" || ctx.PreferredRegion == "cn" { - return ctx.PreferredRegion - } - return "" -} +func (a *App) loginPromptRegion() string { return a.authRegionFromContext() } func (a *App) promptForLogin() error { if !a.shouldPromptForLogin() { @@ -550,7 +632,7 @@ func (a *App) apiRequest(method, pathname string, query map[string]string, body return err } makeReq := func(token *session) (*http.Request, error) { - base := strings.TrimRight(a.env["AGORA_API_BASE_URL"], "/") + base := strings.TrimRight(a.apiBaseURLForRegion(a.authRegionFromContext()), "/") u, err := url.Parse(base + pathname) if err != nil { return nil, err diff --git a/internal/cli/auth_test.go b/internal/cli/auth_test.go index d1a883c..9101e29 100644 --- a/internal/cli/auth_test.go +++ b/internal/cli/auth_test.go @@ -7,6 +7,87 @@ import ( "testing" ) +func TestOAuthConfigForRegion(t *testing.T) { + app := &App{ + cfg: defaultConfig(), + env: map[string]string{ + "AGORA_OAUTH_CLIENT_ID": "test-client", + "AGORA_OAUTH_SCOPE": "basic_info,console", + }, + } + + t.Run("cn region uses shengwang sso by default", func(t *testing.T) { + cfg := app.oauthConfigForRegion("cn") + if cfg.AuthorizeURL != oauthBaseURLCN+"/api/v0/oauth/authorize" { + t.Fatalf("unexpected authorize url: %s", cfg.AuthorizeURL) + } + if cfg.TokenURL != oauthBaseURLCN+"/api/v0/oauth/token" { + t.Fatalf("unexpected token url: %s", cfg.TokenURL) + } + }) + + t.Run("global uses default agora sso", func(t *testing.T) { + cfg := app.oauthConfigForRegion("global") + if cfg.AuthorizeURL != oauthBaseURL+"/api/v0/oauth/authorize" { + t.Fatalf("unexpected authorize url: %s", cfg.AuthorizeURL) + } + if cfg.TokenURL != oauthBaseURL+"/api/v0/oauth/token" { + t.Fatalf("unexpected token url: %s", cfg.TokenURL) + } + }) + + t.Run("env override wins over region default", func(t *testing.T) { + app.env["AGORA_OAUTH_BASE_URL"] = "https://auth.example.com" + cfg := app.oauthConfigForRegion("cn") + if cfg.AuthorizeURL != "https://auth.example.com/api/v0/oauth/authorize" { + t.Fatalf("unexpected authorize url: %s", cfg.AuthorizeURL) + } + if cfg.TokenURL != "https://auth.example.com/api/v0/oauth/token" { + t.Fatalf("unexpected token url: %s", cfg.TokenURL) + } + }) + + t.Run("client and scope default without env", func(t *testing.T) { + app.env = map[string]string{} + cfg := app.oauthConfigForRegion("global") + if cfg.ClientID != defaultOAuthClientID || cfg.Scope != defaultOAuthScope { + t.Fatalf("unexpected oauth defaults: %+v", cfg) + } + }) +} + +func TestAPIBaseURLForRegion(t *testing.T) { + app := &App{ + cfg: defaultConfig(), + } + + t.Run("cn region uses cn cli api by default", func(t *testing.T) { + if got := app.apiBaseURLForRegion("cn"); got != apiBaseURLCN { + t.Fatalf("unexpected api base url: %s", got) + } + }) + + t.Run("global uses default cli api", func(t *testing.T) { + if got := app.apiBaseURLForRegion("global"); got != apiBaseURL { + t.Fatalf("unexpected api base url: %s", got) + } + }) + + t.Run("env override wins over region default", func(t *testing.T) { + app.osEnv = map[string]string{"AGORA_API_BASE_URL": "https://api.example.com"} + if got := app.apiBaseURLForRegion("cn"); got != "https://api.example.com" { + t.Fatalf("unexpected api base url: %s", got) + } + app.osEnv = nil + }) + + t.Run("config does not override region default", func(t *testing.T) { + if got := app.apiBaseURLForRegion("cn"); got != apiBaseURLCN { + t.Fatalf("unexpected api base url: %s", got) + } + }) +} + func TestReadConfirmYesDefaultAcceptsEnterAndRepromptsInvalidInput(t *testing.T) { t.Run("enter defaults to yes", func(t *testing.T) { var out bytes.Buffer diff --git a/internal/cli/commands.go b/internal/cli/commands.go index 191cd03..9185553 100644 --- a/internal/cli/commands.go +++ b/internal/cli/commands.go @@ -301,7 +301,7 @@ func (a *App) buildOpenCommand() *cobra.Command { agora open --target docs --browser `), RunE: func(cmd *cobra.Command, _ []string) error { - url, err := resolveOpenTarget(target, a.osEnv) + url, err := resolveOpenTarget(target, a.authRegion(), a.osEnv) if err != nil { return err } @@ -347,7 +347,7 @@ func (a *App) buildLoginCommand(use string) *cobra.Command { }, } cmd.Flags().BoolVar(&noBrowser, "no-browser", false, "print the login URL instead of auto-opening a browser") - cmd.Flags().StringVar(®ion, "region", "", "control plane region for login defaults (global or cn)") + cmd.Flags().StringVar(®ion, "region", "global", "control plane region for login (global or cn)") return cmd } @@ -509,23 +509,10 @@ func (a *App) buildConfigCommand() *cobra.Command { Example: example(` agora config update --output json agora config update --browser-auto-open=false - agora config update --api-base-url https://agora-cli.agora.io agora config update --debug=true `), RunE: func(cmd *cobra.Command, _ []string) error { next := a.cfg - if cmd.Flags().Changed("api-base-url") { - next.APIBaseURL = cfg.APIBaseURL - } - if cmd.Flags().Changed("oauth-base-url") { - next.OAuthBaseURL = cfg.OAuthBaseURL - } - if cmd.Flags().Changed("oauth-client-id") { - next.OAuthClientID = cfg.OAuthClientID - } - if cmd.Flags().Changed("oauth-scope") { - next.OAuthScope = cfg.OAuthScope - } if cmd.Flags().Changed("telemetry-enabled") { next.TelemetryEnabled = telemetryEnabled } @@ -553,10 +540,6 @@ func (a *App) buildConfigCommand() *cobra.Command { return renderResult(cmd, "config update", next) }, } - update.Flags().StringVar(&cfg.APIBaseURL, "api-base-url", cfg.APIBaseURL, "default CLI API base URL") - update.Flags().StringVar(&cfg.OAuthBaseURL, "oauth-base-url", cfg.OAuthBaseURL, "default OAuth base URL") - update.Flags().StringVar(&cfg.OAuthClientID, "oauth-client-id", cfg.OAuthClientID, "default OAuth client ID") - update.Flags().StringVar(&cfg.OAuthScope, "oauth-scope", cfg.OAuthScope, "default OAuth scope") update.Flags().BoolVar(&telemetryEnabled, "telemetry-enabled", false, "persist telemetry preference; use --telemetry-enabled=false to disable") update.Flags().BoolVar(&browserAutoOpen, "browser-auto-open", false, "persist browser auto-open preference; use --browser-auto-open=false to disable") update.Flags().StringVar(&cfg.LogLevel, "log-level", cfg.LogLevel, "persist default log level") @@ -600,17 +583,17 @@ These commands do not clone local application code. Use "agora quickstart" for s } func (a *App) buildProjectCreate() *cobra.Command { - var region, rtmDataCenter, template string + var rtmDataCenter, template string var features []string var dryRun bool var idempotencyKey string cmd := &cobra.Command{ Use: "create [name]", Short: "Create a new remote Agora project", - Long: "Create a new Agora project resource in the selected control-plane region and optionally enable features after creation.", + Long: "Create a new Agora project resource in the current control-plane region and optionally enable features after creation.", Example: example(` agora project create my-app - agora project create my-agent-demo --region global --feature rtc --feature convoai + agora project create my-agent-demo --feature rtc --feature convoai agora project create my-rtm-demo --rtm-data-center EU agora project create my-voice-agent --template voice-agent `), @@ -634,7 +617,7 @@ func (a *App) buildProjectCreate() *cobra.Command { "enabledFeatures": plannedFeatures, "idempotencyKey": idempotencyKey, "projectName": args[0], - "region": region, + "region": a.authRegion(), "status": "planned", "template": template, } @@ -643,14 +626,13 @@ func (a *App) buildProjectCreate() *cobra.Command { } return renderResult(cmd, "project create", result) } - data, err := a.projectCreate(args[0], region, template, features, normalizedRTMDataCenter, idempotencyKey) + data, err := a.projectCreate(args[0], template, features, normalizedRTMDataCenter, idempotencyKey) if err != nil { return err } return renderResult(cmd, "project create", data) }, } - cmd.Flags().StringVar(®ion, "region", "", "control plane region for the project context (global or cn)") cmd.Flags().StringVar(&rtmDataCenter, "rtm-data-center", "", "RTM data center to configure when rtm is enabled (CN, NA, EU, or AP); defaults to NA") cmd.Flags().StringVar(&template, "template", "", "apply a higher-level project preset such as voice-agent") cmd.Flags().StringArrayVar(&features, "feature", nil, fmt.Sprintf("enable one or more features after creation; defaults to %s; convoai also enables rtm", featureListString())) @@ -886,6 +868,7 @@ When .agora/project.json exists, this command updates it for the selected projec } write.Flags().Bool("overwrite", false, "replace the target file with only Agora App ID and App Certificate values") write.Flags().Bool("append", false, "append Agora App ID and App Certificate values when no existing values are present") + write.Flags().StringVar(&a.projectEnvProject, "project", "", "project ID or exact project name; defaults to the current project context") write.Flags().StringVar(&a.projectEnvWriteTemplate, "template", "", "credential key layout: nextjs or standard; if omitted, detect Next.js from the workspace") cmd.AddCommand(write) return cmd diff --git a/internal/cli/config.go b/internal/cli/config.go index ebd6769..81ff594 100644 --- a/internal/cli/config.go +++ b/internal/cli/config.go @@ -8,12 +8,8 @@ import "strings" // public names. type appConfig struct { Version int `json:"version"` - APIBaseURL string `json:"apiBaseUrl"` BrowserAutoOpen bool `json:"browserAutoOpen"` LogLevel string `json:"logLevel"` - OAuthBaseURL string `json:"oauthBaseUrl"` - OAuthClientID string `json:"oauthClientId"` - OAuthScope string `json:"oauthScope"` Output outputMode `json:"output"` TelemetryEnabled bool `json:"telemetryEnabled"` // Debug controls whether `appendAppLog` mirrors structured log @@ -29,13 +25,9 @@ type appConfig struct { // back to these values. func defaultConfig() appConfig { return appConfig{ - Version: 3, - APIBaseURL: "https://agora-cli.agora.io", + Version: 4, BrowserAutoOpen: true, LogLevel: "info", - OAuthBaseURL: "https://sso2.agora.io", - OAuthClientID: "agora_web_cli", - OAuthScope: "basic_info,console", Output: outputPretty, TelemetryEnabled: true, Debug: false, @@ -46,24 +38,12 @@ func defaultConfig() appConfig { // missing or wrong-typed fields. This is the partial-update path used by // the migration flow in ensureAppConfigState. func mergeConfig(cfg *appConfig, raw map[string]any) { - if v, ok := raw["apiBaseUrl"].(string); ok && v != "" { - cfg.APIBaseURL = v - } if v, ok := raw["browserAutoOpen"].(bool); ok { cfg.BrowserAutoOpen = v } if v, ok := raw["logLevel"].(string); ok && v != "" { cfg.LogLevel = v } - if v, ok := raw["oauthBaseUrl"].(string); ok && v != "" { - cfg.OAuthBaseURL = v - } - if v, ok := raw["oauthClientId"].(string); ok && v != "" { - cfg.OAuthClientID = v - } - if v, ok := raw["oauthScope"].(string); ok && v != "" { - cfg.OAuthScope = v - } if v, ok := raw["output"].(string); ok && (v == "json" || v == "pretty") { cfg.Output = outputMode(v) } @@ -87,10 +67,6 @@ func mergeConfig(cfg *appConfig, raw map[string]any) { // populated. DO_NOT_TRACK forces telemetry off regardless of the persisted // config preference (Console-style telemetry opt-out signal). func (a *App) applyConfigToEnv() { - a.setEnvIfMissing("AGORA_API_BASE_URL", a.cfg.APIBaseURL) - a.setEnvIfMissing("AGORA_OAUTH_BASE_URL", a.cfg.OAuthBaseURL) - a.setEnvIfMissing("AGORA_OAUTH_CLIENT_ID", a.cfg.OAuthClientID) - a.setEnvIfMissing("AGORA_OAUTH_SCOPE", a.cfg.OAuthScope) a.setEnvIfMissing("AGORA_OUTPUT", string(a.cfg.Output)) a.setEnvIfMissing("AGORA_SENTRY_ENABLED", boolString(a.cfg.TelemetryEnabled)) a.setEnvIfMissing("AGORA_BROWSER_AUTO_OPEN", boolString(a.cfg.BrowserAutoOpen)) diff --git a/internal/cli/docgen.go b/internal/cli/docgen.go index c097687..0fe5271 100644 --- a/internal/cli/docgen.go +++ b/internal/cli/docgen.go @@ -89,7 +89,7 @@ func RenderCommandReference(out io.Writer, root *cobra.Command) error { } } - _, err := io.WriteString(out, b.String()) + _, err := io.WriteString(out, strings.TrimRight(b.String(), "\n")+"\n") return err } diff --git a/internal/cli/init.go b/internal/cli/init.go index 73fc7ee..de1b875 100644 --- a/internal/cli/init.go +++ b/internal/cli/init.go @@ -39,7 +39,6 @@ func (a *App) buildInitCommand() *cobra.Command { var templateID string var dir string var existingProject string - var region string var rtmDataCenter string var features []string var agentRules []string @@ -90,7 +89,7 @@ Use --feature to specify which features to enable on a newly created project (re !isCIEnvironment(a.osEnv) && isTTY(os.Stdin) progress := jsonProgressFor(a, cmd, "init") - result, err := a.initProject(args[0], targetDir, *template, existingProject, region, features, rtmDataCenter, newProject, promptForReuse, cmd.ErrOrStderr(), os.Stdin, progress) + result, err := a.initProject(args[0], targetDir, *template, existingProject, features, rtmDataCenter, newProject, promptForReuse, cmd.ErrOrStderr(), os.Stdin, progress) if err != nil { return err } @@ -107,7 +106,6 @@ Use --feature to specify which features to enable on a newly created project (re cmd.Flags().StringVar(&templateID, "template", "", "quickstart template ID to use") cmd.Flags().StringVar(&dir, "dir", "", "target directory for the cloned quickstart; defaults to ") cmd.Flags().StringVar(&existingProject, "project", "", "existing project ID or exact project name to bind to") - cmd.Flags().StringVar(®ion, "region", "", "control plane region for newly created projects (global or cn)") cmd.Flags().StringVar(&rtmDataCenter, "rtm-data-center", "", "RTM data center to configure when rtm is enabled on a newly created project (CN, NA, EU, or AP); defaults to NA") cmd.Flags().StringArrayVar(&features, "feature", nil, fmt.Sprintf("enable a feature on the newly created project (repeatable); defaults to %s; convoai also enables rtm", featureListString())) cmd.Flags().StringArrayVar(&agentRules, "add-agent-rules", nil, "write AI agent rules into the quickstart (repeatable: cursor, claude, windsurf)") @@ -326,20 +324,10 @@ func (a *App) resolveInitProject(ctx projectContext, item projectSummary) (proje if err != nil { return projectTarget{}, err } - region := ctx.CurrentRegion - if region == "" { - region = "global" - } - if item.Region != nil && *item.Region != "" { - region = *item.Region - } - if project.Region != nil && *project.Region != "" { - region = *project.Region - } - return projectTarget{project: project, region: region}, nil + return projectTarget{project: project, region: currentRegionFromContext(ctx)}, nil } -func (a *App) initProject(name, targetDir string, template quickstartTemplate, existingProject, region string, features []string, rtmDataCenter string, newProject bool, promptForReuse bool, promptOut io.Writer, promptIn io.Reader, progress progressEmitter) (map[string]any, error) { +func (a *App) initProject(name, targetDir string, template quickstartTemplate, existingProject string, features []string, rtmDataCenter string, newProject bool, promptForReuse bool, promptOut io.Writer, promptIn io.Reader, progress progressEmitter) (map[string]any, error) { var target projectTarget projectAction := "existing" projectSelectionReason := "explicit_project" @@ -414,7 +402,7 @@ func (a *App) initProject(name, targetDir string, template quickstartTemplate, e if needsCreate { featuresToEnable := normalizeProjectCreateFeatures(features) progress.emit("project:create", "Creating Agora project", map[string]any{"projectName": name, "features": featuresToEnable}) - projectResult, err := a.projectCreate(name, region, "", featuresToEnable, rtmDataCenter, "") + projectResult, err := a.projectCreate(name, "", featuresToEnable, rtmDataCenter, "") if err != nil { return nil, err } @@ -445,9 +433,6 @@ func (a *App) initProject(name, targetDir string, template quickstartTemplate, e ctx.CurrentProjectID = &target.project.ProjectID ctx.CurrentProjectName = &target.project.Name ctx.CurrentRegion = target.region - if ctx.PreferredRegion == "" { - ctx.PreferredRegion = target.region - } if err := saveContext(a.env, ctx); err != nil { return nil, err } diff --git a/internal/cli/install_doctor.go b/internal/cli/install_doctor.go index fe50d77..9642a59 100644 --- a/internal/cli/install_doctor.go +++ b/internal/cli/install_doctor.go @@ -252,13 +252,7 @@ func (a *App) installDoctorAgoraHomeCheck() doctorCheckCategory { func (a *App) installDoctorNetworkCheck() doctorCheckCategory { items := []doctorCheckItem{} - endpoints := []struct { - name string - url string - }{ - {"api", a.env["AGORA_API_BASE_URL"]}, - {"oauth", a.env["AGORA_OAUTH_BASE_URL"]}, - } + endpoints := a.installDoctorNetworkEndpoints() client := &http.Client{Timeout: 5 * time.Second} for _, ep := range endpoints { if strings.TrimSpace(ep.url) == "" { @@ -310,6 +304,20 @@ func (a *App) installDoctorNetworkCheck() doctorCheckCategory { return categoryWithStatus("network", items) } +func (a *App) installDoctorNetworkEndpoints() []struct { + name string + url string +} { + region := a.authRegion() + return []struct { + name string + url string + }{ + {"api", a.apiBaseURLForRegion(region)}, + {"oauth", a.oauthBaseURLForRegion(region)}, + } +} + func (a *App) installDoctorAuthCheck() doctorCheckCategory { items := []doctorCheckItem{} data, err := a.authStatus() diff --git a/internal/cli/install_doctor_test.go b/internal/cli/install_doctor_test.go index 900fe66..16cd1e7 100644 --- a/internal/cli/install_doctor_test.go +++ b/internal/cli/install_doctor_test.go @@ -93,3 +93,43 @@ func TestPathFixSuggestionEmptyInstallDirFallsBackToInstallerHint(t *testing.T) t.Fatalf("expected no half-built export line when install dir is empty, got %q", got) } } + +func TestInstallDoctorNetworkEndpointsFollowCurrentRegion(t *testing.T) { + t.Run("global uses global endpoints", func(t *testing.T) { + app := &App{ + cfg: defaultConfig(), + env: map[string]string{"XDG_CONFIG_HOME": t.TempDir()}, + } + endpoints := app.installDoctorNetworkEndpoints() + if len(endpoints) != 2 { + t.Fatalf("expected 2 endpoints, got %+v", endpoints) + } + if endpoints[0].url != apiBaseURL { + t.Fatalf("expected global api endpoint, got %q", endpoints[0].url) + } + if endpoints[1].url != oauthBaseURL { + t.Fatalf("expected global oauth endpoint, got %q", endpoints[1].url) + } + }) + + t.Run("cn uses cn endpoints", func(t *testing.T) { + env := map[string]string{"XDG_CONFIG_HOME": t.TempDir()} + if err := saveContext(env, projectContext{CurrentRegion: regionCN}); err != nil { + t.Fatal(err) + } + app := &App{ + cfg: defaultConfig(), + env: env, + } + endpoints := app.installDoctorNetworkEndpoints() + if len(endpoints) != 2 { + t.Fatalf("expected 2 endpoints, got %+v", endpoints) + } + if endpoints[0].url != apiBaseURLCN { + t.Fatalf("expected cn api endpoint, got %q", endpoints[0].url) + } + if endpoints[1].url != oauthBaseURLCN { + t.Fatalf("expected cn oauth endpoint, got %q", endpoints[1].url) + } + }) +} diff --git a/internal/cli/integration_auth_test.go b/internal/cli/integration_auth_test.go index ae06549..38c6af1 100644 --- a/internal/cli/integration_auth_test.go +++ b/internal/cli/integration_auth_test.go @@ -17,7 +17,7 @@ func TestCLILoginAndWhoAmI(t *testing.T) { oauth := newFakeOAuthServer() defer oauth.server.Close() - result := runCLI(t, []string{"login"}, cliRunOptions{env: map[string]string{ + result := runCLI(t, []string{"login", "--region", "cn"}, cliRunOptions{env: map[string]string{ "XDG_CONFIG_HOME": configHome, "AGORA_OAUTH_BASE_URL": oauth.baseURL, "AGORA_OAUTH_CLIENT_ID": "test-public-client", @@ -69,6 +69,9 @@ func TestCLILoginAndWhoAmI(t *testing.T) { if data["authenticated"] != true { t.Fatalf("expected authenticated response, got %v", status.stdout) } + if data["region"] != "cn" { + t.Fatalf("expected persisted auth region cn, got %v", data["region"]) + } } func TestCLIAuthStatusExitCode(t *testing.T) { diff --git a/internal/cli/integration_project_test.go b/internal/cli/integration_project_test.go index 09cde05..40f31b0 100644 --- a/internal/cli/integration_project_test.go +++ b/internal/cli/integration_project_test.go @@ -25,7 +25,6 @@ func TestCLIProjectEnvAndDoctor(t *testing.T) { CurrentProjectID: &alpha.ProjectID, CurrentProjectName: &alpha.Name, CurrentRegion: "global", - PreferredRegion: "global", }); err != nil { t.Fatal(err) } @@ -248,7 +247,6 @@ func TestCLIProjectDoctorDeepDetectsWorkspaceDrift(t *testing.T) { CurrentProjectID: &project.ProjectID, CurrentProjectName: &project.Name, CurrentRegion: "global", - PreferredRegion: "global", }); err != nil { t.Fatal(err) } @@ -305,12 +303,13 @@ func TestCLIProjectEnvFormatsAndWriteRules(t *testing.T) { project.FeatureState.ConvoAIEnabled = true project.FeatureState.RTMEnabled = true api.projects[project.ProjectID] = &project + explicitProject := buildFakeProject("Project Explicit", "prj_explicit", "app_explicit", "global") + api.projects[explicitProject.ProjectID] = &explicitProject persistSessionForIntegration(t, configHome) if err := saveContext(map[string]string{"XDG_CONFIG_HOME": configHome}, projectContext{ CurrentProjectID: &project.ProjectID, CurrentProjectName: &project.Name, CurrentRegion: "global", - PreferredRegion: "global", }); err != nil { t.Fatal(err) } @@ -336,6 +335,27 @@ func TestCLIProjectEnvFormatsAndWriteRules(t *testing.T) { t.Fatalf("unexpected json env result: %+v", jsonResult) } + explicitProjectDir := t.TempDir() + explicitProjectPath := filepath.Join(explicitProjectDir, "explicit.env") + explicitProjectWrite := runCLI(t, []string{"project", "env", "write", explicitProjectPath, "--project", explicitProject.ProjectID, "--overwrite", "--template", "standard", "--json"}, cliRunOptions{ + env: map[string]string{ + "XDG_CONFIG_HOME": configHome, + "AGORA_API_BASE_URL": api.baseURL, + "AGORA_LOG_LEVEL": "error", + }, + workdir: explicitProjectDir, + }) + if explicitProjectWrite.exitCode != 0 || !strings.Contains(explicitProjectWrite.stdout, `"projectId":"prj_explicit"`) { + t.Fatalf("unexpected explicit project write result: %+v", explicitProjectWrite) + } + explicitProjectEnv, err := os.ReadFile(explicitProjectPath) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(string(explicitProjectEnv), "AGORA_APP_ID=app_explicit") { + t.Fatalf("expected explicit project env values, got %s", string(explicitProjectEnv)) + } + if err := os.WriteFile(filepath.Join(projectDir, ".env.custom"), []byte("FOO=bar\n"), 0o644); err != nil { t.Fatal(err) } @@ -445,7 +465,6 @@ func TestCLIProjectEnvWriteRecordsProjectTypeInBinding(t *testing.T) { CurrentProjectID: &project.ProjectID, CurrentProjectName: &project.Name, CurrentRegion: "global", - PreferredRegion: "global", }); err != nil { t.Fatal(err) } @@ -501,7 +520,6 @@ func TestCLIFeatureEnableAndDoctorAuthError(t *testing.T) { CurrentProjectID: &project.ProjectID, CurrentProjectName: &project.Name, CurrentRegion: "global", - PreferredRegion: "global", }); err != nil { t.Fatal(err) } diff --git a/internal/cli/integration_quickstart_test.go b/internal/cli/integration_quickstart_test.go index 1c8e8f2..936b46e 100644 --- a/internal/cli/integration_quickstart_test.go +++ b/internal/cli/integration_quickstart_test.go @@ -41,7 +41,6 @@ func TestCLIQuickstartListAndCreate(t *testing.T) { CurrentProjectID: &project.ProjectID, CurrentProjectName: &project.Name, CurrentRegion: "global", - PreferredRegion: "global", }); err != nil { t.Fatal(err) } @@ -246,7 +245,6 @@ func TestCLIQuickstartEnvWriteUsesTargetRepoBindingPrecedence(t *testing.T) { CurrentProjectID: &beta.ProjectID, CurrentProjectName: &beta.Name, CurrentRegion: "global", - PreferredRegion: "global", }); err != nil { t.Fatal(err) } diff --git a/internal/cli/integration_test.go b/internal/cli/integration_test.go index 9169007..a9e79d1 100644 --- a/internal/cli/integration_test.go +++ b/internal/cli/integration_test.go @@ -349,7 +349,7 @@ type fakeProject struct { Name string `json:"name"` ProjectID string `json:"projectId"` ProjectType string `json:"projectType"` - Region string `json:"region"` + Region string `json:"-"` SignKey *string `json:"signKey"` Stage int `json:"stage"` Status string `json:"status"` @@ -432,7 +432,6 @@ func newFakeCLIBFF() *fakeCLIBFF { "name": project.Name, "projectId": project.ProjectID, "projectType": project.ProjectType, - "region": project.Region, "signKey": project.SignKey, "stage": project.Stage, "status": project.Status, diff --git a/internal/cli/mcp.go b/internal/cli/mcp.go index b147d98..51bb808 100644 --- a/internal/cli/mcp.go +++ b/internal/cli/mcp.go @@ -191,7 +191,6 @@ func mcpTools() []map[string]any { mcpTool("agora.project.use", "Select the current Agora project", map[string]string{"project": "string"}), mcpTool("agora.project.create", "Create a new Agora project and optionally enable features", map[string]string{ "name": "string", - "region": "string", "template": "string", "features": "array", "rtmDataCenter": "string", @@ -223,7 +222,6 @@ func mcpTools() []map[string]any { "template": "string", "project": "string", "newProject": "boolean", - "region": "string", "rtmDataCenter": "string", "features": "array", }), @@ -360,7 +358,6 @@ func (a *App) callMCPTool(name string, args map[string]any, progress progressEmi progress.emit("project:create", "Creating Agora project", map[string]any{"projectName": name, "features": projectCreateFeatures(stringArg(args, "template"), features)}) result, err := a.projectCreate( name, - stringArg(args, "region"), stringArg(args, "template"), features, rtmDataCenter, @@ -460,7 +457,6 @@ func (a *App) callMCPTool(name string, args map[string]any, progress progressEmi defaultString(stringArg(args, "dir"), name), *template, stringArg(args, "project"), - stringArg(args, "region"), stringSliceArg(args, "features"), stringArg(args, "rtmDataCenter"), boolArg(args, "newProject", false), diff --git a/internal/cli/open_targets.go b/internal/cli/open_targets.go index b9c6315..b273afc 100644 --- a/internal/cli/open_targets.go +++ b/internal/cli/open_targets.go @@ -19,8 +19,10 @@ import ( // same docs tree. Pages publishes these files under /md/ after // rendering the human HTML site, giving agents stable source URLs // such as /md/commands.md and /md/automation.md. -// - consoleURL is the public Agora Console front door. -// - productDocsURL is the public product documentation site. +// - consoleURL / consoleURLCN are the public Console front doors for +// the global and cn control planes respectively. +// - productDocsURL / productDocsURLCN are the public product +// documentation sites for the global and cn control planes. // // A smoke test in open_targets_test.go validates that each URL // parses, uses HTTPS, and is non-empty so a typo here surfaces in CI. @@ -32,7 +34,9 @@ const ( cliDocsURL = "https://agoraio.github.io/cli/" cliDocsMarkdownURL = "https://agoraio.github.io/cli/md/index.md" consoleURL = "https://console.agora.io" + consoleURLCN = "https://console.shengwang.cn" productDocsURL = "https://docs.agora.io" + productDocsURLCN = "https://doc.shengwang.cn" ) // openTargetEnv maps each open-target name to the environment variable @@ -48,15 +52,15 @@ var openTargetEnv = map[string]string{ } // resolveOpenTarget returns the URL the `agora open` command should -// open or print for the given target name. Resolution order: +// open or print for the given target name and active login region. +// Resolution order: // -// 1. Per-target environment override from openTargetEnv (when set -// and non-empty), e.g. AGORA_DOCS_URL=https://staging-docs.example/ -// 2. Compiled-in canonical URL. +// 1. Region-agnostic environment override from openTargetEnv +// 2. Compiled-in region default // // Returns a structured error for unknown targets so the message stays // consistent with the rest of the CLI's input-validation errors. -func resolveOpenTarget(target string, env map[string]string) (string, error) { +func resolveOpenTarget(target, region string, env map[string]string) (string, error) { envKey, known := openTargetEnv[target] if !known { return "", fmt.Errorf("unknown open target %q. Use console, docs, docs-md, or product-docs.", target) @@ -66,15 +70,29 @@ func resolveOpenTarget(target string, env map[string]string) (string, error) { return override, nil } } - switch target { - case "console": - return consoleURL, nil - case "docs": - return cliDocsURL, nil - case "docs-md": - return cliDocsMarkdownURL, nil - case "product-docs": - return productDocsURL, nil + + if normalizeContextRegion(region) == regionCN { + switch target { + case "console": + return consoleURLCN, nil + case "docs": + return cliDocsURL, nil + case "docs-md": + return cliDocsMarkdownURL, nil + case "product-docs": + return productDocsURLCN, nil + } + } else { + switch target { + case "console": + return consoleURL, nil + case "docs": + return cliDocsURL, nil + case "docs-md": + return cliDocsMarkdownURL, nil + case "product-docs": + return productDocsURL, nil + } } // Unreachable: openTargetEnv keys and switch cases are kept in sync. return "", fmt.Errorf("unknown open target %q. Use console, docs, docs-md, or product-docs.", target) diff --git a/internal/cli/open_targets_test.go b/internal/cli/open_targets_test.go index 2b33831..c0c9c1d 100644 --- a/internal/cli/open_targets_test.go +++ b/internal/cli/open_targets_test.go @@ -15,7 +15,7 @@ import ( func TestOpenTargetURLsAreWellFormed(t *testing.T) { for _, target := range []string{"console", "docs", "docs-md", "product-docs"} { t.Run(target, func(t *testing.T) { - url, err := resolveOpenTarget(target, nil) + url, err := resolveOpenTarget(target, regionGlobal, nil) if err != nil { t.Fatalf("resolveOpenTarget(%q) = error %v", target, err) } @@ -45,7 +45,7 @@ func TestOpenTargetEnvOverridesWin(t *testing.T) { "product-docs": env["AGORA_PRODUCT_DOCS_URL"], } { t.Run(target, func(t *testing.T) { - got, err := resolveOpenTarget(target, env) + got, err := resolveOpenTarget(target, regionGlobal, env) if err != nil { t.Fatalf("resolveOpenTarget(%q) = error %v", target, err) } @@ -56,10 +56,28 @@ func TestOpenTargetEnvOverridesWin(t *testing.T) { } } +func TestOpenTargetCNDefaults(t *testing.T) { + got, err := resolveOpenTarget("console", regionCN, nil) + if err != nil { + t.Fatal(err) + } + if got != consoleURLCN { + t.Fatalf("cn console default = %q, want %q", got, consoleURLCN) + } + + got, err = resolveOpenTarget("product-docs", regionCN, nil) + if err != nil { + t.Fatal(err) + } + if got != productDocsURLCN { + t.Fatalf("cn product docs default = %q, want %q", got, productDocsURLCN) + } +} + // TestOpenTargetUnknownReturnsStructuredError confirms unknown // targets fail with the documented message rather than fallthrough. func TestOpenTargetUnknownReturnsStructuredError(t *testing.T) { - _, err := resolveOpenTarget("nope", nil) + _, err := resolveOpenTarget("nope", regionGlobal, nil) if err == nil { t.Fatal("expected error for unknown target") } @@ -73,7 +91,7 @@ func TestOpenTargetUnknownReturnsStructuredError(t *testing.T) { // indistinguishable from "unset" from the user's perspective. func TestOpenTargetEmptyOverrideFallsBackToCompiledIn(t *testing.T) { env := map[string]string{"AGORA_DOCS_URL": " "} - got, err := resolveOpenTarget("docs", env) + got, err := resolveOpenTarget("docs", regionGlobal, env) if err != nil { t.Fatal(err) } diff --git a/internal/cli/paths.go b/internal/cli/paths.go index 26a2368..3a67586 100644 --- a/internal/cli/paths.go +++ b/internal/cli/paths.go @@ -101,14 +101,14 @@ func writeSecureJSON(path string, value any) error { } // loadContext reads the persisted project context (currently selected -// project + region). A missing file returns the zero-value context with -// region defaults set to "global". +// project + active control-plane region). A missing file returns the +// zero-value context with region defaulted to "global". func loadContext(env map[string]string) (projectContext, error) { path, err := resolveContextFilePath(env) if err != nil { return projectContext{}, err } - ctx := projectContext{CurrentRegion: "global", PreferredRegion: "global"} + ctx := projectContext{CurrentRegion: regionGlobal} data, err := os.ReadFile(path) if errors.Is(err, os.ErrNotExist) { return ctx, nil @@ -119,12 +119,7 @@ func loadContext(env map[string]string) (projectContext, error) { if err := json.Unmarshal(data, &ctx); err != nil { return projectContext{}, err } - if ctx.CurrentRegion == "" { - ctx.CurrentRegion = "global" - } - if ctx.PreferredRegion == "" { - ctx.PreferredRegion = "global" - } + ctx.CurrentRegion = currentRegionFromContext(ctx) return ctx, nil } diff --git a/internal/cli/projects.go b/internal/cli/projects.go index 56977b3..907d846 100644 --- a/internal/cli/projects.go +++ b/internal/cli/projects.go @@ -58,7 +58,6 @@ func (a *App) resolveProjectByNameOrID(value string) (*projectSummary, error) { Name: project.Name, ProjectID: project.ProjectID, ProjectType: project.ProjectType, - Region: project.Region, SignKey: project.SignKey, Stage: project.Stage, Status: project.Status, @@ -126,33 +125,34 @@ func (a *App) resolveProjectTargetFrom(explicit, startPath string) (projectTarge if err != nil { return projectTarget{}, err } - region := ctx.CurrentRegion - if region == "" { - region = "global" - } - if project.Region != nil && *project.Region != "" { - region = *project.Region - } else if resolved.Region != nil && *resolved.Region != "" { - region = *resolved.Region - } - return projectTarget{project: project, region: region}, nil + return projectTarget{project: project, region: currentRegionFromContext(ctx)}, nil } if binding, ok, _, err := detectLocalProjectBindingFrom(startPath); err != nil { return projectTarget{}, err } else if ok && binding.ProjectID != "" { + // A repo-local binding pins both a project and the region that + // project lives in. The session context, meanwhile, carries the + // region the user last logged into. If the two disagree we must + // not silently route the request: the binding's project does not + // exist on the session's control plane, so the request would fail + // with a confusing "project not found". Fail fast with actionable + // guidance instead. An empty session region (fresh login default) + // is treated as "no opinion" and does not conflict. + bindingRegion := strings.TrimSpace(binding.Region) + sessionRegion := currentRegionFromContext(ctx) + if bindingRegion != "" && sessionRegion != "" && !strings.EqualFold(bindingRegion, sessionRegion) { + return projectTarget{}, &cliError{ + Message: fmt.Sprintf("This repo is bound to a %s project (.agora/project.json), but you are logged into %s. Run `agora login --region %s` to switch, or pass --project to override.", bindingRegion, sessionRegion, bindingRegion), + Code: "PROJECT_REGION_MISMATCH", + } + } project, err := a.getProject(binding.ProjectID) if err != nil { return projectTarget{}, err } - region := binding.Region - if region == "" { - region = ctx.CurrentRegion - } + region := bindingRegion if region == "" { - region = "global" - } - if project.Region != nil && *project.Region != "" { - region = *project.Region + region = sessionRegion } return projectTarget{project: project, region: region}, nil } @@ -163,11 +163,7 @@ func (a *App) resolveProjectTargetFrom(explicit, startPath string) (projectTarge if err != nil { return projectTarget{}, err } - region := ctx.CurrentRegion - if project.Region != nil && *project.Region != "" { - region = *project.Region - } - return projectTarget{project: project, region: region}, nil + return projectTarget{project: project, region: currentRegionFromContext(ctx)}, nil } func (a *App) getRTM2Config(projectID string) (map[string]any, error) { @@ -285,17 +281,12 @@ func (a *App) enableProjectFeature(feature string, project projectDetail, region } } -func (a *App) projectCreate(name, region, template string, features []string, rtmDataCenter string, idempotencyKey string) (map[string]any, error) { +func (a *App) projectCreate(name, template string, features []string, rtmDataCenter string, idempotencyKey string) (map[string]any, error) { ctx, err := loadContext(a.env) if err != nil { return nil, err } - if region == "" { - region = ctx.PreferredRegion - if region == "" { - region = "global" - } - } + region := currentRegionFromContext(ctx) features = projectCreateFeatures(template, features) rtmDataCenter, err = rtmDataCenterForFeatures(features, rtmDataCenter) if err != nil { @@ -320,7 +311,6 @@ func (a *App) projectCreate(name, region, template string, features []string, rt ctx.CurrentProjectID = &project.ProjectID ctx.CurrentProjectName = &project.Name ctx.CurrentRegion = region - ctx.PreferredRegion = region if err := saveContext(a.env, ctx); err != nil { return nil, err } @@ -405,19 +395,10 @@ func (a *App) projectUse(projectArg string) (map[string]any, error) { if resolved == nil { return nil, &cliError{Message: fmt.Sprintf("Project %q was not found. Run `agora project list` to see available projects.", projectArg), Code: "PROJECT_NOT_FOUND"} } - region := current.CurrentRegion - if region == "" { - region = current.PreferredRegion - } - if resolved.Region != nil && *resolved.Region != "" { - region = *resolved.Region - } + region := currentRegionFromContext(current) current.CurrentProjectID = &resolved.ProjectID current.CurrentProjectName = &resolved.Name current.CurrentRegion = region - if current.PreferredRegion == "" { - current.PreferredRegion = region - } if err := saveContext(a.env, current); err != nil { return nil, err } diff --git a/internal/cli/quickstart.go b/internal/cli/quickstart.go index 222e990..20e4bab 100644 --- a/internal/cli/quickstart.go +++ b/internal/cli/quickstart.go @@ -18,7 +18,9 @@ type quickstartTemplate struct { Description string Runtime string RepoURL string + RepoURLCN string DocsURL string + DocsURLCN string DetectPaths []string EnvExamplePath string EnvTargetPath string @@ -37,7 +39,9 @@ func quickstartTemplates() []quickstartTemplate { Description: "Clone the official Next.js conversational AI quickstart.", Runtime: "node", RepoURL: "https://github.com/AgoraIO-Conversational-AI/agent-quickstart-nextjs", + RepoURLCN: "https://github.com/AgoraIO-Conversational-AI/agent-quickstart-nextjs", DocsURL: "https://github.com/AgoraIO-Conversational-AI/agent-quickstart-nextjs", + DocsURLCN: "https://github.com/AgoraIO-Conversational-AI/agent-quickstart-nextjs", DetectPaths: []string{"env.local.example", "app"}, EnvExamplePath: "env.local.example", EnvTargetPath: ".env.local", @@ -53,7 +57,9 @@ func quickstartTemplates() []quickstartTemplate { Description: "Clone the official Python conversational AI quickstart.", Runtime: "python", RepoURL: "https://github.com/AgoraIO-Conversational-AI/agent-quickstart-python", + RepoURLCN: "https://github.com/AgoraIO-Conversational-AI/agent-quickstart-python", DocsURL: "https://github.com/AgoraIO-Conversational-AI/agent-quickstart-python", + DocsURLCN: "https://github.com/AgoraIO-Conversational-AI/agent-quickstart-python", DetectPaths: []string{"server/env.example", "server", "web-client"}, EnvExamplePath: "server/env.example", EnvTargetPath: "server/.env", @@ -69,7 +75,9 @@ func quickstartTemplates() []quickstartTemplate { Description: "Clone the official Go conversational AI quickstart.", Runtime: "go", RepoURL: "https://github.com/AgoraIO-Conversational-AI/agent-quickstart-go", + RepoURLCN: "https://github.com/AgoraIO-Conversational-AI/agent-quickstart-go", DocsURL: "https://github.com/AgoraIO-Conversational-AI/agent-quickstart-go", + DocsURLCN: "https://github.com/AgoraIO-Conversational-AI/agent-quickstart-go", DetectPaths: []string{"server-go/env.example", "server-go", "web-client"}, EnvExamplePath: "server-go/env.example", EnvTargetPath: "server-go/.env", @@ -140,10 +148,10 @@ func (a *App) buildQuickstartList() *cobra.Command { items = append(items, map[string]any{ "available": template.Available, "description": template.Description, - "docsUrl": template.DocsURL, + "docsUrl": quickstartDocsURL(template, a.authRegion()), "envDocs": template.EnvDocsSummary, "id": template.ID, - "repoUrl": template.RepoURL, + "repoUrl": quickstartRepoURLForRegion(template, a.authRegion()), "runtime": template.Runtime, "supportsInit": template.SupportsInit, "title": template.Title, @@ -340,7 +348,7 @@ func (a *App) quickstartCreate(template quickstartTemplate, targetDir, explicitP result := map[string]any{ "action": "create", "cloneUrl": repoURL, - "docsUrl": template.DocsURL, + "docsUrl": quickstartDocsURL(template, a.authRegion()), "envPath": envPath, "envStatus": envStatus, "metadataPath": "", @@ -421,6 +429,26 @@ func quickstartRepoOverrideKey(templateID string) string { return "AGORA_QUICKSTART_" + strings.ToUpper(strings.ReplaceAll(templateID, "-", "_")) + "_REPO_URL" } +// quickstartRepoURLForRegion returns the default quickstart repository URL +// for the active login region, falling back to the global URL when no +// cn-specific repository is configured. +func quickstartRepoURLForRegion(template quickstartTemplate, region string) string { + if normalizeContextRegion(region) == regionCN && strings.TrimSpace(template.RepoURLCN) != "" { + return template.RepoURLCN + } + return template.RepoURL +} + +// quickstartDocsURL returns the default quickstart documentation URL for +// the active login region, falling back to the global URL when no +// cn-specific docs page is configured. +func quickstartDocsURL(template quickstartTemplate, region string) string { + if normalizeContextRegion(region) == regionCN && strings.TrimSpace(template.DocsURLCN) != "" { + return template.DocsURLCN + } + return template.DocsURL +} + // quickstartRepoURL resolves the clone URL for a template, honoring an // env override if present. Returns the URL, the env var name that // supplied the override (empty when none was used), and an error if the @@ -436,7 +464,7 @@ func (a *App) quickstartRepoURL(template quickstartTemplate) (string, string, er } return override, key, nil } - return template.RepoURL, "", nil + return quickstartRepoURLForRegion(template, a.authRegion()), "", nil } func stripClonedGitMetadata(targetDir string) error { @@ -459,6 +487,9 @@ func cloneQuickstartRepo(repoURL, targetDir, ref string) error { // #nosec G204 -- git is invoked without a shell; repoURL and targetDir // follow "--" so git cannot parse them as flags. cmd := exec.Command("git", gitQuickstartCloneArgs(repoURL, targetDir, ref)...) + if isLocalGitRepoURL(repoURL) { + cmd.Env = append(os.Environ(), "GIT_ALLOW_PROTOCOL=file") + } output, err := cmd.CombinedOutput() if err != nil { trimmed := strings.TrimSpace(string(output)) @@ -471,6 +502,11 @@ func cloneQuickstartRepo(repoURL, targetDir, ref string) error { return nil } +func isLocalGitRepoURL(repoURL string) bool { + trimmed := strings.TrimSpace(repoURL) + return filepath.IsAbs(trimmed) || strings.HasPrefix(trimmed, "file://") +} + // validateGitRef rejects ref values that would either confuse git's // option parser or are obviously malformed. Empty refs are allowed and // mean "default branch." @@ -520,7 +556,10 @@ func validateRepoOverrideURL(s string) error { func gitQuickstartCloneArgs(repoURL, targetDir, ref string) []string { // Disable credential helpers for this invocation so non-TTY agent/CI runs // do not consult macOS keychain for public HTTPS repos. - args := []string{"-c", "credential.helper=", "clone", "--depth", "1"} + args := []string{"-c", "credential.helper=", "clone"} + if !isLocalGitRepoURL(repoURL) { + args = append(args, "--depth", "1") + } if strings.TrimSpace(ref) != "" { args = append(args, "--branch", strings.TrimSpace(ref)) } diff --git a/internal/cli/quickstart_test.go b/internal/cli/quickstart_test.go index 9be94f2..316b444 100644 --- a/internal/cli/quickstart_test.go +++ b/internal/cli/quickstart_test.go @@ -26,6 +26,13 @@ func TestGitQuickstartCloneArgs(t *testing.T) { if !reflect.DeepEqual(args, want) { t.Fatalf("unexpected clone args with dash-prefixed url:\n got: %#v\nwant: %#v", args, want) } + + localRepo := filepath.Join(t.TempDir(), "example-repo") + args = gitQuickstartCloneArgs(localRepo, "/tmp/example", "") + want = []string{"-c", "credential.helper=", "clone", "--", localRepo, "/tmp/example"} + if !reflect.DeepEqual(args, want) { + t.Fatalf("unexpected clone args with local path:\n got: %#v\nwant: %#v", args, want) + } } func TestStripClonedGitMetadata(t *testing.T) { @@ -151,21 +158,31 @@ func TestQuickstartRepoOverrideKey(t *testing.T) { } func TestQuickstartRepoURLOverride(t *testing.T) { - tmpl := quickstartTemplate{ID: "nextjs", RepoURL: "https://default.example/repo"} - app := &App{env: map[string]string{}} + tmpl := quickstartTemplate{ + ID: "nextjs", + RepoURL: "https://default.example/repo", + RepoURLCN: "https://cn.example/repo", + } + app := &App{env: map[string]string{"XDG_CONFIG_HOME": t.TempDir()}} url, override, err := app.quickstartRepoURL(tmpl) if err != nil || override != "" || url != tmpl.RepoURL { t.Fatalf("default path: url=%q override=%q err=%v", url, override, err) } - app.env = map[string]string{"AGORA_QUICKSTART_NEXTJS_REPO_URL": "https://fork.example/repo"} + app.env = map[string]string{ + "AGORA_QUICKSTART_NEXTJS_REPO_URL": "https://fork.example/repo", + "XDG_CONFIG_HOME": t.TempDir(), + } url, override, err = app.quickstartRepoURL(tmpl) if err != nil || override != "AGORA_QUICKSTART_NEXTJS_REPO_URL" || url != "https://fork.example/repo" { t.Fatalf("override path: url=%q override=%q err=%v", url, override, err) } - app.env = map[string]string{"AGORA_QUICKSTART_NEXTJS_REPO_URL": "-fexploit"} + app.env = map[string]string{ + "AGORA_QUICKSTART_NEXTJS_REPO_URL": "-fexploit", + "XDG_CONFIG_HOME": t.TempDir(), + } if _, _, err := app.quickstartRepoURL(tmpl); err == nil { t.Fatal("expected error for invalid override") } else { @@ -175,3 +192,29 @@ func TestQuickstartRepoURLOverride(t *testing.T) { } } } + +func TestQuickstartRepoURLForRegion(t *testing.T) { + tmpl := quickstartTemplate{ + RepoURL: "https://global.example/repo", + RepoURLCN: "https://cn.example/repo", + } + if got := quickstartRepoURLForRegion(tmpl, regionGlobal); got != tmpl.RepoURL { + t.Fatalf("global repo url = %q, want %q", got, tmpl.RepoURL) + } + if got := quickstartRepoURLForRegion(tmpl, regionCN); got != tmpl.RepoURLCN { + t.Fatalf("cn repo url = %q, want %q", got, tmpl.RepoURLCN) + } +} + +func TestQuickstartDocsURLForRegion(t *testing.T) { + tmpl := quickstartTemplate{ + DocsURL: "https://global.example/docs", + DocsURLCN: "https://cn.example/docs", + } + if got := quickstartDocsURL(tmpl, regionGlobal); got != tmpl.DocsURL { + t.Fatalf("global docs url = %q, want %q", got, tmpl.DocsURL) + } + if got := quickstartDocsURL(tmpl, regionCN); got != tmpl.DocsURLCN { + t.Fatalf("cn docs url = %q, want %q", got, tmpl.DocsURLCN) + } +} diff --git a/internal/cli/region.go b/internal/cli/region.go new file mode 100644 index 0000000..2be5504 --- /dev/null +++ b/internal/cli/region.go @@ -0,0 +1,59 @@ +package cli + +import ( + "fmt" + "strings" +) + +const ( + regionGlobal = "global" + regionCN = "cn" +) + +// normalizeLoginRegion validates the login flag value and resolves the +// effective control-plane region used for login. +func normalizeLoginRegion(region string) (string, error) { + switch strings.ToLower(strings.TrimSpace(region)) { + case "", regionGlobal: + return regionGlobal, nil + case regionCN: + return regionCN, nil + default: + return "", fmt.Errorf("--region must be one of: %s, %s", regionGlobal, regionCN) + } +} + +// normalizeContextRegion canonicalizes a persisted context region value and +// falls back to the global control plane for empty or unknown inputs. +func normalizeContextRegion(region string) string { + if strings.EqualFold(strings.TrimSpace(region), regionCN) { + return regionCN + } + return regionGlobal +} + +// currentRegionFromContext returns the canonical active region stored in the +// persisted project context. +func currentRegionFromContext(ctx projectContext) string { + return normalizeContextRegion(ctx.CurrentRegion) +} + +// authRegion returns the current control-plane region for authenticated CLI +// operations, defaulting to global when context loading fails. +func (a *App) authRegion() string { + ctx, err := loadContext(a.env) + if err != nil { + return regionGlobal + } + return currentRegionFromContext(ctx) +} + +// authRegionFromContext returns the current control-plane region from the +// persisted context and falls back to global when the context is unavailable. +func (a *App) authRegionFromContext() string { + ctx, err := loadContext(a.env) + if err != nil { + return regionGlobal + } + return currentRegionFromContext(ctx) +} diff --git a/internal/cli/region_test.go b/internal/cli/region_test.go new file mode 100644 index 0000000..a67450a --- /dev/null +++ b/internal/cli/region_test.go @@ -0,0 +1,77 @@ +package cli + +import "testing" + +func TestNormalizeLoginRegion(t *testing.T) { + cases := []struct { + name string + input string + want string + wantErr bool + }{ + {name: "empty defaults to global", input: "", want: regionGlobal}, + {name: "global stays global", input: "global", want: regionGlobal}, + {name: "cn stays cn", input: "cn", want: regionCN}, + {name: "trim and lowercase", input: " CN ", want: regionCN}, + {name: "invalid rejected", input: "test", wantErr: true}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + got, err := normalizeLoginRegion(tc.input) + if tc.wantErr { + if err == nil { + t.Fatalf("expected error for %q", tc.input) + } + return + } + if err != nil { + t.Fatalf("unexpected error for %q: %v", tc.input, err) + } + if got != tc.want { + t.Fatalf("normalizeLoginRegion(%q) = %q, want %q", tc.input, got, tc.want) + } + }) + } +} + +func TestCurrentRegionFromContext(t *testing.T) { + cases := []struct { + name string + input projectContext + want string + }{ + {name: "empty defaults to global", input: projectContext{}, want: regionGlobal}, + {name: "global stays global", input: projectContext{CurrentRegion: regionGlobal}, want: regionGlobal}, + {name: "cn stays cn", input: projectContext{CurrentRegion: regionCN}, want: regionCN}, + {name: "case insensitive cn", input: projectContext{CurrentRegion: "CN"}, want: regionCN}, + {name: "invalid falls back to global", input: projectContext{CurrentRegion: "test"}, want: regionGlobal}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + if got := currentRegionFromContext(tc.input); got != tc.want { + t.Fatalf("currentRegionFromContext(%+v) = %q, want %q", tc.input, got, tc.want) + } + }) + } +} + +func TestAuthRegionFromContextDefaultsToGlobal(t *testing.T) { + app := &App{env: map[string]string{"XDG_CONFIG_HOME": t.TempDir()}} + if got := app.authRegionFromContext(); got != regionGlobal { + t.Fatalf("authRegionFromContext() = %q, want %q", got, regionGlobal) + } +} + +func TestAuthRegionReadsPersistedContext(t *testing.T) { + env := map[string]string{"XDG_CONFIG_HOME": t.TempDir()} + if err := saveContext(env, projectContext{CurrentRegion: regionCN}); err != nil { + t.Fatal(err) + } + app := &App{env: env} + if got := app.authRegion(); got != regionCN { + t.Fatalf("authRegion() = %q, want %q", got, regionCN) + } + if got := app.authRegionFromContext(); got != regionCN { + t.Fatalf("authRegionFromContext() = %q, want %q", got, regionCN) + } +} diff --git a/internal/cli/render.go b/internal/cli/render.go index 76221d2..cb95ec0 100644 --- a/internal/cli/render.go +++ b/internal/cli/render.go @@ -36,13 +36,13 @@ func renderResult(cmd *cobra.Command, command string, data any) error { switch command { case "login": m := data.(map[string]any) - printBlock(out, "Login", [][2]string{{"Status", asString(m["status"])}, {"Scope", asString(m["scope"])}, {"Expires At", asString(m["expiresAt"])}}) + printBlock(out, "Login", [][2]string{{"Status", asString(m["status"])}, {"Region", asString(m["region"])}, {"Scope", asString(m["scope"])}, {"Expires At", asString(m["expiresAt"])}}) case "logout": m := data.(map[string]any) printBlock(out, "Logout", [][2]string{{"Status", asString(m["status"])}, {"Session Cleared", asString(m["clearedSession"])}}) case "auth status": m := data.(map[string]any) - printBlock(out, "Auth", [][2]string{{"Status", asString(m["status"])}, {"Authenticated", asString(m["authenticated"])}, {"Scope", asString(m["scope"])}, {"Expires At", asString(m["expiresAt"])}}) + printBlock(out, "Auth", [][2]string{{"Status", asString(m["status"])}, {"Authenticated", asString(m["authenticated"])}, {"Region", asString(m["region"])}, {"Scope", asString(m["scope"])}, {"Expires At", asString(m["expiresAt"])}}) case "project create": m := data.(map[string]any) features := "-" diff --git a/internal/cli/runtime_support.go b/internal/cli/runtime_support.go index 9c976c2..8aa8ff1 100644 --- a/internal/cli/runtime_support.go +++ b/internal/cli/runtime_support.go @@ -17,14 +17,11 @@ import ( const ( // currentAppConfigVersion is the schema version stamped on every // config write. Bumping it forces ensureAppConfigState to mark - // the load as "migrated" so the migration banner runs once. v3 - // renamed the persisted "verbose" key to "debug" (see - // mergeConfig); v2 was the API/OAuth base-URL flip from staging - // to production. - currentAppConfigVersion = 3 - previousAPIBaseURL = "https://agora-cli-bff.staging.la3.agoralab.co" - previousOAuthBaseURL = "https://staging-sso.agora.io" - previousOAuthClientID = "cli_demo" + // the load as "migrated" so the migration banner runs once. v4 + // stopped persisting endpoint/OAuth integration defaults; v3 renamed + // the persisted "verbose" key to "debug" (see mergeConfig); v2 was + // the API/OAuth base-URL flip from staging to production. + currentAppConfigVersion = 4 ) type configState struct { @@ -70,7 +67,7 @@ func ensureAppConfigState(env map[string]string) (configState, error) { } cfg := defaultConfig() - mergeConfig(&cfg, migratePreviousConfig(raw)) + mergeConfig(&cfg, raw) version, hasVersion := intValue(raw["version"]) if hasVersion && version > currentAppConfigVersion { return configState{}, fmt.Errorf("Config version %d is newer than this CLI supports.", version) @@ -98,26 +95,6 @@ func ensureAppConfigState(env map[string]string) (configState, error) { }, nil } -func migratePreviousConfig(raw map[string]any) map[string]any { - clone := map[string]any{} - for k, v := range raw { - clone[k] = v - } - version, _ := intValue(raw["version"]) - if version < 2 { - if v, ok := clone["apiBaseUrl"].(string); ok && v == previousAPIBaseURL { - clone["apiBaseUrl"] = defaultConfig().APIBaseURL - } - if v, ok := clone["oauthBaseUrl"].(string); ok && v == previousOAuthBaseURL { - clone["oauthBaseUrl"] = defaultConfig().OAuthBaseURL - } - if v, ok := clone["oauthClientId"].(string); ok && v == previousOAuthClientID { - clone["oauthClientId"] = defaultConfig().OAuthClientID - } - } - return clone -} - func intValue(v any) (int, bool) { switch x := v.(type) { case float64: