From dcec191357741cf8b21a772b5216f66e563a7b4e Mon Sep 17 00:00:00 2001 From: sunshinexcode <24xinhui@163.com> Date: Tue, 16 Jun 2026 21:28:43 +0800 Subject: [PATCH 01/18] fix(auth): resolve region-specific API and OAuth base URLs --- internal/cli/auth.go | 91 ++++++++++++++++++++++++++++++++++++--- internal/cli/auth_test.go | 74 +++++++++++++++++++++++++++++++ 2 files changed, 160 insertions(+), 5 deletions(-) diff --git a/internal/cli/auth.go b/internal/cli/auth.go index d8ec006..64638d0 100644 --- a/internal/cli/auth.go +++ b/internal/cli/auth.go @@ -21,8 +21,15 @@ import ( "time" ) +const ( + globalAPIBaseURL = "https://agora-cli.agora.io" + cnAPIBaseURL = "https://cli-cn.agora.io" + globalOAuthBaseURL = "https://sso2.agora.io" + cnOAuthBaseURL = "https://sso.shengwang.cn" +) + func (a *App) login(noBrowser bool, region string, progress progressEmitter) (map[string]any, error) { - config := a.oauthConfig() + config := a.oauthConfigForRegion(region) pair, err := generatePKCE() if err != nil { return nil, err @@ -126,8 +133,8 @@ 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", @@ -136,6 +143,76 @@ func (a *App) oauthConfig() oauthConfig { } } +// 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. a persisted non-default config override, +// 3. the built-in region default (cn vs global). +// +// As with apiBaseURLForRegion, the explicit-env check must look at the +// original process environment rather than a.env. applyConfigToEnv injects +// default values into a.env after startup, and treating those injected +// defaults as explicit overrides would prevent region-aware fallback from +// selecting the correct cn/global SSO host. +func (a *App) oauthBaseURLForRegion(region string) string { + if override := strings.TrimSpace(a.explicitEnvValue("AGORA_OAUTH_BASE_URL")); override != "" { + return override + } + if strings.TrimSpace(a.cfg.OAuthBaseURL) != "" && a.cfg.OAuthBaseURL != globalOAuthBaseURL { + return a.cfg.OAuthBaseURL + } + if region == "cn" { + return cnOAuthBaseURL + } + return globalOAuthBaseURL +} + +// 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. a persisted non-default config override, +// 3. the built-in region default (cn vs global). +// +// The explicit-env check intentionally uses explicitEnvValue rather than +// a.env because applyConfigToEnv injects defaults into a.env after startup. +// Reading only a.env would make those injected global defaults look like +// user-pinned overrides, which would break region-aware host switching. +func (a *App) apiBaseURLForRegion(region string) string { + if override := strings.TrimSpace(a.explicitEnvValue("AGORA_API_BASE_URL")); override != "" { + return override + } + if strings.TrimSpace(a.cfg.APIBaseURL) != "" && a.cfg.APIBaseURL != globalAPIBaseURL { + return a.cfg.APIBaseURL + } + if region == "cn" { + return cnAPIBaseURL + } + return globalAPIBaseURL +} + +// 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: reading from +// a.env alone would treat injected global defaults as if the user had pinned +// them intentionally, preventing the cn/global fallback logic from switching +// hosts. +func (a *App) explicitEnvValue(key string) string { + if a.osEnv != nil { + return a.osEnv[key] + } + if a.env != nil { + return a.env[key] + } + return "" +} + type pkcePair struct { CodeVerifier string CodeChallenge string @@ -308,7 +385,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"}, @@ -471,6 +548,10 @@ func readConfirmYesDefault(in io.Reader, out io.Writer, prompt string) (bool, er } func (a *App) loginPromptRegion() string { + return a.authRegionFromContext() +} + +func (a *App) authRegionFromContext() string { ctx, err := loadContext(a.env) if err != nil { return "" @@ -550,7 +631,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..e1f45f4 100644 --- a/internal/cli/auth_test.go +++ b/internal/cli/auth_test.go @@ -7,6 +7,80 @@ 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 != cnOAuthBaseURL+"/api/v0/oauth/authorize" { + t.Fatalf("unexpected authorize url: %s", cfg.AuthorizeURL) + } + if cfg.TokenURL != cnOAuthBaseURL+"/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 != globalOAuthBaseURL+"/api/v0/oauth/authorize" { + t.Fatalf("unexpected authorize url: %s", cfg.AuthorizeURL) + } + if cfg.TokenURL != globalOAuthBaseURL+"/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) + } + }) +} + +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 != cnAPIBaseURL { + 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 != globalAPIBaseURL { + 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 override wins over region default", func(t *testing.T) { + app.cfg.APIBaseURL = "https://staging-api.example.com" + if got := app.apiBaseURLForRegion("cn"); got != "https://staging-api.example.com" { + 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 From 409c80ef3956406510216a1393f4b5835d267cde Mon Sep 17 00:00:00 2001 From: sunshinexcode <24xinhui@163.com> Date: Tue, 16 Jun 2026 22:09:06 +0800 Subject: [PATCH 02/18] fix(auth): include region in login and status output --- docs/automation.md | 4 ++++ internal/cli/auth.go | 25 +++++++++++++++++++++++-- internal/cli/integration_auth_test.go | 5 ++++- internal/cli/render.go | 4 ++-- 4 files changed, 33 insertions(+), 5 deletions(-) diff --git a/docs/automation.md b/docs/automation.md index 5e23ac2..a022522 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` diff --git a/internal/cli/auth.go b/internal/cli/auth.go index 64638d0..0744bda 100644 --- a/internal/cli/auth.go +++ b/internal/cli/auth.go @@ -97,7 +97,7 @@ func (a *App) login(noBrowser bool, region string, progress progressEmitter) (ma if err := saveContext(a.env, ctx); err != nil { return nil, err } - return map[string]any{"action": "login", "expiresAt": token.ExpiresAt, "scope": token.Scope, "status": "authenticated"}, nil + return map[string]any{"action": "login", "expiresAt": token.ExpiresAt, "region": nextRegion, "scope": token.Scope, "status": "authenticated"}, nil } func (a *App) logout() (map[string]any, error) { @@ -123,7 +123,28 @@ 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 +} + +func (a *App) authRegion() string { + ctx, err := loadContext(a.env) + if err != nil { + return "global" + } + if strings.TrimSpace(ctx.CurrentRegion) != "" { + return ctx.CurrentRegion + } + if strings.TrimSpace(ctx.PreferredRegion) != "" { + return ctx.PreferredRegion + } + return "global" } type oauthConfig struct { 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/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 := "-" From 9cbdfc223c7c738a82e69424a581a71eb7dd457e Mon Sep 17 00:00:00 2001 From: sunshinexcode <24xinhui@163.com> Date: Wed, 17 Jun 2026 17:55:58 +0800 Subject: [PATCH 03/18] fix(auth): reset session region and guard repo project mismatch --- internal/cli/auth.go | 48 ++++++++++++++++++++++++++++------------ internal/cli/projects.go | 20 +++++++++++++++-- 2 files changed, 52 insertions(+), 16 deletions(-) diff --git a/internal/cli/auth.go b/internal/cli/auth.go index 0744bda..000ca4f 100644 --- a/internal/cli/auth.go +++ b/internal/cli/auth.go @@ -29,7 +29,17 @@ const ( ) func (a *App) login(noBrowser bool, region string, progress progressEmitter) (map[string]any, error) { - config := a.oauthConfigForRegion(region) + // Resolve the effective login region exactly once. An explicit --region + // flag (global or cn) wins; otherwise a flag-less login means global. We + // intentionally do NOT carry over a previously preferred region: the + // resolved value below drives both the OAuth host and the persisted + // context, so any divergence would leave the session pointed at one + // control plane while its token was issued by another. + loginRegion := "global" + if region == "cn" { + loginRegion = "cn" + } + config := a.oauthConfigForRegion(loginRegion) pair, err := generatePKCE() if err != nil { return nil, err @@ -81,23 +91,33 @@ 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, + PreferredRegion: 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, "region": nextRegion, "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) { diff --git a/internal/cli/projects.go b/internal/cli/projects.go index 56977b3..79c848d 100644 --- a/internal/cli/projects.go +++ b/internal/cli/projects.go @@ -140,13 +140,29 @@ func (a *App) resolveProjectTargetFrom(explicit, startPath string) (projectTarge 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 := strings.TrimSpace(ctx.CurrentRegion) + 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 + region := bindingRegion if region == "" { - region = ctx.CurrentRegion + region = sessionRegion } if region == "" { region = "global" From e166d24644115fa6f8ee4376e93645d7231d3715 Mon Sep 17 00:00:00 2001 From: sunshinexcode <24xinhui@163.com> Date: Thu, 18 Jun 2026 10:32:30 +0800 Subject: [PATCH 04/18] refactor(cli): simplify region handling for auth and project setup --- docs/commands.md | 7 ++----- internal/cli/app.go | 1 - internal/cli/auth.go | 8 ++------ internal/cli/commands.go | 13 ++++++------- internal/cli/init.go | 11 +++-------- internal/cli/integration_project_test.go | 5 ----- internal/cli/integration_quickstart_test.go | 2 -- internal/cli/mcp.go | 4 ---- internal/cli/paths.go | 9 +++------ internal/cli/projects.go | 14 ++++---------- internal/cli/quickstart.go | 13 ++++++++++++- internal/cli/quickstart_test.go | 6 ++++++ 12 files changed, 38 insertions(+), 55 deletions(-) diff --git a/docs/commands.md b/docs/commands.md index 43c4195..acdd1e5 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` | — | control plane region for login (global or cn; defaults to global) | ### `agora auth logout` @@ -115,7 +115,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 +131,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` | — | control plane region for login (global or cn; defaults to global) | ### `agora logout` @@ -177,7 +176,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 | @@ -379,4 +377,3 @@ Show the current auth status **`outputModes`**: `pretty`, `json` **`doctorStatus`**: `healthy`, `warning`, `not_ready`, `auth_error` - diff --git a/internal/cli/app.go b/internal/cli/app.go index a64830b..380bd1d 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 { diff --git a/internal/cli/auth.go b/internal/cli/auth.go index 000ca4f..665fed7 100644 --- a/internal/cli/auth.go +++ b/internal/cli/auth.go @@ -106,7 +106,6 @@ func (a *App) resetSessionRuntimeState(loginRegion string) error { CurrentProjectID: nil, CurrentProjectName: nil, CurrentRegion: loginRegion, - PreferredRegion: loginRegion, } if err := saveContext(a.env, rebuilt); err != nil { return err @@ -161,9 +160,6 @@ func (a *App) authRegion() string { if strings.TrimSpace(ctx.CurrentRegion) != "" { return ctx.CurrentRegion } - if strings.TrimSpace(ctx.PreferredRegion) != "" { - return ctx.PreferredRegion - } return "global" } @@ -597,8 +593,8 @@ func (a *App) authRegionFromContext() string { if err != nil { return "" } - if ctx.PreferredRegion == "global" || ctx.PreferredRegion == "cn" { - return ctx.PreferredRegion + if ctx.CurrentRegion == "global" || ctx.CurrentRegion == "cn" { + return ctx.CurrentRegion } return "" } diff --git a/internal/cli/commands.go b/internal/cli/commands.go index 191cd03..4ab60a1 100644 --- a/internal/cli/commands.go +++ b/internal/cli/commands.go @@ -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", "", "control plane region for login (global or cn; defaults to global)") return cmd } @@ -600,17 +600,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 +634,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 +643,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())) diff --git a/internal/cli/init.go b/internal/cli/init.go index 73fc7ee..46f2ab2 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)") @@ -339,7 +337,7 @@ func (a *App) resolveInitProject(ctx projectContext, item projectSummary) (proje return projectTarget{project: project, region: region}, 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 +412,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 +443,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/integration_project_test.go b/internal/cli/integration_project_test.go index 09cde05..85d49cc 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) } @@ -310,7 +308,6 @@ func TestCLIProjectEnvFormatsAndWriteRules(t *testing.T) { CurrentProjectID: &project.ProjectID, CurrentProjectName: &project.Name, CurrentRegion: "global", - PreferredRegion: "global", }); err != nil { t.Fatal(err) } @@ -445,7 +442,6 @@ func TestCLIProjectEnvWriteRecordsProjectTypeInBinding(t *testing.T) { CurrentProjectID: &project.ProjectID, CurrentProjectName: &project.Name, CurrentRegion: "global", - PreferredRegion: "global", }); err != nil { t.Fatal(err) } @@ -501,7 +497,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/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/paths.go b/internal/cli/paths.go index 26a2368..44e6af8 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: "global"} data, err := os.ReadFile(path) if errors.Is(err, os.ErrNotExist) { return ctx, nil @@ -122,9 +122,6 @@ func loadContext(env map[string]string) (projectContext, error) { if ctx.CurrentRegion == "" { ctx.CurrentRegion = "global" } - if ctx.PreferredRegion == "" { - ctx.PreferredRegion = "global" - } return ctx, nil } diff --git a/internal/cli/projects.go b/internal/cli/projects.go index 79c848d..ef478ff 100644 --- a/internal/cli/projects.go +++ b/internal/cli/projects.go @@ -301,16 +301,14 @@ 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 } + region := ctx.CurrentRegion if region == "" { - region = ctx.PreferredRegion - if region == "" { - region = "global" - } + region = "global" } features = projectCreateFeatures(template, features) rtmDataCenter, err = rtmDataCenterForFeatures(features, rtmDataCenter) @@ -336,7 +334,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 } @@ -423,7 +420,7 @@ func (a *App) projectUse(projectArg string) (map[string]any, error) { } region := current.CurrentRegion if region == "" { - region = current.PreferredRegion + region = "global" } if resolved.Region != nil && *resolved.Region != "" { region = *resolved.Region @@ -431,9 +428,6 @@ func (a *App) projectUse(projectArg string) (map[string]any, error) { 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..d2325fd 100644 --- a/internal/cli/quickstart.go +++ b/internal/cli/quickstart.go @@ -459,6 +459,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 +474,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 +528,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..8792400 100644 --- a/internal/cli/quickstart_test.go +++ b/internal/cli/quickstart_test.go @@ -26,6 +26,12 @@ 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) } + + args = gitQuickstartCloneArgs("/tmp/example-repo", "/tmp/example", "") + want = []string{"-c", "credential.helper=", "clone", "--", "/tmp/example-repo", "/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) { From e8a9c48c979e1b8f5a9ee07dd97c9553331dc34f Mon Sep 17 00:00:00 2001 From: sunshinexcode <24xinhui@163.com> Date: Thu, 18 Jun 2026 10:37:46 +0800 Subject: [PATCH 05/18] fix(auth): default login region flag to global --- docs/commands.md | 4 ++-- internal/cli/auth.go | 16 +++------------- internal/cli/commands.go | 2 +- 3 files changed, 6 insertions(+), 16 deletions(-) diff --git a/docs/commands.md b/docs/commands.md index acdd1e5..9d9b4f1 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 (global or cn; defaults to global) | +| `--region` | `string` | `global` | control plane region for login (global or cn) | ### `agora auth logout` @@ -131,7 +131,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 (global or cn; defaults to global) | +| `--region` | `string` | `global` | control plane region for login (global or cn) | ### `agora logout` diff --git a/internal/cli/auth.go b/internal/cli/auth.go index 665fed7..ba4e680 100644 --- a/internal/cli/auth.go +++ b/internal/cli/auth.go @@ -29,17 +29,7 @@ const ( ) func (a *App) login(noBrowser bool, region string, progress progressEmitter) (map[string]any, error) { - // Resolve the effective login region exactly once. An explicit --region - // flag (global or cn) wins; otherwise a flag-less login means global. We - // intentionally do NOT carry over a previously preferred region: the - // resolved value below drives both the OAuth host and the persisted - // context, so any divergence would leave the session pointed at one - // control plane while its token was issued by another. - loginRegion := "global" - if region == "cn" { - loginRegion = "cn" - } - config := a.oauthConfigForRegion(loginRegion) + config := a.oauthConfigForRegion(region) pair, err := generatePKCE() if err != nil { return nil, err @@ -91,10 +81,10 @@ func (a *App) login(noBrowser bool, region string, progress progressEmitter) (ma return nil, err } progress.emit("oauth:complete", "Session stored", nil) - if err := a.resetSessionRuntimeState(loginRegion); err != nil { + if err := a.resetSessionRuntimeState(region); err != nil { return nil, err } - return map[string]any{"action": "login", "expiresAt": token.ExpiresAt, "region": loginRegion, "scope": token.Scope, "status": "authenticated"}, nil + return map[string]any{"action": "login", "expiresAt": token.ExpiresAt, "region": region, "scope": token.Scope, "status": "authenticated"}, nil } func (a *App) resetSessionRuntimeState(loginRegion string) error { diff --git a/internal/cli/commands.go b/internal/cli/commands.go index 4ab60a1..a950f14 100644 --- a/internal/cli/commands.go +++ b/internal/cli/commands.go @@ -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 (global or cn; defaults to global)") + cmd.Flags().StringVar(®ion, "region", "global", "control plane region for login (global or cn)") return cmd } From db3e39b1822d99fe67f3ec7b907dafff2730dfec Mon Sep 17 00:00:00 2001 From: sunshinexcode <24xinhui@163.com> Date: Thu, 18 Jun 2026 10:51:32 +0800 Subject: [PATCH 06/18] fix(auth): validate and normalize login region on login --- internal/cli/auth.go | 22 +++++++++++++++++++--- internal/cli/auth_test.go | 32 ++++++++++++++++++++++++++++++++ 2 files changed, 51 insertions(+), 3 deletions(-) diff --git a/internal/cli/auth.go b/internal/cli/auth.go index ba4e680..06c1bec 100644 --- a/internal/cli/auth.go +++ b/internal/cli/auth.go @@ -29,7 +29,12 @@ const ( ) func (a *App) login(noBrowser bool, region string, progress progressEmitter) (map[string]any, error) { - config := a.oauthConfigForRegion(region) + loginRegion, err := normalizeLoginRegion(region) + if err != nil { + return nil, err + } + + config := a.oauthConfigForRegion(loginRegion) pair, err := generatePKCE() if err != nil { return nil, err @@ -81,10 +86,21 @@ func (a *App) login(noBrowser bool, region string, progress progressEmitter) (ma return nil, err } progress.emit("oauth:complete", "Session stored", nil) - if err := a.resetSessionRuntimeState(region); err != nil { + if err := a.resetSessionRuntimeState(loginRegion); err != nil { return nil, err } - return map[string]any{"action": "login", "expiresAt": token.ExpiresAt, "region": region, "scope": token.Scope, "status": "authenticated"}, nil + return map[string]any{"action": "login", "expiresAt": token.ExpiresAt, "region": loginRegion, "scope": token.Scope, "status": "authenticated"}, nil +} + +func normalizeLoginRegion(region string) (string, error) { + switch strings.ToLower(strings.TrimSpace(region)) { + case "", "global": + return "global", nil + case "cn": + return "cn", nil + default: + return "", fmt.Errorf("--region must be one of: global, cn") + } } func (a *App) resetSessionRuntimeState(loginRegion string) error { diff --git a/internal/cli/auth_test.go b/internal/cli/auth_test.go index e1f45f4..7af7b4c 100644 --- a/internal/cli/auth_test.go +++ b/internal/cli/auth_test.go @@ -81,6 +81,38 @@ func TestAPIBaseURLForRegion(t *testing.T) { }) } +func TestNormalizeLoginRegion(t *testing.T) { + cases := []struct { + name string + input string + want string + wantErr bool + }{ + {name: "empty defaults to global", input: "", want: "global"}, + {name: "global stays global", input: "global", want: "global"}, + {name: "cn stays cn", input: "cn", want: "cn"}, + {name: "trim and lowercase", input: " CN ", want: "cn"}, + {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 TestReadConfirmYesDefaultAcceptsEnterAndRepromptsInvalidInput(t *testing.T) { t.Run("enter defaults to yes", func(t *testing.T) { var out bytes.Buffer From e82d225c8717e6a3617e7e522642cd782fc16f23 Mon Sep 17 00:00:00 2001 From: sunshinexcode <24xinhui@163.com> Date: Thu, 18 Jun 2026 11:04:17 +0800 Subject: [PATCH 07/18] refactor(cli): centralize region normalization logic --- internal/cli/auth.go | 41 ++------------------ internal/cli/auth_test.go | 32 --------------- internal/cli/init.go | 5 +-- internal/cli/paths.go | 6 +-- internal/cli/projects.go | 22 +++-------- internal/cli/region.go | 59 ++++++++++++++++++++++++++++ internal/cli/region_test.go | 77 +++++++++++++++++++++++++++++++++++++ 7 files changed, 147 insertions(+), 95 deletions(-) create mode 100644 internal/cli/region.go create mode 100644 internal/cli/region_test.go diff --git a/internal/cli/auth.go b/internal/cli/auth.go index 06c1bec..04db666 100644 --- a/internal/cli/auth.go +++ b/internal/cli/auth.go @@ -92,17 +92,6 @@ func (a *App) login(noBrowser bool, region string, progress progressEmitter) (ma return map[string]any{"action": "login", "expiresAt": token.ExpiresAt, "region": loginRegion, "scope": token.Scope, "status": "authenticated"}, nil } -func normalizeLoginRegion(region string) (string, error) { - switch strings.ToLower(strings.TrimSpace(region)) { - case "", "global": - return "global", nil - case "cn": - return "cn", nil - default: - return "", fmt.Errorf("--region must be one of: global, cn") - } -} - 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 @@ -158,17 +147,6 @@ func (a *App) authStatus() (map[string]any, error) { }, nil } -func (a *App) authRegion() string { - ctx, err := loadContext(a.env) - if err != nil { - return "global" - } - if strings.TrimSpace(ctx.CurrentRegion) != "" { - return ctx.CurrentRegion - } - return "global" -} - type oauthConfig struct { AuthorizeURL string TokenURL string @@ -205,7 +183,7 @@ func (a *App) oauthBaseURLForRegion(region string) string { if strings.TrimSpace(a.cfg.OAuthBaseURL) != "" && a.cfg.OAuthBaseURL != globalOAuthBaseURL { return a.cfg.OAuthBaseURL } - if region == "cn" { + if region == regionCN { return cnOAuthBaseURL } return globalOAuthBaseURL @@ -229,7 +207,7 @@ func (a *App) apiBaseURLForRegion(region string) string { if strings.TrimSpace(a.cfg.APIBaseURL) != "" && a.cfg.APIBaseURL != globalAPIBaseURL { return a.cfg.APIBaseURL } - if region == "cn" { + if region == regionCN { return cnAPIBaseURL } return globalAPIBaseURL @@ -590,20 +568,7 @@ func readConfirmYesDefault(in io.Reader, out io.Writer, prompt string) (bool, er } } -func (a *App) loginPromptRegion() string { - return a.authRegionFromContext() -} - -func (a *App) authRegionFromContext() string { - ctx, err := loadContext(a.env) - if err != nil { - return "" - } - if ctx.CurrentRegion == "global" || ctx.CurrentRegion == "cn" { - return ctx.CurrentRegion - } - return "" -} +func (a *App) loginPromptRegion() string { return a.authRegionFromContext() } func (a *App) promptForLogin() error { if !a.shouldPromptForLogin() { diff --git a/internal/cli/auth_test.go b/internal/cli/auth_test.go index 7af7b4c..e1f45f4 100644 --- a/internal/cli/auth_test.go +++ b/internal/cli/auth_test.go @@ -81,38 +81,6 @@ func TestAPIBaseURLForRegion(t *testing.T) { }) } -func TestNormalizeLoginRegion(t *testing.T) { - cases := []struct { - name string - input string - want string - wantErr bool - }{ - {name: "empty defaults to global", input: "", want: "global"}, - {name: "global stays global", input: "global", want: "global"}, - {name: "cn stays cn", input: "cn", want: "cn"}, - {name: "trim and lowercase", input: " CN ", want: "cn"}, - {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 TestReadConfirmYesDefaultAcceptsEnterAndRepromptsInvalidInput(t *testing.T) { t.Run("enter defaults to yes", func(t *testing.T) { var out bytes.Buffer diff --git a/internal/cli/init.go b/internal/cli/init.go index 46f2ab2..9f0f369 100644 --- a/internal/cli/init.go +++ b/internal/cli/init.go @@ -324,10 +324,7 @@ func (a *App) resolveInitProject(ctx projectContext, item projectSummary) (proje if err != nil { return projectTarget{}, err } - region := ctx.CurrentRegion - if region == "" { - region = "global" - } + region := currentRegionFromContext(ctx) if item.Region != nil && *item.Region != "" { region = *item.Region } diff --git a/internal/cli/paths.go b/internal/cli/paths.go index 44e6af8..3a67586 100644 --- a/internal/cli/paths.go +++ b/internal/cli/paths.go @@ -108,7 +108,7 @@ func loadContext(env map[string]string) (projectContext, error) { if err != nil { return projectContext{}, err } - ctx := projectContext{CurrentRegion: "global"} + ctx := projectContext{CurrentRegion: regionGlobal} data, err := os.ReadFile(path) if errors.Is(err, os.ErrNotExist) { return ctx, nil @@ -119,9 +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" - } + ctx.CurrentRegion = currentRegionFromContext(ctx) return ctx, nil } diff --git a/internal/cli/projects.go b/internal/cli/projects.go index ef478ff..e9771e0 100644 --- a/internal/cli/projects.go +++ b/internal/cli/projects.go @@ -126,10 +126,7 @@ func (a *App) resolveProjectTargetFrom(explicit, startPath string) (projectTarge if err != nil { return projectTarget{}, err } - region := ctx.CurrentRegion - if region == "" { - region = "global" - } + region := currentRegionFromContext(ctx) if project.Region != nil && *project.Region != "" { region = *project.Region } else if resolved.Region != nil && *resolved.Region != "" { @@ -149,7 +146,7 @@ func (a *App) resolveProjectTargetFrom(explicit, startPath string) (projectTarge // guidance instead. An empty session region (fresh login default) // is treated as "no opinion" and does not conflict. bindingRegion := strings.TrimSpace(binding.Region) - sessionRegion := strings.TrimSpace(ctx.CurrentRegion) + 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), @@ -164,9 +161,6 @@ func (a *App) resolveProjectTargetFrom(explicit, startPath string) (projectTarge if region == "" { region = sessionRegion } - if region == "" { - region = "global" - } if project.Region != nil && *project.Region != "" { region = *project.Region } @@ -179,7 +173,7 @@ func (a *App) resolveProjectTargetFrom(explicit, startPath string) (projectTarge if err != nil { return projectTarget{}, err } - region := ctx.CurrentRegion + region := currentRegionFromContext(ctx) if project.Region != nil && *project.Region != "" { region = *project.Region } @@ -306,10 +300,7 @@ func (a *App) projectCreate(name, template string, features []string, rtmDataCen if err != nil { return nil, err } - region := ctx.CurrentRegion - if region == "" { - region = "global" - } + region := currentRegionFromContext(ctx) features = projectCreateFeatures(template, features) rtmDataCenter, err = rtmDataCenterForFeatures(features, rtmDataCenter) if err != nil { @@ -418,10 +409,7 @@ 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 = "global" - } + region := currentRegionFromContext(current) if resolved.Region != nil && *resolved.Region != "" { region = *resolved.Region } 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) + } +} From 87c7e084f344fa7339490e3a0ec97773cb77ba2c Mon Sep 17 00:00:00 2001 From: sunshinexcode <24xinhui@163.com> Date: Thu, 18 Jun 2026 11:13:38 +0800 Subject: [PATCH 08/18] fix(projects): stop exposing region in project responses --- docs/automation.md | 2 +- internal/cli/app.go | 2 -- internal/cli/init.go | 9 +-------- internal/cli/integration_test.go | 3 +-- internal/cli/projects.go | 21 ++------------------- 5 files changed, 5 insertions(+), 32 deletions(-) diff --git a/docs/automation.md b/docs/automation.md index a022522..bddd0bd 100644 --- a/docs/automation.md +++ b/docs/automation.md @@ -855,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` diff --git a/internal/cli/app.go b/internal/cli/app.go index 380bd1d..ea5ba64 100644 --- a/internal/cli/app.go +++ b/internal/cli/app.go @@ -61,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"` @@ -77,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/init.go b/internal/cli/init.go index 9f0f369..de1b875 100644 --- a/internal/cli/init.go +++ b/internal/cli/init.go @@ -324,14 +324,7 @@ func (a *App) resolveInitProject(ctx projectContext, item projectSummary) (proje if err != nil { return projectTarget{}, err } - region := currentRegionFromContext(ctx) - 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 string, features []string, rtmDataCenter string, newProject bool, promptForReuse bool, promptOut io.Writer, promptIn io.Reader, progress progressEmitter) (map[string]any, error) { 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/projects.go b/internal/cli/projects.go index e9771e0..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,13 +125,7 @@ func (a *App) resolveProjectTargetFrom(explicit, startPath string) (projectTarge if err != nil { return projectTarget{}, err } - region := currentRegionFromContext(ctx) - 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 @@ -161,9 +154,6 @@ func (a *App) resolveProjectTargetFrom(explicit, startPath string) (projectTarge if region == "" { region = sessionRegion } - if project.Region != nil && *project.Region != "" { - region = *project.Region - } return projectTarget{project: project, region: region}, nil } if ctx.CurrentProjectID == nil || *ctx.CurrentProjectID == "" { @@ -173,11 +163,7 @@ func (a *App) resolveProjectTargetFrom(explicit, startPath string) (projectTarge if err != nil { return projectTarget{}, err } - region := currentRegionFromContext(ctx) - 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) { @@ -410,9 +396,6 @@ func (a *App) projectUse(projectArg string) (map[string]any, error) { 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 := currentRegionFromContext(current) - if resolved.Region != nil && *resolved.Region != "" { - region = *resolved.Region - } current.CurrentProjectID = &resolved.ProjectID current.CurrentProjectName = &resolved.Name current.CurrentRegion = region From b7034b18ebebcd1c45f917f10f59f5219165d8d0 Mon Sep 17 00:00:00 2001 From: sunshinexcode <24xinhui@163.com> Date: Thu, 18 Jun 2026 11:34:30 +0800 Subject: [PATCH 09/18] fix(open): use auth region for console and product docs --- internal/cli/commands.go | 2 +- internal/cli/open_targets.go | 50 +++++++++++++++++++++---------- internal/cli/open_targets_test.go | 26 +++++++++++++--- 3 files changed, 57 insertions(+), 21 deletions(-) diff --git a/internal/cli/commands.go b/internal/cli/commands.go index a950f14..31b63bb 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 } 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) } From b77c2c3898c4e1e4e819466a844cb25e231ce91e Mon Sep 17 00:00:00 2001 From: sunshinexcode <24xinhui@163.com> Date: Thu, 18 Jun 2026 11:53:00 +0800 Subject: [PATCH 10/18] fix(quickstart): use region-specific repo and docs URLs --- internal/cli/quickstart.go | 36 +++++++++++++++++++++++++++++---- internal/cli/quickstart_test.go | 32 ++++++++++++++++++++++++++++- 2 files changed, 63 insertions(+), 5 deletions(-) diff --git a/internal/cli/quickstart.go b/internal/cli/quickstart.go index d2325fd..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 { diff --git a/internal/cli/quickstart_test.go b/internal/cli/quickstart_test.go index 8792400..7db5a55 100644 --- a/internal/cli/quickstart_test.go +++ b/internal/cli/quickstart_test.go @@ -157,7 +157,11 @@ func TestQuickstartRepoOverrideKey(t *testing.T) { } func TestQuickstartRepoURLOverride(t *testing.T) { - tmpl := quickstartTemplate{ID: "nextjs", RepoURL: "https://default.example/repo"} + tmpl := quickstartTemplate{ + ID: "nextjs", + RepoURL: "https://default.example/repo", + RepoURLCN: "https://cn.example/repo", + } app := &App{env: map[string]string{}} url, override, err := app.quickstartRepoURL(tmpl) @@ -181,3 +185,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) + } +} From d5f8b527b94d9bd74a3926542d9daa1641a75825 Mon Sep 17 00:00:00 2001 From: sunshinexcode <24xinhui@163.com> Date: Thu, 18 Jun 2026 12:18:17 +0800 Subject: [PATCH 11/18] fix(doctor): use region-specific endpoints for network check --- internal/cli/install_doctor.go | 22 +++++++++++----- internal/cli/install_doctor_test.go | 40 +++++++++++++++++++++++++++++ 2 files changed, 55 insertions(+), 7 deletions(-) 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..0295504 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 != globalAPIBaseURL { + t.Fatalf("expected global api endpoint, got %q", endpoints[0].url) + } + if endpoints[1].url != globalOAuthBaseURL { + 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 != cnAPIBaseURL { + t.Fatalf("expected cn api endpoint, got %q", endpoints[0].url) + } + if endpoints[1].url != cnOAuthBaseURL { + t.Fatalf("expected cn oauth endpoint, got %q", endpoints[1].url) + } + }) +} From 20fa03b568bb444e6bd488bbd4372d3630674c73 Mon Sep 17 00:00:00 2001 From: sunshinexcode <24xinhui@163.com> Date: Thu, 18 Jun 2026 14:16:19 +0800 Subject: [PATCH 12/18] refactor(config): stop persisting API/OAuth settings in config --- CHANGELOG.md | 4 +++ config.example.json | 6 +--- docs/automation.md | 13 +++++++-- docs/commands.md | 4 --- internal/cli/app_test.go | 37 ++++++++++++++++++------ internal/cli/auth.go | 51 ++++++++++++++------------------- internal/cli/auth_test.go | 13 +++++++-- internal/cli/commands.go | 17 ----------- internal/cli/config.go | 26 +---------------- internal/cli/docgen.go | 2 +- internal/cli/runtime_support.go | 35 ++++------------------ 11 files changed, 84 insertions(+), 124 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 65194ac..96bdecb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,10 @@ Earlier entries pre-date this convention and only carry their version's compare ## [Unreleased] +### Changed + +- **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. + ## [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 bddd0bd..09b328c 100644 --- a/docs/automation.md +++ b/docs/automation.md @@ -958,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 9d9b4f1..7575fd3 100644 --- a/docs/commands.md +++ b/docs/commands.md @@ -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 | 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 04db666..11c5849 100644 --- a/internal/cli/auth.go +++ b/internal/cli/auth.go @@ -22,10 +22,12 @@ import ( ) const ( - globalAPIBaseURL = "https://agora-cli.agora.io" - cnAPIBaseURL = "https://cli-cn.agora.io" - globalOAuthBaseURL = "https://sso2.agora.io" - cnOAuthBaseURL = "https://sso.shengwang.cn" + globalAPIBaseURL = "https://agora-cli.agora.io" + cnAPIBaseURL = "https://cli-cn.agora.io" + globalOAuthBaseURL = "https://sso2.agora.io" + cnOAuthBaseURL = "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) { @@ -159,8 +161,8 @@ func (a *App) oauthConfigForRegion(region string) oauthConfig { 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), } } @@ -168,21 +170,14 @@ func (a *App) oauthConfigForRegion(region string) oauthConfig { // region. Resolution order is: // // 1. an explicit process-env override (AGORA_OAUTH_BASE_URL), -// 2. a persisted non-default config override, -// 3. the built-in region default (cn vs global). +// 2. the built-in region default (cn vs global). // -// As with apiBaseURLForRegion, the explicit-env check must look at the -// original process environment rather than a.env. applyConfigToEnv injects -// default values into a.env after startup, and treating those injected -// defaults as explicit overrides would prevent region-aware fallback from -// selecting the correct cn/global SSO host. +// 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 strings.TrimSpace(a.cfg.OAuthBaseURL) != "" && a.cfg.OAuthBaseURL != globalOAuthBaseURL { - return a.cfg.OAuthBaseURL - } if region == regionCN { return cnOAuthBaseURL } @@ -193,20 +188,14 @@ func (a *App) oauthBaseURLForRegion(region string) string { // requested region. Resolution order is: // // 1. an explicit process-env override (AGORA_API_BASE_URL), -// 2. a persisted non-default config override, -// 3. the built-in region default (cn vs global). +// 2. the built-in region default (cn vs global). // -// The explicit-env check intentionally uses explicitEnvValue rather than -// a.env because applyConfigToEnv injects defaults into a.env after startup. -// Reading only a.env would make those injected global defaults look like -// user-pinned overrides, which would break region-aware host switching. +// 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 strings.TrimSpace(a.cfg.APIBaseURL) != "" && a.cfg.APIBaseURL != globalAPIBaseURL { - return a.cfg.APIBaseURL - } if region == regionCN { return cnAPIBaseURL } @@ -220,10 +209,7 @@ func (a *App) apiBaseURLForRegion(region string) string { // 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: reading from -// a.env alone would treat injected global defaults as if the user had pinned -// them intentionally, preventing the cn/global fallback logic from switching -// hosts. +// That distinction matters for region-aware endpoint selection. func (a *App) explicitEnvValue(key string) string { if a.osEnv != nil { return a.osEnv[key] @@ -234,6 +220,13 @@ func (a *App) explicitEnvValue(key string) string { return "" } +func valueOrDefault(value, fallback string) string { + if trimmed := strings.TrimSpace(value); trimmed != "" { + return trimmed + } + return fallback +} + type pkcePair struct { CodeVerifier string CodeChallenge string diff --git a/internal/cli/auth_test.go b/internal/cli/auth_test.go index e1f45f4..af66743 100644 --- a/internal/cli/auth_test.go +++ b/internal/cli/auth_test.go @@ -46,6 +46,14 @@ func TestOAuthConfigForRegion(t *testing.T) { 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) { @@ -73,9 +81,8 @@ func TestAPIBaseURLForRegion(t *testing.T) { app.osEnv = nil }) - t.Run("config override wins over region default", func(t *testing.T) { - app.cfg.APIBaseURL = "https://staging-api.example.com" - if got := app.apiBaseURLForRegion("cn"); got != "https://staging-api.example.com" { + t.Run("config does not override region default", func(t *testing.T) { + if got := app.apiBaseURLForRegion("cn"); got != cnAPIBaseURL { t.Fatalf("unexpected api base url: %s", got) } }) diff --git a/internal/cli/commands.go b/internal/cli/commands.go index 31b63bb..20c086e 100644 --- a/internal/cli/commands.go +++ b/internal/cli/commands.go @@ -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") 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/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: From 9cf696e84ae2ea625268e24a08d98ff26db946db Mon Sep 17 00:00:00 2001 From: sunshinexcode <24xinhui@163.com> Date: Thu, 18 Jun 2026 14:20:06 +0800 Subject: [PATCH 13/18] refactor(cli): normalize auth endpoint constant names --- internal/cli/auth.go | 16 ++++++++-------- internal/cli/auth_test.go | 14 +++++++------- internal/cli/install_doctor_test.go | 8 ++++---- 3 files changed, 19 insertions(+), 19 deletions(-) diff --git a/internal/cli/auth.go b/internal/cli/auth.go index 11c5849..46bae71 100644 --- a/internal/cli/auth.go +++ b/internal/cli/auth.go @@ -22,10 +22,10 @@ import ( ) const ( - globalAPIBaseURL = "https://agora-cli.agora.io" - cnAPIBaseURL = "https://cli-cn.agora.io" - globalOAuthBaseURL = "https://sso2.agora.io" - cnOAuthBaseURL = "https://sso.shengwang.cn" + 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" ) @@ -179,9 +179,9 @@ func (a *App) oauthBaseURLForRegion(region string) string { return override } if region == regionCN { - return cnOAuthBaseURL + return oauthBaseURLCN } - return globalOAuthBaseURL + return oauthBaseURL } // apiBaseURLForRegion resolves the control-plane API base URL for the @@ -197,9 +197,9 @@ func (a *App) apiBaseURLForRegion(region string) string { return override } if region == regionCN { - return cnAPIBaseURL + return apiBaseURLCN } - return globalAPIBaseURL + return apiBaseURL } // explicitEnvValue returns the value the user explicitly supplied in the diff --git a/internal/cli/auth_test.go b/internal/cli/auth_test.go index af66743..9101e29 100644 --- a/internal/cli/auth_test.go +++ b/internal/cli/auth_test.go @@ -18,20 +18,20 @@ func TestOAuthConfigForRegion(t *testing.T) { t.Run("cn region uses shengwang sso by default", func(t *testing.T) { cfg := app.oauthConfigForRegion("cn") - if cfg.AuthorizeURL != cnOAuthBaseURL+"/api/v0/oauth/authorize" { + if cfg.AuthorizeURL != oauthBaseURLCN+"/api/v0/oauth/authorize" { t.Fatalf("unexpected authorize url: %s", cfg.AuthorizeURL) } - if cfg.TokenURL != cnOAuthBaseURL+"/api/v0/oauth/token" { + 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 != globalOAuthBaseURL+"/api/v0/oauth/authorize" { + if cfg.AuthorizeURL != oauthBaseURL+"/api/v0/oauth/authorize" { t.Fatalf("unexpected authorize url: %s", cfg.AuthorizeURL) } - if cfg.TokenURL != globalOAuthBaseURL+"/api/v0/oauth/token" { + if cfg.TokenURL != oauthBaseURL+"/api/v0/oauth/token" { t.Fatalf("unexpected token url: %s", cfg.TokenURL) } }) @@ -62,13 +62,13 @@ func TestAPIBaseURLForRegion(t *testing.T) { } t.Run("cn region uses cn cli api by default", func(t *testing.T) { - if got := app.apiBaseURLForRegion("cn"); got != cnAPIBaseURL { + 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 != globalAPIBaseURL { + if got := app.apiBaseURLForRegion("global"); got != apiBaseURL { t.Fatalf("unexpected api base url: %s", got) } }) @@ -82,7 +82,7 @@ func TestAPIBaseURLForRegion(t *testing.T) { }) t.Run("config does not override region default", func(t *testing.T) { - if got := app.apiBaseURLForRegion("cn"); got != cnAPIBaseURL { + if got := app.apiBaseURLForRegion("cn"); got != apiBaseURLCN { t.Fatalf("unexpected api base url: %s", got) } }) diff --git a/internal/cli/install_doctor_test.go b/internal/cli/install_doctor_test.go index 0295504..16cd1e7 100644 --- a/internal/cli/install_doctor_test.go +++ b/internal/cli/install_doctor_test.go @@ -104,10 +104,10 @@ func TestInstallDoctorNetworkEndpointsFollowCurrentRegion(t *testing.T) { if len(endpoints) != 2 { t.Fatalf("expected 2 endpoints, got %+v", endpoints) } - if endpoints[0].url != globalAPIBaseURL { + if endpoints[0].url != apiBaseURL { t.Fatalf("expected global api endpoint, got %q", endpoints[0].url) } - if endpoints[1].url != globalOAuthBaseURL { + if endpoints[1].url != oauthBaseURL { t.Fatalf("expected global oauth endpoint, got %q", endpoints[1].url) } }) @@ -125,10 +125,10 @@ func TestInstallDoctorNetworkEndpointsFollowCurrentRegion(t *testing.T) { if len(endpoints) != 2 { t.Fatalf("expected 2 endpoints, got %+v", endpoints) } - if endpoints[0].url != cnAPIBaseURL { + if endpoints[0].url != apiBaseURLCN { t.Fatalf("expected cn api endpoint, got %q", endpoints[0].url) } - if endpoints[1].url != cnOAuthBaseURL { + if endpoints[1].url != oauthBaseURLCN { t.Fatalf("expected cn oauth endpoint, got %q", endpoints[1].url) } }) From f62ce19daebfb29ec72d02b85a7b1ab2cdfa025b Mon Sep 17 00:00:00 2001 From: sunshinexcode <24xinhui@163.com> Date: Thu, 18 Jun 2026 14:51:02 +0800 Subject: [PATCH 14/18] fix(cli): allow project env write to target explicit project --- internal/cli/commands.go | 1 + internal/cli/integration_project_test.go | 23 +++++++++++++++++++++++ 2 files changed, 24 insertions(+) diff --git a/internal/cli/commands.go b/internal/cli/commands.go index 20c086e..9185553 100644 --- a/internal/cli/commands.go +++ b/internal/cli/commands.go @@ -868,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/integration_project_test.go b/internal/cli/integration_project_test.go index 85d49cc..40f31b0 100644 --- a/internal/cli/integration_project_test.go +++ b/internal/cli/integration_project_test.go @@ -303,6 +303,8 @@ 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, @@ -333,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) } From c6cae5a23a9810662219b7edf1362d5ede85ea3f Mon Sep 17 00:00:00 2001 From: sunshinexcode <24xinhui@163.com> Date: Thu, 18 Jun 2026 15:44:49 +0800 Subject: [PATCH 15/18] test(quickstart): isolate repo override tests and document --project --- docs/commands.md | 1 + internal/cli/quickstart_test.go | 12 +++++++++--- 2 files changed, 10 insertions(+), 3 deletions(-) diff --git a/docs/commands.md b/docs/commands.md index 7575fd3..c92b426 100644 --- a/docs/commands.md +++ b/docs/commands.md @@ -203,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` diff --git a/internal/cli/quickstart_test.go b/internal/cli/quickstart_test.go index 7db5a55..47bcfe9 100644 --- a/internal/cli/quickstart_test.go +++ b/internal/cli/quickstart_test.go @@ -162,20 +162,26 @@ func TestQuickstartRepoURLOverride(t *testing.T) { RepoURL: "https://default.example/repo", RepoURLCN: "https://cn.example/repo", } - app := &App{env: map[string]string{}} + 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 { From d06ffb8b96f056324298b54ef4c18b5112dcaaf7 Mon Sep 17 00:00:00 2001 From: sunshinexcode <24xinhui@163.com> Date: Thu, 18 Jun 2026 16:03:41 +0800 Subject: [PATCH 16/18] docs(error-codes): add PROJECT_REGION_MISMATCH entry --- docs/error-codes.md | 1 + 1 file changed, 1 insertion(+) 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. | From f15d696a0a8b6db21bc6bc4c11de424726159719 Mon Sep 17 00:00:00 2001 From: sunshinexcode <24xinhui@163.com> Date: Thu, 18 Jun 2026 16:16:24 +0800 Subject: [PATCH 17/18] docs(changelog): document region-aware profile changes --- CHANGELOG.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 96bdecb..69a7842 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,7 +17,11 @@ Earlier entries pre-date this convention and only carry their version's compare ### 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 From 5a5d297fafedee7bbd9ae18a9feb95762eb80f58 Mon Sep 17 00:00:00 2001 From: sunshinexcode <24xinhui@163.com> Date: Thu, 18 Jun 2026 16:20:56 +0800 Subject: [PATCH 18/18] test(quickstart): use temp dir path for local clone args --- internal/cli/quickstart_test.go | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/internal/cli/quickstart_test.go b/internal/cli/quickstart_test.go index 47bcfe9..316b444 100644 --- a/internal/cli/quickstart_test.go +++ b/internal/cli/quickstart_test.go @@ -27,8 +27,9 @@ func TestGitQuickstartCloneArgs(t *testing.T) { t.Fatalf("unexpected clone args with dash-prefixed url:\n got: %#v\nwant: %#v", args, want) } - args = gitQuickstartCloneArgs("/tmp/example-repo", "/tmp/example", "") - want = []string{"-c", "credential.helper=", "clone", "--", "/tmp/example-repo", "/tmp/example"} + 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) }