From 37459b60ec21cbe74a5f5da87f919f664145ce07 Mon Sep 17 00:00:00 2001 From: JackZhao10086 Date: Thu, 14 May 2026 14:12:29 +0800 Subject: [PATCH] =?UTF-8?q?feat(auth):=20support=20--exclude=20flag=20and?= =?UTF-8?q?=20combine=20--scope=20with=20--domain/=E2=80=A6=20(#844)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(auth/login): 增加exclude参数使用校验逻辑 当使用--exclude参数时,必须同时指定--scope、--domain或--recommend中的至少一个,避免非法参数调用 * feat(auth/login): add --exclude flag and support combining scope options 1. 新增--exclude命令行标志用于排除指定的授权范围 2. 移除--scope与--domain/--recommend的互斥限制,改为叠加使用 3. 重构范围合并与排除逻辑,增加校验和辅助工具函数 4. 更新--scope参数的帮助文档说明叠加行为 * fix(auth/login): 修复登录命令scope参数描述重复的问题 移除了重复的参数说明文本,整理冗余的注释内容,让帮助文档更清晰易读 * fix(auth/login): 修复exclude参数校验逻辑 添加--exclude参数必须配合其他可选参数使用的校验,避免无效的exclude参数调用 --------- Co-authored-by: cqc-a11y --- cmd/auth/login.go | 101 ++++++++++++++++++++++++++++++++++++++++++---- 1 file changed, 93 insertions(+), 8 deletions(-) diff --git a/cmd/auth/login.go b/cmd/auth/login.go index 916fda9e..97e2b819 100644 --- a/cmd/auth/login.go +++ b/cmd/auth/login.go @@ -30,6 +30,7 @@ type LoginOptions struct { Scope string Recommend bool Domains []string + Exclude []string NoWait bool DeviceCode string } @@ -62,11 +63,13 @@ browser. Run it in the background and retrieve the verification URL from its out } cmdutil.SetSupportedIdentities(cmd, []string{"user"}) - cmd.Flags().StringVar(&opts.Scope, "scope", "", "scopes to request (space- or comma-separated)") + cmd.Flags().StringVar(&opts.Scope, "scope", "", "scopes to request (space- or comma-separated). Combines additively with --domain/--recommend") cmd.Flags().BoolVar(&opts.Recommend, "recommend", false, "request only recommended (auto-approve) scopes") available := sortedKnownDomains() cmd.Flags().StringSliceVar(&opts.Domains, "domain", nil, fmt.Sprintf("domain (repeatable or comma-separated, e.g. --domain calendar,task)\navailable: %s, all", strings.Join(available, ", "))) + cmd.Flags().StringSliceVar(&opts.Exclude, "exclude", nil, + "scopes to exclude from the request (repeatable or comma-separated, e.g. --exclude drive:file:download)") cmd.Flags().BoolVar(&opts.JSON, "json", false, "structured JSON output") cmd.Flags().BoolVar(&opts.NoWait, "no-wait", false, "initiate device authorization and return immediately; use --device-code to complete") cmd.Flags().StringVar(&opts.DeviceCode, "device-code", "", "poll and complete authorization with a device code from a previous --no-wait call") @@ -158,6 +161,10 @@ func authLoginRun(opts *LoginOptions) error { hasAnyOption := opts.Scope != "" || opts.Recommend || len(selectedDomains) > 0 + if len(opts.Exclude) > 0 && !hasAnyOption { + return output.ErrValidation("--exclude requires --scope, --domain, or --recommend to be specified") + } + if !hasAnyOption { if !opts.JSON && f.IOStreams.IsTerminal { result, err := runInteractiveLogin(f.IOStreams, lang, msg) @@ -191,12 +198,11 @@ func authLoginRun(opts *LoginOptions) error { // endpoint rejects raw "a,b" strings as a single malformed scope. finalScope := normalizeScopeInput(opts.Scope) - // Resolve scopes from domain/permission filters + // Resolve scopes from domain/permission filters and merge with --scope. + // --scope, --domain, and --recommend combine additively so callers can, + // for example, request all `docs` scopes plus a few specific `drive` + // scopes in a single command. if len(selectedDomains) > 0 || opts.Recommend { - if opts.Scope != "" { - return output.ErrValidation("cannot use --scope together with --domain/--recommend") - } - var candidateScopes []string if len(selectedDomains) > 0 { candidateScopes = collectScopesForDomains(selectedDomains, "user") @@ -210,11 +216,35 @@ func authLoginRun(opts *LoginOptions) error { candidateScopes = registry.FilterAutoApproveScopes(candidateScopes) } - if len(candidateScopes) == 0 { + if len(candidateScopes) == 0 && opts.Scope == "" { return output.ErrValidation("no matching scopes found, check domain/scope options") } - finalScope = strings.Join(candidateScopes, " ") + // Merge --scope additively with the resolved domain scopes. + merged := make(map[string]bool, len(candidateScopes)+len(strings.Fields(finalScope))) + for _, s := range candidateScopes { + merged[s] = true + } + for _, s := range strings.Fields(finalScope) { + merged[s] = true + } + finalScope = joinSortedScopeSet(merged) + } + + // Apply --exclude on top of the resolved scope set. We honour exclude + // regardless of whether scopes came from --scope, --domain, --recommend, + // or any combination thereof. + if len(opts.Exclude) > 0 { + excluded, unknown := applyExcludeScopes(finalScope, opts.Exclude) + if len(unknown) > 0 { + return output.ErrValidation( + "these --exclude scopes are not present in the requested set: %s", + strings.Join(unknown, ", ")) + } + finalScope = excluded + if strings.TrimSpace(finalScope) == "" { + return output.ErrValidation("no scopes left after applying --exclude; nothing to authorize") + } } // Step 1: Request device authorization @@ -580,3 +610,58 @@ func suggestDomain(input string, known map[string]bool) string { } return "" } + +// joinSortedScopeSet returns a deterministic, space-separated scope string +// from a set, sorted alphabetically. Empty/blank scopes are dropped. +func joinSortedScopeSet(set map[string]bool) string { + out := make([]string, 0, len(set)) + for s := range set { + if strings.TrimSpace(s) == "" { + continue + } + out = append(out, s) + } + sort.Strings(out) + return strings.Join(out, " ") +} + +// applyExcludeScopes removes the provided exclude entries from the requested +// scope string. Each --exclude flag value may itself contain comma- or +// whitespace-separated scopes. Returns the filtered scope string and any +// exclude entries that were not present in the requested set (callers can +// surface those as a validation error to catch typos like +// `--exclude drive:file:downlod`). +func applyExcludeScopes(requested string, excludes []string) (string, []string) { + requestedSet := make(map[string]bool) + for _, s := range strings.Fields(requested) { + requestedSet[s] = true + } + + excludeSet := make(map[string]bool) + for _, raw := range excludes { + // --exclude already splits on commas (StringSliceVar), but also + // tolerate whitespace-separated entries inside a single value. + for _, s := range strings.Fields(strings.ReplaceAll(raw, ",", " ")) { + excludeSet[s] = true + } + } + + var unknown []string + for s := range excludeSet { + if !requestedSet[s] { + unknown = append(unknown, s) + } + } + if len(unknown) > 0 { + sort.Strings(unknown) + return requested, unknown + } + + kept := make(map[string]bool, len(requestedSet)) + for s := range requestedSet { + if !excludeSet[s] { + kept[s] = true + } + } + return joinSortedScopeSet(kept), nil +}