diff --git a/internal/app/auth_command.go b/internal/app/auth_command.go index dcf47e87..5923b64d 100644 --- a/internal/app/auth_command.go +++ b/internal/app/auth_command.go @@ -30,18 +30,39 @@ import ( apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/helpers" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/keychain" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/pat" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/config" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition" + "github.com/charmbracelet/huh" + "github.com/charmbracelet/lipgloss" "github.com/spf13/cobra" ) type authLoginConfig struct { - Token string - Force bool - Device bool + Token string + Force bool + Device bool + Recommend bool + Yes bool } -func buildAuthCommand() *cobra.Command { +type authLoginGuideAction string + +const ( + authLoginGuideDirectCLI authLoginGuideAction = "direct_cli" + authLoginGuideConfigureAgentApp authLoginGuideAction = "configure_agent_app" + authLoginGuideManualCredentials authLoginGuideAction = "manual_credentials" +) + +var ( + authLoginBrandBlue = lipgloss.AdaptiveColor{Light: "#1677FF", Dark: "#69B1FF"} + authLoginInk = lipgloss.AdaptiveColor{Light: "#1F2937", Dark: "#EAF2FF"} + authLoginMuted = lipgloss.AdaptiveColor{Light: "#667085", Dark: "#8A96A8"} + authLoginLine = lipgloss.AdaptiveColor{Light: "#D6E4FF", Dark: "#2F3B52"} + authLoginDanger = lipgloss.AdaptiveColor{Light: "#D92D20", Dark: "#FF6B6B"} +) + +func buildAuthCommand(patCaller edition.ToolCaller) *cobra.Command { cmd := &cobra.Command{ Use: "auth", Short: "认证管理", @@ -55,7 +76,7 @@ func buildAuthCommand() *cobra.Command { } if !edition.Get().HideAuthLogin { - cmd.AddCommand(newAuthLoginCommand()) + cmd.AddCommand(newAuthLoginCommand(patCaller)) } cmd.AddCommand( newAuthLogoutCommand(), @@ -68,7 +89,7 @@ func buildAuthCommand() *cobra.Command { return cmd } -func newAuthLoginCommand() *cobra.Command { +func newAuthLoginCommand(patCaller edition.ToolCaller) *cobra.Command { cmd := &cobra.Command{ Use: "login", Short: "登录钉钉(自动刷新 token,必要时扫码)", @@ -88,7 +109,8 @@ func newAuthLoginCommand() *cobra.Command { 否则 OAuth 回调会跳到本机不可达的 127.0.0.1 链接,授权完成后无法回写 token。 示例: - dws auth login # 本机扫码登录 (loopback 流) + dws auth login # 本机登录后选择推荐/全部权限与授权业务域 + dws auth login --recommend # 无交互批量授权服务端推荐权限 dws auth login --device # SSH 远程 / 无头环境登录 (设备流) dws auth login --force # 强制重新登录 (忽略缓存 token) dws auth login --token xxx # 使用指定 token`, @@ -100,6 +122,10 @@ func newAuthLoginCommand() *cobra.Command { } configDir := defaultConfigDir() var tokenData *authpkg.TokenData + format, _ := cmd.Root().PersistentFlags().GetString("format") + postLoginTUIMode := !cfg.Yes && authLoginShouldUsePostLoginTUIMode(cmd, format, cfg.Recommend) + recommendAuthMode := cfg.Recommend || postLoginTUIMode + humanAuthMode := !cfg.Yes && authLoginShouldUseHumanAuthorizationMode(cmd, format, recommendAuthMode) switch { case strings.TrimSpace(cfg.Token) != "": @@ -139,41 +165,93 @@ func newAuthLoginCommand() *cobra.Command { clearCompatCache() w := cmd.OutOrStdout() + runPostLoginAuthorization := func() error { + if !recommendAuthMode { + return nil + } + recommendScopeMode := pat.LoginRecommendScopeRecommended + var initialPlan *pat.LoginRecommendPlan + if postLoginTUIMode { + var planErr error + initialPlan, planErr = pat.PlanLoginRecommendAuthorization(cmd.Context(), patCaller) + if planErr != nil { + return planErr + } + if authLoginRecommendPlanSkipsInteractiveAuthorization(initialPlan) { + fmt.Fprintln(cmd.ErrOrStderr(), "推荐权限已全部授权或没有可授权项") + return nil + } + var err error + recommendScopeMode, err = loginRecommendScopeModeSelector() + if err != nil { + return err + } + } + opts := pat.LoginRecommendOptions{Confirmed: cfg.Yes, ScopeMode: recommendScopeMode, InitialPlan: initialPlan} + if postLoginTUIMode { + opts.ProductSelector = func(products []pat.LoginRecommendProduct) ([]string, error) { + return loginRecommendProductSelector(products) + } + } + retryFormat := format + if humanAuthMode { + retryFormat = "table" + } + run := func(ctx context.Context) error { + return pat.RunLoginRecommendAuthorizationWithOptions(ctx, patCaller, cmd.ErrOrStderr(), opts) + } + err := run(cmd.Context()) + if patErr := apperrors.AsPatAuthCheckError(err); patErr != nil { + return runDirectPATAuthCheckWaitOnly( + cmd.Context(), + &GlobalFlags{Format: retryFormat}, + patErr, + cmd.ErrOrStderr(), + ) + } + return err + } // Check if JSON output is requested - format, _ := cmd.Root().PersistentFlags().GetString("format") - if strings.EqualFold(strings.TrimSpace(format), "json") { + if strings.EqualFold(strings.TrimSpace(format), "json") && !humanAuthMode { + if err := runPostLoginAuthorization(); err != nil { + return err + } return writeAuthLoginJSON(w, tokenData, cfg.Force) } // Default table output + if err := runPostLoginAuthorization(); err != nil { + return err + } fmt.Fprintln(w) if !cfg.Device && tokenData != nil && tokenData.IsAccessTokenValid() && !cfg.Force { - fmt.Fprintf(w, "[OK] Token 有效,无需重新登录\n") + fmt.Fprintln(w, authLoginStatusLine("Token 有效,无需重新登录")) } else { - fmt.Fprintf(w, "[OK] 登录成功!\n") + fmt.Fprintln(w, authLoginStatusLine("登录成功!")) } if tokenData != nil { if tokenData.CorpName != "" { - fmt.Fprintf(w, "%-16s%s\n", "企业:", tokenData.CorpName) + fmt.Fprintln(w, authLoginInfoLine("企业", tokenData.CorpName)) } if tokenData.CorpID != "" { - fmt.Fprintf(w, "%-16s%s\n", "企业 ID:", tokenData.CorpID) + fmt.Fprintln(w, authLoginInfoLine("企业 ID", tokenData.CorpID)) } if tokenData.UserName != "" { - fmt.Fprintf(w, "%-16s%s\n", "用户:", tokenData.UserName) + fmt.Fprintln(w, authLoginInfoLine("用户", tokenData.UserName)) } if expiry := authLoginDisplayExpiry(tokenData); expiry != "" { - fmt.Fprintf(w, "%-16s%s\n", "有效期:", expiry) + fmt.Fprintln(w, authLoginInfoLine("有效期", expiry)) } } - fmt.Fprintf(w, "Token 将自动刷新,无需重复登录\n") + fmt.Fprintln(w, authLoginMutedStyle().Render("Token 将自动刷新,无需重复登录")) return nil }, } cmd.Flags().String("token", "", "Access token") cmd.Flags().Bool("device", false, "Use device authorization flow") cmd.Flags().Bool("force", false, "Force interactive login (ignore cached token)") + cmd.Flags().Bool("recommend", false, "登录成功后无交互批量授权服务端推荐权限") // Hidden compatibility flags cmd.Flags().String("redirect-url", "", "Loopback redirect URL") cmd.Flags().String("scopes", "", "Space-separated DingTalk OAuth scopes") @@ -191,6 +269,109 @@ func newAuthLoginCommand() *cobra.Command { return cmd } +var ( + authLoginGuideActionSelector = selectAuthLoginGuideAction + authLoginGuideActionApplier = applyAuthLoginGuideAction + loginRecommendScopeModeSelector = selectLoginRecommendScopeMode + loginRecommendProductSelector = selectLoginRecommendProducts + authLoginInteractiveTerminal = isInteractiveTerminal +) + +func selectAuthLoginGuideAction() (authLoginGuideAction, error) { + choice := authLoginGuideDirectCLI + form := huh.NewForm( + huh.NewGroup( + huh.NewSelect[authLoginGuideAction](). + Title("选择操作"). + Options( + huh.NewOption("直接使用CLI", authLoginGuideDirectCLI), + huh.NewOption("一键配置智能体应用", authLoginGuideConfigureAgentApp), + huh.NewOption("手动输入应用凭证", authLoginGuideManualCredentials), + ). + Value(&choice), + ), + ).WithTheme(authLoginHuhTheme()) + if err := form.Run(); err != nil { + return "", fmt.Errorf("使用引导选择中止: %w", err) + } + return choice, nil +} + +func applyAuthLoginGuideAction(cmd *cobra.Command, configDir string, action authLoginGuideAction) error { + switch action { + case authLoginGuideDirectCLI: + return nil + case authLoginGuideConfigureAgentApp: + fmt.Fprintln(cmd.ErrOrStderr(), "一键配置智能体应用暂未开放,已继续使用 CLI 登录") + return nil + case authLoginGuideManualCredentials: + clientID, clientSecret, err := promptAuthLoginManualCredentials() + if err != nil { + return err + } + authpkg.SetClientID(clientID) + authpkg.SetClientSecret(clientSecret) + if err := authpkg.SaveAppConfig(configDir, &authpkg.AppConfig{ + ClientID: clientID, + ClientSecret: authpkg.PlainSecret(clientSecret), + }); err != nil { + return apperrors.NewInternal(fmt.Sprintf("failed to persist app credentials: %v", err)) + } + return nil + default: + return fmt.Errorf("未知操作: %s", action) + } +} + +func promptAuthLoginManualCredentials() (string, string, error) { + var clientID, clientSecret string + nonEmpty := func(label string) func(string) error { + return func(value string) error { + if strings.TrimSpace(value) == "" { + return fmt.Errorf("%s 不能为空", label) + } + return nil + } + } + form := huh.NewForm( + huh.NewGroup( + huh.NewInput(). + Title("输入 AppKey"). + Value(&clientID). + Validate(nonEmpty("AppKey")), + huh.NewInput(). + Title("输入 AppSecret"). + EchoMode(huh.EchoModePassword). + Value(&clientSecret). + Validate(nonEmpty("AppSecret")), + ), + ).WithTheme(authLoginHuhTheme()) + if err := form.Run(); err != nil { + return "", "", fmt.Errorf("应用凭证输入中止: %w", err) + } + return strings.TrimSpace(clientID), strings.TrimSpace(clientSecret), nil +} + +func selectLoginRecommendScopeMode() (pat.LoginRecommendScopeMode, error) { + choice := pat.LoginRecommendScopeRecommended + form := huh.NewForm( + huh.NewGroup( + huh.NewSelect[pat.LoginRecommendScopeMode](). + Title("选择授权范围"). + Description("空格选择 回车确认"). + Options( + huh.NewOption("推荐授权", pat.LoginRecommendScopeRecommended), + huh.NewOption("全部授权", pat.LoginRecommendScopeAll), + ). + Value(&choice), + ), + ).WithTheme(authLoginHuhTheme()) + if err := form.Run(); err != nil { + return "", fmt.Errorf("授权范围选择中止: %w", err) + } + return choice, nil +} + func newAuthLogoutCommand() *cobra.Command { return &cobra.Command{ Use: "logout", @@ -230,7 +411,7 @@ func newAuthLogoutCommand() *cobra.Command { w := cmd.OutOrStdout() fmt.Fprintln(w, "[OK] 已清除所有认证信息") if !edition.Get().IsEmbedded { - fmt.Fprintln(w, "请运行 dws auth login 重新登录") + fmt.Fprintln(w, "请运行 dws auth login --recommend 重新登录") } return nil }, @@ -298,7 +479,7 @@ func newAuthStatusCommand() *cobra.Command { } else { fmt.Fprintf(w, "%-16s%s\n", "状态:", "未登录") if !edition.Get().IsEmbedded { - fmt.Fprintln(w, "运行 dws auth login 进行登录") + fmt.Fprintln(w, "运行 dws auth login --recommend 进行登录") } } return nil @@ -338,7 +519,7 @@ func newAuthExportCommand() *cobra.Command { )) } if !authpkg.PortableAuthSourceReady() { - return apperrors.NewValidation("尚未登录,请先运行 dws auth login") + return apperrors.NewValidation("尚未登录,请先运行 dws auth login --recommend") } var bundle bytes.Buffer @@ -512,7 +693,7 @@ func newAuthResetCommand() *cobra.Command { w := cmd.OutOrStdout() fmt.Fprintln(w, "[OK] 认证信息已重置") if !edition.Get().IsEmbedded { - fmt.Fprintln(w, "请运行 dws auth login 重新登录") + fmt.Fprintln(w, "请运行 dws auth login --recommend 重新登录") } return nil }, @@ -553,6 +734,205 @@ func authLoginDisplayExpiry(data *authpkg.TokenData) string { return "" } +func selectLoginRecommendProducts(products []pat.LoginRecommendProduct) ([]string, error) { + if len(products) == 0 { + return nil, nil + } + selected := make([]string, 0, len(products)) + options := make([]huh.Option[string], 0, len(products)) + for _, product := range products { + code := strings.TrimSpace(product.ProductCode) + if code == "" { + continue + } + selected = append(selected, code) + options = append(options, huh.NewOption(loginRecommendProductLabel(product), code).Selected(true)) + } + if len(options) == 0 { + return nil, nil + } + height := len(options) + if height > 15 { + height = 15 + } + form := huh.NewForm( + huh.NewGroup( + huh.NewMultiSelect[string](). + Title("选择要授权的业务域"). + Description("空格选择 回车确认"). + Options(options...). + Height(height). + Value(&selected). + Validate(func(values []string) error { + if len(values) == 0 { + return fmt.Errorf("至少选择一个授权业务域") + } + return nil + }), + ), + ).WithTheme(authLoginHuhTheme()) + if err := form.Run(); err != nil { + return nil, fmt.Errorf("授权业务域选择中止: %w", err) + } + return selected, nil +} + +func authLoginHuhTheme() *huh.Theme { + t := huh.ThemeBase() + + t.Form.Base = lipgloss.NewStyle().Foreground(authLoginInk) + t.FieldSeparator = lipgloss.NewStyle().SetString("\n") + + t.Focused.Base = t.Focused.Base.BorderForeground(authLoginBrandBlue) + t.Focused.Card = t.Focused.Base + t.Focused.Title = lipgloss.NewStyle().Foreground(authLoginBrandBlue).Bold(true) + t.Focused.NoteTitle = t.Focused.Title.MarginBottom(1) + t.Focused.Description = authLoginMutedStyle() + t.Focused.ErrorIndicator = lipgloss.NewStyle().SetString(" *").Foreground(authLoginDanger) + t.Focused.ErrorMessage = lipgloss.NewStyle().SetString(" *").Foreground(authLoginDanger) + t.Focused.SelectSelector = lipgloss.NewStyle().SetString("› ").Foreground(authLoginBrandBlue).Bold(true) + t.Focused.MultiSelectSelector = t.Focused.SelectSelector + t.Focused.Option = lipgloss.NewStyle().Foreground(authLoginInk) + t.Focused.SelectedOption = lipgloss.NewStyle().Foreground(authLoginBrandBlue).Bold(true) + t.Focused.SelectedPrefix = lipgloss.NewStyle().SetString("● ").Foreground(authLoginBrandBlue) + t.Focused.UnselectedOption = lipgloss.NewStyle().Foreground(authLoginInk) + t.Focused.UnselectedPrefix = lipgloss.NewStyle().SetString("○ ").Foreground(authLoginMuted) + t.Focused.NextIndicator = lipgloss.NewStyle().SetString("→").Foreground(authLoginBrandBlue) + t.Focused.PrevIndicator = lipgloss.NewStyle().SetString("←").Foreground(authLoginMuted) + t.Focused.FocusedButton = lipgloss.NewStyle(). + Foreground(lipgloss.AdaptiveColor{Light: "#FFFFFF", Dark: "#0B1220"}). + Background(authLoginBrandBlue). + Padding(0, 2). + Bold(true) + t.Focused.BlurredButton = lipgloss.NewStyle(). + Foreground(authLoginInk). + Background(authLoginLine). + Padding(0, 2) + t.Focused.Next = t.Focused.FocusedButton + t.Focused.TextInput.Cursor = lipgloss.NewStyle().Foreground(authLoginBrandBlue) + t.Focused.TextInput.CursorText = lipgloss.NewStyle().Foreground(authLoginInk) + t.Focused.TextInput.Placeholder = authLoginMutedStyle() + t.Focused.TextInput.Prompt = lipgloss.NewStyle().Foreground(authLoginBrandBlue) + t.Focused.TextInput.Text = lipgloss.NewStyle().Foreground(authLoginInk) + + t.Blurred = t.Focused + t.Blurred.Base = t.Focused.Base.BorderStyle(lipgloss.HiddenBorder()).BorderForeground(authLoginLine) + t.Blurred.Card = t.Blurred.Base + t.Blurred.Title = lipgloss.NewStyle().Foreground(authLoginInk) + t.Blurred.NoteTitle = t.Blurred.Title.MarginBottom(1) + t.Blurred.Description = authLoginMutedStyle() + t.Blurred.SelectSelector = lipgloss.NewStyle().SetString(" ") + t.Blurred.MultiSelectSelector = t.Blurred.SelectSelector + t.Blurred.SelectedOption = lipgloss.NewStyle().Foreground(authLoginInk) + t.Blurred.SelectedPrefix = lipgloss.NewStyle().SetString("● ").Foreground(authLoginBrandBlue) + t.Blurred.UnselectedOption = lipgloss.NewStyle().Foreground(authLoginMuted) + t.Blurred.UnselectedPrefix = lipgloss.NewStyle().SetString("○ ").Foreground(authLoginMuted) + t.Blurred.NextIndicator = lipgloss.NewStyle() + t.Blurred.PrevIndicator = lipgloss.NewStyle() + t.Blurred.TextInput.Prompt = lipgloss.NewStyle().Foreground(authLoginMuted) + t.Blurred.TextInput.Text = lipgloss.NewStyle().Foreground(authLoginInk) + + t.Group.Title = t.Focused.Title + t.Group.Description = t.Focused.Description + + t.Help.ShortKey = authLoginMutedStyle() + t.Help.ShortDesc = authLoginMutedStyle() + t.Help.ShortSeparator = authLoginMutedStyle() + t.Help.FullKey = authLoginMutedStyle() + t.Help.FullDesc = authLoginMutedStyle() + t.Help.FullSeparator = authLoginMutedStyle() + t.Help.Ellipsis = authLoginMutedStyle() + + return t +} + +func authLoginStatusLine(message string) string { + return fmt.Sprintf("%s %s", + lipgloss.NewStyle().Foreground(authLoginBrandBlue).Bold(true).Render("[OK]"), + lipgloss.NewStyle().Foreground(authLoginInk).Bold(true).Render(message), + ) +} + +func authLoginInfoLine(key, value string) string { + label := authLoginMutedStyle().Width(14).Render(key + ":") + return fmt.Sprintf("%s %s", label, value) +} + +func authLoginMutedStyle() lipgloss.Style { + return lipgloss.NewStyle().Foreground(authLoginMuted) +} + +func authLoginShouldShowPostLoginTUI(cmd *cobra.Command, format string, recommend bool) bool { + return authLoginShouldUsePostLoginTUIModeForTerminal(cmd, format, recommend, authLoginInteractiveTerminal()) +} + +func authLoginShouldShowPostLoginTUIForTerminal(cmd *cobra.Command, format string, recommend bool, interactive bool) bool { + return authLoginShouldUsePostLoginTUIModeForTerminal(cmd, format, recommend, interactive) +} + +func authLoginShouldUsePostLoginTUIMode(cmd *cobra.Command, format string, recommend bool) bool { + return authLoginShouldUsePostLoginTUIModeForTerminal(cmd, format, recommend, authLoginInteractiveTerminal()) +} + +func authLoginShouldUsePostLoginTUIModeForTerminal(cmd *cobra.Command, format string, recommend bool, interactive bool) bool { + if recommend || !interactive { + return false + } + return authLoginAllowsInteractiveDefault(cmd, format) +} + +func authLoginShouldUseHumanAuthorizationMode(cmd *cobra.Command, format string, hasAuthorizationFlow bool) bool { + return authLoginShouldUseHumanAuthorizationModeForTerminal(cmd, format, hasAuthorizationFlow, authLoginInteractiveTerminal()) +} + +func authLoginShouldUseHumanAuthorizationModeForTerminal(cmd *cobra.Command, format string, hasAuthorizationFlow bool, interactive bool) bool { + if !hasAuthorizationFlow || !interactive { + return false + } + return authLoginAllowsInteractiveDefault(cmd, format) +} + +func authLoginRecommendPlanSkipsInteractiveAuthorization(plan *pat.LoginRecommendPlan) bool { + if plan == nil { + return false + } + return plan.AllGranted || len(plan.Scopes) == 0 +} + +func authLoginAllowsInteractiveDefault(cmd *cobra.Command, format string) bool { + if cmd == nil || cmd.Root() == nil { + return false + } + if !strings.EqualFold(strings.TrimSpace(format), "json") { + return true + } + flags := cmd.Root().PersistentFlags() + return !flags.Changed("format") +} + +func loginRecommendProductLabel(product pat.LoginRecommendProduct) string { + name := strings.TrimSpace(product.ProductName) + if name == "" || name == product.ProductCode { + name = product.ProductCode + } + summary := strings.TrimSpace(product.Summary) + if summary != "" { + summary = " - " + clipRunes(summary, 42) + } + return fmt.Sprintf("%-10s %s%s", product.ProductCode, name, summary) +} + +func clipRunes(value string, limit int) string { + if limit <= 0 { + return "" + } + runes := []rune(value) + if len(runes) <= limit { + return value + } + return string(runes[:limit]) + "..." +} + func clearCompatCache() { store := cacheStoreFromEnv() if store != nil { @@ -573,10 +953,20 @@ func resolveAuthLoginConfig(cmd *cobra.Command) (authLoginConfig, error) { if err != nil { return authLoginConfig{}, apperrors.NewInternal("failed to read --force") } + recommend, err := cmd.Flags().GetBool("recommend") + if err != nil { + return authLoginConfig{}, apperrors.NewInternal("failed to read --recommend") + } + yes := false + if cmd.Root() != nil { + yes, _ = cmd.Root().PersistentFlags().GetBool("yes") + } return authLoginConfig{ - Token: strings.TrimSpace(token), - Force: force, - Device: device, + Token: strings.TrimSpace(token), + Force: force, + Device: device, + Recommend: recommend, + Yes: yes, }, nil } diff --git a/internal/app/auth_command_test.go b/internal/app/auth_command_test.go index b6a1e85a..2844ab70 100644 --- a/internal/app/auth_command_test.go +++ b/internal/app/auth_command_test.go @@ -15,6 +15,7 @@ package app import ( "bytes" + "context" "errors" "net/http" "os" @@ -26,6 +27,9 @@ import ( authpkg "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/auth" apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/keychain" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/pat" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition" + "github.com/spf13/cobra" ) func TestAuthExportImportBase64RoundTrip(t *testing.T) { @@ -180,8 +184,461 @@ func TestAuthStatusRefreshFailureLeavesStoredTokenIntact(t *testing.T) { } } +func TestAuthLoginPostLoginTUIModeRespectsRecommendAndFormat(t *testing.T) { + newRoot := func(t *testing.T) *cobra.Command { + t.Helper() + root := &cobra.Command{Use: "dws"} + root.PersistentFlags().String("format", "json", "") + return root + } + + t.Run("recommend skips tui but keeps human auth for interactive login", func(t *testing.T) { + root := newRoot(t) + if authLoginShouldShowPostLoginTUIForTerminal(root, "json", true, true) { + t.Fatal("--recommend must not show the post-login product TUI") + } + if !authLoginShouldUseHumanAuthorizationModeForTerminal(root, "json", true, true) { + t.Fatal("default interactive --recommend should still use human authorization flow") + } + }) + + t.Run("without recommend shows two-step authorization tui", func(t *testing.T) { + root := newRoot(t) + if !authLoginShouldShowPostLoginTUIForTerminal(root, "json", false, true) { + t.Fatal("default interactive login should show post-login authorization TUI") + } + if !authLoginShouldUseHumanAuthorizationModeForTerminal(root, "json", true, true) { + t.Fatal("default interactive post-login authorization should use human authorization flow") + } + }) + + t.Run("explicit json keeps machine mode", func(t *testing.T) { + root := newRoot(t) + if err := root.PersistentFlags().Set("format", "json"); err != nil { + t.Fatalf("set format: %v", err) + } + if authLoginShouldShowPostLoginTUIForTerminal(root, "json", false, true) { + t.Fatal("explicit --format json must not show post-login TUI") + } + if authLoginShouldUseHumanAuthorizationModeForTerminal(root, "json", true, true) { + t.Fatal("explicit --format json must keep machine-readable authorization flow") + } + }) + + t.Run("table without recommend shows authorization tui", func(t *testing.T) { + root := newRoot(t) + if err := root.PersistentFlags().Set("format", "table"); err != nil { + t.Fatalf("set format: %v", err) + } + if !authLoginShouldShowPostLoginTUIForTerminal(root, "table", false, true) { + t.Fatal("table format should show post-login TUI without --recommend") + } + if !authLoginShouldUseHumanAuthorizationModeForTerminal(root, "table", true, true) { + t.Fatal("table format should use human authorization flow in an interactive terminal") + } + }) + + t.Run("non interactive skips selector", func(t *testing.T) { + root := newRoot(t) + if authLoginShouldShowPostLoginTUIForTerminal(root, "json", false, false) { + t.Fatal("non-interactive login should skip post-login TUI") + } + if authLoginShouldUseHumanAuthorizationModeForTerminal(root, "json", true, false) { + t.Fatal("non-interactive login should keep machine-readable authorization flow") + } + }) + + t.Run("without authorization flow keeps normal login output contract", func(t *testing.T) { + root := newRoot(t) + if authLoginShouldUseHumanAuthorizationModeForTerminal(root, "json", false, true) { + t.Fatal("login without a post-login authorization flow should not switch default json to human mode") + } + }) +} + +func TestLoginRecommendProductLabelMatchesTUITarget(t *testing.T) { + label := loginRecommendProductLabel(pat.LoginRecommendProduct{ + ProductCode: "approval", + ProductName: "审批", + Summary: "审批实例,审批模板,审批任务管理", + ScopeCount: 12, + }) + if label != "approval 审批 - 审批实例,审批模板,审批任务管理" { + t.Fatalf("label = %q", label) + } +} + +func TestResolveAuthLoginConfigReadsInheritedYes(t *testing.T) { + root := &cobra.Command{Use: "dws"} + root.PersistentFlags().Bool("yes", false, "") + login := &cobra.Command{Use: "login"} + login.Flags().String("token", "", "") + login.Flags().Bool("device", false, "") + login.Flags().Bool("force", false, "") + login.Flags().Bool("recommend", false, "") + root.AddCommand(login) + + if err := root.PersistentFlags().Set("yes", "true"); err != nil { + t.Fatalf("set yes: %v", err) + } + if err := login.Flags().Set("recommend", "true"); err != nil { + t.Fatalf("set recommend: %v", err) + } + + cfg, err := resolveAuthLoginConfig(login) + if err != nil { + t.Fatalf("resolveAuthLoginConfig error = %v", err) + } + if !cfg.Recommend { + t.Fatal("Recommend = false, want true") + } + if !cfg.Yes { + t.Fatal("Yes = false, want true") + } +} + +func TestAuthLoginRecommendSkipsPostLoginTUI(t *testing.T) { + t.Setenv(keychain.DisableKeychainEnv, "1") + t.Setenv(keychain.StorageDirEnv, t.TempDir()) + t.Setenv("DWS_CONFIG_DIR", t.TempDir()) + + oldGuideSelector := authLoginGuideActionSelector + oldGuideApplier := authLoginGuideActionApplier + oldScopeSelector := loginRecommendScopeModeSelector + oldProductSelector := loginRecommendProductSelector + oldInteractiveTerminal := authLoginInteractiveTerminal + t.Cleanup(func() { + authLoginGuideActionSelector = oldGuideSelector + authLoginGuideActionApplier = oldGuideApplier + loginRecommendScopeModeSelector = oldScopeSelector + loginRecommendProductSelector = oldProductSelector + authLoginInteractiveTerminal = oldInteractiveTerminal + }) + authLoginInteractiveTerminal = func() bool { return true } + authLoginGuideActionSelector = func() (authLoginGuideAction, error) { + t.Fatal("--recommend must not call the post-login guide selector") + return "", nil + } + authLoginGuideActionApplier = func(*cobra.Command, string, authLoginGuideAction) error { + t.Fatal("--recommend must not apply a post-login guide action") + return nil + } + loginRecommendScopeModeSelector = func() (pat.LoginRecommendScopeMode, error) { + t.Fatal("--recommend must not call the scope-mode TUI") + return "", nil + } + loginRecommendProductSelector = func([]pat.LoginRecommendProduct) ([]string, error) { + t.Fatal("--recommend must not call the product-domain TUI") + return nil, nil + } + + fake := &authLoginRecommendSequenceCaller{responses: []string{ + `{"success":true,"data":{"items":[{"scope":"calendar.event:read","productCode":"calendar","productName":"日历"}],"selectedScopes":["calendar.event:read"]}}`, + `{"success":true,"data":{"grantedScopes":["calendar.event:read"]}}`, + }} + cmd := newAuthLoginCommand(fake) + var out bytes.Buffer + cmd.SetOut(&out) + cmd.SetErr(&out) + cmd.SetArgs([]string{"--token", "login-token", "--recommend"}) + + if err := cmd.Execute(); err != nil { + t.Fatalf("auth login --recommend error = %v\noutput:\n%s", err, out.String()) + } + if len(fake.tools) != 2 { + t.Fatalf("CallTool count = %d, want plan + grant", len(fake.tools)) + } + if fake.tools[0] != "pat.batch_plan" || fake.tools[1] != "pat.batch_grant" { + t.Fatalf("tool sequence = %v, want plan, grant", fake.tools) + } + if got := fake.args[0]["recommend"]; got != true { + t.Fatalf("--recommend plan recommend = %#v, want true", got) + } +} + +func TestAuthLoginDefaultTUIModeSkipsSelectorWhenAllGranted(t *testing.T) { + t.Setenv(keychain.DisableKeychainEnv, "1") + t.Setenv(keychain.StorageDirEnv, t.TempDir()) + t.Setenv("DWS_CONFIG_DIR", t.TempDir()) + + oldGuideSelector := authLoginGuideActionSelector + oldGuideApplier := authLoginGuideActionApplier + oldScopeSelector := loginRecommendScopeModeSelector + oldProductSelector := loginRecommendProductSelector + oldInteractiveTerminal := authLoginInteractiveTerminal + t.Cleanup(func() { + authLoginGuideActionSelector = oldGuideSelector + authLoginGuideActionApplier = oldGuideApplier + loginRecommendScopeModeSelector = oldScopeSelector + loginRecommendProductSelector = oldProductSelector + authLoginInteractiveTerminal = oldInteractiveTerminal + }) + authLoginInteractiveTerminal = func() bool { return true } + authLoginGuideActionSelector = func() (authLoginGuideAction, error) { + t.Fatal("default auth login must not call the operation guide selector") + return "", nil + } + authLoginGuideActionApplier = func(*cobra.Command, string, authLoginGuideAction) error { + t.Fatal("default auth login must not apply a post-login guide action") + return nil + } + loginRecommendScopeModeSelector = func() (pat.LoginRecommendScopeMode, error) { + t.Fatal("all-granted recommend plan must not call the scope-mode TUI") + return "", nil + } + loginRecommendProductSelector = func([]pat.LoginRecommendProduct) ([]string, error) { + t.Fatal("all-granted recommend plan must not call the product-domain TUI") + return nil, nil + } + + fake := &authLoginRecommendSequenceCaller{responses: []string{ + `{"success":true,"data":{"allGranted":true,"selectedScopes":[]}}`, + }} + cmd := newAuthLoginCommand(fake) + var out bytes.Buffer + cmd.SetOut(&out) + cmd.SetErr(&out) + cmd.SetArgs([]string{"--token", "login-token"}) + + if err := cmd.Execute(); err != nil { + t.Fatalf("auth login error = %v\noutput:\n%s", err, out.String()) + } + if len(fake.tools) != 1 { + t.Fatalf("CallTool count = %d, want only preflight plan", len(fake.tools)) + } + if fake.tools[0] != "pat.batch_plan" { + t.Fatalf("tool sequence = %v, want only plan", fake.tools) + } + if !strings.Contains(out.String(), "推荐权限已全部授权或没有可授权项") { + t.Fatalf("output = %q, want all-granted message", out.String()) + } +} + +func TestAuthLoginDefaultTUIModeRecommendedAlreadyGrantedSkipsTUIAndAuthorizationPage(t *testing.T) { + t.Setenv(keychain.DisableKeychainEnv, "1") + t.Setenv(keychain.StorageDirEnv, t.TempDir()) + t.Setenv("DWS_CONFIG_DIR", t.TempDir()) + + oldGuideSelector := authLoginGuideActionSelector + oldGuideApplier := authLoginGuideActionApplier + oldScopeSelector := loginRecommendScopeModeSelector + oldProductSelector := loginRecommendProductSelector + oldInteractiveTerminal := authLoginInteractiveTerminal + t.Cleanup(func() { + authLoginGuideActionSelector = oldGuideSelector + authLoginGuideActionApplier = oldGuideApplier + loginRecommendScopeModeSelector = oldScopeSelector + loginRecommendProductSelector = oldProductSelector + authLoginInteractiveTerminal = oldInteractiveTerminal + }) + authLoginInteractiveTerminal = func() bool { return true } + authLoginGuideActionSelector = func() (authLoginGuideAction, error) { + t.Fatal("default auth login must not call the operation guide selector") + return "", nil + } + authLoginGuideActionApplier = func(*cobra.Command, string, authLoginGuideAction) error { + t.Fatal("default auth login must not apply a post-login guide action") + return nil + } + loginRecommendScopeModeSelector = func() (pat.LoginRecommendScopeMode, error) { + t.Fatal("already-granted recommended auth must not call the scope-mode TUI") + return "", nil + } + loginRecommendProductSelector = func([]pat.LoginRecommendProduct) ([]string, error) { + t.Fatal("already-granted recommended auth must not call product-domain TUI") + return nil, nil + } + + fake := &authLoginRecommendSequenceCaller{responses: []string{ + `{"success":true,"data":{"allGranted":false,"items":[{"scope":"calendar.event:read","productCode":"calendar","productName":"日历"}],"selectedScopes":[]}}`, + }} + cmd := newAuthLoginCommand(fake) + var out bytes.Buffer + cmd.SetOut(&out) + cmd.SetErr(&out) + cmd.SetArgs([]string{"--token", "login-token"}) + + if err := cmd.Execute(); err != nil { + t.Fatalf("auth login error = %v\noutput:\n%s", err, out.String()) + } + if len(fake.tools) != 1 { + t.Fatalf("CallTool count = %d, want only preflight recommend plan", len(fake.tools)) + } + if fake.tools[0] != "pat.batch_plan" { + t.Fatalf("tool sequence = %v, want only plan", fake.tools) + } + if !strings.Contains(out.String(), "推荐权限已全部授权或没有可授权项") { + t.Fatalf("output = %q, want already-granted message", out.String()) + } +} + +func TestAuthLoginDefaultTUIRunsAfterLoginTokenSaved(t *testing.T) { + t.Setenv(keychain.DisableKeychainEnv, "1") + t.Setenv(keychain.StorageDirEnv, t.TempDir()) + configDir := t.TempDir() + t.Setenv("DWS_CONFIG_DIR", configDir) + + oldGuideSelector := authLoginGuideActionSelector + oldGuideApplier := authLoginGuideActionApplier + oldScopeSelector := loginRecommendScopeModeSelector + oldProductSelector := loginRecommendProductSelector + oldInteractiveTerminal := authLoginInteractiveTerminal + t.Cleanup(func() { + authLoginGuideActionSelector = oldGuideSelector + authLoginGuideActionApplier = oldGuideApplier + loginRecommendScopeModeSelector = oldScopeSelector + loginRecommendProductSelector = oldProductSelector + authLoginInteractiveTerminal = oldInteractiveTerminal + }) + authLoginInteractiveTerminal = func() bool { return true } + + var sawTokenBeforeScopeTUI bool + var sawTokenBeforeProductTUI bool + var sawTokenBeforePlan bool + authLoginGuideActionSelector = func() (authLoginGuideAction, error) { + t.Fatal("default login must not call the operation guide selector") + return "", nil + } + authLoginGuideActionApplier = func(*cobra.Command, string, authLoginGuideAction) error { + t.Fatal("default login must not apply a post-login guide action") + return nil + } + loginRecommendScopeModeSelector = func() (pat.LoginRecommendScopeMode, error) { + token, err := authpkg.LoadTokenData(configDir) + if err != nil { + t.Fatalf("LoadTokenData before scope TUI error = %v", err) + } + if token.AccessToken != "login-token" { + t.Fatalf("AccessToken before scope TUI = %q, want login-token", token.AccessToken) + } + sawTokenBeforeScopeTUI = true + return pat.LoginRecommendScopeAll, nil + } + loginRecommendProductSelector = func(products []pat.LoginRecommendProduct) ([]string, error) { + token, err := authpkg.LoadTokenData(configDir) + if err != nil { + t.Fatalf("LoadTokenData before product TUI error = %v", err) + } + if token.AccessToken != "login-token" { + t.Fatalf("AccessToken before product TUI = %q, want login-token", token.AccessToken) + } + sawTokenBeforeProductTUI = true + if len(products) != 1 || products[0].ProductCode != "calendar" { + t.Fatalf("selector products = %+v, want calendar", products) + } + return []string{"calendar"}, nil + } + + fake := &authLoginRecommendSequenceCaller{responses: []string{ + `{"success":true,"data":{"items":[{"scope":"calendar.event:read","productCode":"calendar","productName":"日历"}],"selectedScopes":["calendar.event:read"]}}`, + `{"success":true,"data":{"items":[{"scope":"calendar.event:read","productCode":"calendar","productName":"日历"}],"selectedScopes":["calendar.event:read"]}}`, + `{"success":true,"data":{"grantedScopes":["calendar.event:read"]}}`, + }, beforeCall: func(toolName string) { + token, err := authpkg.LoadTokenData(configDir) + if err != nil { + t.Fatalf("LoadTokenData before %s error = %v", toolName, err) + } + if token.AccessToken != "login-token" { + t.Fatalf("AccessToken before %s = %q, want login-token", toolName, token.AccessToken) + } + sawTokenBeforePlan = true + }} + cmd := newAuthLoginCommand(fake) + var out bytes.Buffer + cmd.SetOut(&out) + cmd.SetErr(&out) + cmd.SetArgs([]string{"--token", "login-token"}) + + if err := cmd.Execute(); err != nil { + t.Fatalf("auth login error = %v\noutput:\n%s", err, out.String()) + } + if !sawTokenBeforeScopeTUI { + t.Fatal("scope-mode TUI was not called after token save") + } + if !sawTokenBeforeProductTUI { + t.Fatal("product-domain TUI was not called after token save") + } + if !sawTokenBeforePlan { + t.Fatal("authorization plan was not called after token save") + } + if len(fake.tools) != 3 { + t.Fatalf("CallTool count = %d, want discovery plan + selected plan + grant", len(fake.tools)) + } + if fake.tools[0] != "pat.batch_plan" || fake.tools[1] != "pat.batch_plan" || fake.tools[2] != "pat.batch_grant" { + t.Fatalf("tool sequence = %v, want plan, plan, grant", fake.tools) + } + if got := fake.args[0]["recommend"]; got != true { + t.Fatalf("discovery plan recommend = %#v, want true", got) + } + if got := fake.args[1]["recommend"]; got != false { + t.Fatalf("selected all-scope plan recommend = %#v, want false", got) + } + if got := fake.args[1]["productCodes"]; !stringSliceArgEqual(got, []string{"calendar"}) { + t.Fatalf("selected all-scope plan productCodes = %#v, want calendar", got) + } +} + type roundTripFunc func(*http.Request) (*http.Response, error) func (f roundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) { return f(req) } + +type authLoginRecommendSequenceCaller struct { + responses []string + tools []string + args []map[string]any + beforeCall func(toolName string) +} + +func (f *authLoginRecommendSequenceCaller) CallTool(_ context.Context, _ string, toolName string, args map[string]any) (*edition.ToolResult, error) { + if f.beforeCall != nil { + f.beforeCall(toolName) + } + f.tools = append(f.tools, toolName) + copiedArgs := make(map[string]any, len(args)) + for key, value := range args { + copiedArgs[key] = value + } + f.args = append(f.args, copiedArgs) + response := `{"success":true,"data":{}}` + if len(f.responses) > 0 { + response = f.responses[0] + f.responses = f.responses[1:] + } + return &edition.ToolResult{Content: []edition.ContentBlock{{Type: "text", Text: response}}}, nil +} + +func (f *authLoginRecommendSequenceCaller) Format() string { return "table" } + +func (f *authLoginRecommendSequenceCaller) DryRun() bool { return false } + +func stringSliceArgEqual(got any, want []string) bool { + if got == nil { + return len(want) == 0 + } + switch values := got.(type) { + case []string: + if len(values) != len(want) { + return false + } + for i := range values { + if values[i] != want[i] { + return false + } + } + return true + case []any: + if len(values) != len(want) { + return false + } + for i := range values { + if values[i] != want[i] { + return false + } + } + return true + default: + return false + } +} diff --git a/internal/app/doctor_command.go b/internal/app/doctor_command.go index 4b3d56fd..b139d379 100644 --- a/internal/app/doctor_command.go +++ b/internal/app/doctor_command.go @@ -24,6 +24,7 @@ import ( "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cache" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/market" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/output" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/tui" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/upgrade" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/config" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition" @@ -112,7 +113,8 @@ func runDoctor(cmd *cobra.Command, _ []string) error { return output.WriteJSON(w, result) } - fmt.Fprintf(w, "\n诊断完成: %d 项通过, %d 项警告, %d 项失败\n", pass, warn, fail) + fmt.Fprintf(w, "\n%s\n", tui.Header("Doctor", fmt.Sprintf("%d pass · %d warn · %d fail", pass, warn, fail))) + fmt.Fprintf(w, "%s 诊断完成: %d 项通过, %d 项警告, %d 项失败\n", tui.StateMark("ok"), pass, warn, fail) if fail > 0 { return fmt.Errorf("诊断发现 %d 项失败", fail) } @@ -123,7 +125,7 @@ func runDoctor(cmd *cobra.Command, _ []string) error { func doctorCheckAuth(ctx context.Context, w io.Writer, jsonOut bool) checkResult { if !jsonOut { - fmt.Fprint(w, "检查登录状态... ") + fmt.Fprint(w, tui.Dim("检查登录状态... ")) } configDir := defaultConfigDir() @@ -186,7 +188,7 @@ func doctorCheckAuth(ctx context.Context, w io.Writer, jsonOut bool) checkResult func doctorCheckNetwork(ctx context.Context, w io.Writer, jsonOut bool, timeout time.Duration) checkResult { if !jsonOut { - fmt.Fprint(w, "检查网络连通性... ") + fmt.Fprint(w, tui.Dim("检查网络连通性... ")) } baseURL := config.GetMCPBaseURL() @@ -228,7 +230,7 @@ func doctorCheckNetwork(ctx context.Context, w io.Writer, jsonOut bool, timeout func doctorCheckCache(w io.Writer, jsonOut bool) checkResult { if !jsonOut { - fmt.Fprint(w, "检查缓存状态... ") + fmt.Fprint(w, tui.Dim("检查缓存状态... ")) } store := cacheStoreFromEnv() @@ -300,7 +302,7 @@ func doctorCheckCache(w io.Writer, jsonOut bool) checkResult { func doctorCheckVersion(w io.Writer, jsonOut bool, timeout time.Duration) checkResult { if !jsonOut { - fmt.Fprint(w, "检查版本更新... ") + fmt.Fprint(w, tui.Dim("检查版本更新... ")) } currentVer := version @@ -348,9 +350,18 @@ func doctorCheckVersion(w io.Writer, jsonOut bool, timeout time.Duration) checkR func printCheckResult(w io.Writer, r checkResult) { icon := statusIcon(r.Status) - fmt.Fprintf(w, "%s %s\n", icon, r.Message) + message := r.Message + switch r.Status { + case statusPass: + message = tui.Success(message) + case statusWarn: + message = tui.Warning(message) + case statusFail: + message = tui.Danger(message) + } + fmt.Fprintf(w, "%s %s\n", icon, message) if r.Hint != "" { - fmt.Fprintf(w, " %s\n", r.Hint) + fmt.Fprintf(w, " %s\n", tui.Dim(r.Hint)) } } @@ -385,7 +396,7 @@ func countResults(checks []checkResult) (pass, warn, fail int) { func doctorCheckPerf(w io.Writer, jsonOut bool) checkResult { if !jsonOut { - fmt.Fprint(w, "检查性能报告... ") + fmt.Fprint(w, tui.Dim("检查性能报告... ")) } report, err := LoadLatestReport() diff --git a/internal/app/pat_auth_retry.go b/internal/app/pat_auth_retry.go index e7a3b5a8..d2baa050 100644 --- a/internal/app/pat_auth_retry.go +++ b/internal/app/pat_auth_retry.go @@ -33,8 +33,9 @@ import ( authpkg "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/auth" apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/executor" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/jsonutil" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/pat" - "github.com/fatih/color" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/tui" ) const ( @@ -51,6 +52,10 @@ const ( var openBrowserFunc = tryOpenBrowser +type patSuppressBrowserOpenKeyType struct{} + +var patSuppressBrowserOpenKey = patSuppressBrowserOpenKeyType{} + // PatScopeError holds information about a missing PAT scope. type PatScopeError struct { OriginalError string @@ -153,36 +158,31 @@ func extractPatScopeError(err error) *PatScopeError { // PrintPatAuthError prints a human-readable PAT authorization error. func PrintPatAuthError(w io.Writer, scopeErr *PatScopeError) { - bold := color.New(color.Bold).SprintFunc() - cyan := color.New(color.FgCyan).SprintFunc() - dim := color.New(color.Faint).SprintFunc() - green := color.New(color.FgGreen).SprintFunc() - fmt.Fprintln(w) fmt.Fprintf(w, "{\n") - fmt.Fprintf(w, " %s: %s,\n", bold("\"ok\""), "false") - fmt.Fprintf(w, " %s: %q,\n", bold("\"identity\""), scopeErr.Identity) - fmt.Fprintf(w, " %s: {\n", bold("\"error\"")) - fmt.Fprintf(w, " %s: %q,\n", bold("\"type\""), scopeErr.ErrorType) - fmt.Fprintf(w, " %s: %q,\n", bold("\"message\""), scopeErr.Message) - fmt.Fprintf(w, " %s: %q\n", bold("\"hint\""), scopeErr.Hint) + fmt.Fprintf(w, " %s: %s,\n", tui.Bold("\"ok\""), "false") + fmt.Fprintf(w, " %s: %q,\n", tui.Bold("\"identity\""), scopeErr.Identity) + fmt.Fprintf(w, " %s: {\n", tui.Bold("\"error\"")) + fmt.Fprintf(w, " %s: %q,\n", tui.Bold("\"type\""), scopeErr.ErrorType) + fmt.Fprintf(w, " %s: %q,\n", tui.Bold("\"message\""), scopeErr.Message) + fmt.Fprintf(w, " %s: %q\n", tui.Bold("\"hint\""), scopeErr.Hint) fmt.Fprintf(w, " }\n") fmt.Fprintf(w, "}\n") fmt.Fprintln(w) // Print authorization instructions - fmt.Fprintf(w, "%s %s\n", green("▶"), bold("需要额外授权")) + fmt.Fprintf(w, "%s %s\n", tui.StateMark("warning"), tui.Bold("需要额外授权")) fmt.Fprintln(w) - fmt.Fprintf(w, " %s %s\n", dim("#"), dim("运行以下命令完成授权")) + fmt.Fprintf(w, " %s %s\n", tui.Dim("#"), tui.Dim("运行以下命令完成授权")) if scopeErr.MissingScope != "" { - fmt.Fprintf(w, " %s %s\n", cyan("$"), cyan(fmt.Sprintf("dws auth login --scope %q", scopeErr.MissingScope))) + fmt.Fprintf(w, " %s %s\n", tui.Cyan("$"), tui.Cyan(fmt.Sprintf("dws auth login --scope %q", scopeErr.MissingScope))) } else { - fmt.Fprintf(w, " %s %s\n", cyan("$"), cyan("dws auth login")) + fmt.Fprintf(w, " %s %s\n", tui.Cyan("$"), tui.Cyan("dws auth login")) } fmt.Fprintln(w) - fmt.Fprintf(w, " %s 在浏览器中打开授权链接,完成授权后重新执行命令\n", dim("ℹ")) + fmt.Fprintf(w, " %s 在浏览器中打开授权链接,完成授权后重新执行命令\n", tui.Dim("ℹ")) fmt.Fprintln(w) } @@ -206,7 +206,10 @@ func wantsStructuredPATOutputFromRunner(runner executor.Runner) bool { return wantsStructuredPATOutput(rr) } -func currentPATOpenBrowser(configDir string) bool { +func currentPATOpenBrowser(ctx context.Context, configDir string) bool { + if suppressed, _ := ctx.Value(patSuppressBrowserOpenKey).(bool); suppressed { + return false + } return pat.EffectiveOpenBrowser(configDir) } @@ -225,8 +228,11 @@ func enrichPATErrorWithOpenBrowser(raw string, openBrowser bool) string { data = map[string]any{} payload["data"] = data } - if rawURI, ok := data["uri"].(string); ok && strings.TrimSpace(rawURI) != "" { - data["authorizationUrl"] = apperrors.PATAuthorizationURL(rawURI) + if rawURI := patAuthorizationURIFromData(data); rawURI != "" { + authURL := apperrors.PATAuthorizationURL(rawURI) + data["uri"] = authURL + delete(data, "authUrl") + delete(data, "authorizationUrl") } data["openBrowser"] = openBrowser @@ -237,15 +243,19 @@ func enrichPATErrorWithOpenBrowser(raw string, openBrowser bool) string { return string(encoded) } +func patAuthorizationURIFromData(data map[string]any) string { + for _, key := range []string{"uri", "authUrl", "authorizationUrl"} { + value, _ := data[key].(string) + if strings.TrimSpace(value) != "" { + return strings.TrimSpace(value) + } + } + return "" +} + // WaitForPatAuthorization polls until the user completes authorization or timeout. // It returns true if authorization was completed, false if timed out or cancelled. func WaitForPatAuthorization(ctx context.Context, configDir string, output io.Writer) bool { - bold := color.New(color.Bold).SprintFunc() - yellow := color.New(color.FgYellow).SprintFunc() - green := color.New(color.FgGreen).SprintFunc() - red := color.New(color.FgRed).SprintFunc() - dim := color.New(color.Faint).SprintFunc() - timeout := PatAuthRetryTimeout deadline := time.Now().Add(timeout) pollTicker := time.NewTicker(PatAuthPollInterval) @@ -253,21 +263,21 @@ func WaitForPatAuthorization(ctx context.Context, configDir string, output io.Wr start := time.Now() fmt.Fprintln(output) - fmt.Fprintf(output, "%s %s\n", yellow("⏳"), bold("等待用户授权...")) - fmt.Fprintf(output, " %s 请在另一个终端完成 dws auth login 授权\n", dim("ℹ")) - fmt.Fprintf(output, " %s 超时时间: %s\n", dim("⏱"), timeout) + fmt.Fprintf(output, "%s %s\n", tui.StateMark("pending"), tui.Bold("等待用户授权...")) + fmt.Fprintf(output, " %s 请在另一个终端完成 dws auth login 授权\n", tui.Dim("ℹ")) + fmt.Fprintf(output, " %s 超时时间: %s\n", tui.Dim("⏱"), timeout) fmt.Fprintln(output) pollCount := 0 for { select { case <-ctx.Done(): - fmt.Fprintf(output, "%s 操作已取消\n", red("✗")) + fmt.Fprintf(output, "%s 操作已取消\n", tui.StateMark("error")) return false case <-time.After(time.Until(deadline)): - fmt.Fprintf(output, "%s 等待授权超时 (%s)\n", red("✗"), timeout) - fmt.Fprintf(output, " %s 请重新执行命令\n", dim("ℹ")) + fmt.Fprintf(output, "%s 等待授权超时 (%s)\n", tui.StateMark("error"), timeout) + fmt.Fprintf(output, " %s 请重新执行命令\n", tui.Dim("ℹ")) return false case <-pollTicker.C: @@ -280,7 +290,7 @@ func WaitForPatAuthorization(ctx context.Context, configDir string, output io.Wr if err == nil && tokenData != nil { if tokenData.IsAccessTokenValid() || tokenData.IsRefreshTokenValid() { fmt.Fprintf(output, "\r%s %s (%s 已用, %s 剩余) \n", - green("✓"), bold("授权成功!"), elapsed, remaining) + tui.StateMark("ok"), tui.Bold("授权成功!"), elapsed, remaining) fmt.Fprintln(output) return true } @@ -288,7 +298,7 @@ func WaitForPatAuthorization(ctx context.Context, configDir string, output io.Wr // Show polling status fmt.Fprintf(output, "\r%s [%d] 等待授权中... (%s 已用, %s 剩余) ", - dim("⟳"), pollCount, elapsed, remaining) + tui.Dim("⟳"), pollCount, elapsed, remaining) } } } @@ -328,8 +338,7 @@ func retryWithPatAuthRetry(ctx context.Context, runner executor.Runner, invocati // Retry the invocation fmt.Fprintln(output) - fmt.Fprintf(output, "%s %s\n", color.New(color.FgGreen).SprintFunc()("▶"), - color.New(color.Bold).SprintFunc()("授权完成,正在重试...")) + fmt.Fprintf(output, "%s %s\n", tui.StateMark("ok"), tui.Bold("授权完成,正在重试...")) fmt.Fprintln(output) return runner.Run(ctx, invocation) @@ -340,6 +349,9 @@ func retryWithPatAuthRetry(ctx context.Context, runner executor.Runner, invocati const ( // patPollInterval is how often we poll the device flow status endpoint. patPollInterval = 2 * time.Second + // patMaxPollInterval caps a server-provided poll interval so a malformed + // response cannot make the CLI look permanently stuck. + patMaxPollInterval = 30 * time.Second // patPollTimeout is the maximum time to wait for user authorization via device flow. patPollTimeout = 10 * time.Minute ) @@ -350,6 +362,12 @@ type patRetryingKeyType struct{} var patRetryingKey = patRetryingKeyType{} +type patRetryRunnerFunc func(context.Context, executor.Invocation) (executor.Result, error) + +func (f patRetryRunnerFunc) Run(ctx context.Context, invocation executor.Invocation) (executor.Result, error) { + return f(ctx, invocation) +} + // IsPatRetrying returns true if the current context is already in a PAT retry. func IsPatRetrying(ctx context.Context) bool { v, _ := ctx.Value(patRetryingKey).(bool) @@ -364,8 +382,8 @@ func openPATAuthorizationURI(rawURI string) error { } // The PAT service returns the complete authorization URL. Treat it as an // opaque string unless it is the known legacy DingTalk hash-route variant. - // That variant is normalized by the PAT error contract helper while still - // preserving the original data.uri in structured output. + // That variant is normalized by the PAT error contract helper before being + // printed, opened, or returned in structured output. return openBrowserFunc(apperrors.PATAuthorizationURL(rawURI)) } @@ -382,6 +400,70 @@ func printPATPollDebugResponse(output io.Writer, statusCode int, body []byte) { fmt.Fprintf(output, " %s\n", trimmed) } +func runDirectPATAuthCheck( + ctx context.Context, + globalFlags *GlobalFlags, + patErr *apperrors.PATError, + retry func(context.Context) error, + output io.Writer, +) error { + if retry == nil { + return patErr + } + return runDirectPATAuthCheckWithMode(ctx, globalFlags, patErr, retry, output, true) +} + +func runDirectPATAuthCheckWaitOnly( + ctx context.Context, + globalFlags *GlobalFlags, + patErr *apperrors.PATError, + output io.Writer, +) error { + ctx = context.WithValue(ctx, patSuppressBrowserOpenKey, true) + return runDirectPATAuthCheckWithMode(ctx, globalFlags, patErr, nil, output, false) +} + +func runDirectPATAuthCheckWithMode( + ctx context.Context, + globalFlags *GlobalFlags, + patErr *apperrors.PATError, + retry func(context.Context) error, + output io.Writer, + retryAfterApproval bool, +) error { + if retryAfterApproval && retry == nil { + return patErr + } + runner := &runtimeRunner{ + globalFlags: globalFlags, + fallback: patRetryRunnerFunc(func(retryCtx context.Context, invocation executor.Invocation) (executor.Result, error) { + if retry != nil { + if err := retry(retryCtx); err != nil { + return executor.Result{}, err + } + } + invocation.Implemented = true + return executor.Result{ + Invocation: invocation, + Response: map[string]any{ + "ok": true, + }, + }, nil + }), + } + _, err := handlePatAuthCheck(ctx, runner, executor.Invocation{ + Kind: "direct_pat_authorization", + Stage: "auth_login_recommend", + CanonicalProduct: defaultPATProductID, + Tool: "pat.batch_grant", + CanonicalPath: "pat.batch_grant", + Params: map[string]any{ + "retryAfterApproval": retryAfterApproval, + }, + }, patErr, defaultConfigDir(), output) + return err +} + // handlePatAuthCheck is called by runner.executeInvocation when a PAT // authorization error is detected. It injects the server-assigned clientId // as x-robot-uid header, prints authorization details, opens the browser, @@ -399,16 +481,25 @@ func handlePatAuthCheck( var patData struct { Code string `json:"code"` Data struct { - Desc string `json:"desc"` - FlowID string `json:"flowId"` - URI string `json:"uri"` - ClientID string `json:"clientId"` - ClientSecret string `json:"clientSecret"` + Desc string `json:"desc"` + FlowID string `json:"flowId"` + URI string `json:"uri"` + AuthURL string `json:"authUrl"` + AuthorizationURL string `json:"authorizationUrl"` + ClientID string `json:"clientId"` + ClientSecret string `json:"clientSecret"` + PollIntervalSecs int `json:"pollIntervalSeconds"` } `json:"data"` } if err := json.Unmarshal([]byte(patErr.RawJSON), &patData); err != nil { return executor.Result{}, patErr } + if patData.Data.URI == "" { + patData.Data.URI = patData.Data.AuthURL + } + if patData.Data.URI == "" { + patData.Data.URI = patData.Data.AuthorizationURL + } slog.Debug("PAT auth check", "clientId", patData.Data.ClientID, @@ -416,7 +507,7 @@ func handlePatAuthCheck( "hasSecret", patData.Data.ClientSecret != "", ) hostOwnedPAT := authpkg.HostOwnsPATFlow() - openBrowser := currentPATOpenBrowser(configDir) + openBrowser := currentPATOpenBrowser(ctx, configDir) slog.Debug("pat.host_owned_decision", "site", "handlePatAuthCheck", "hostOwned", hostOwnedPAT, @@ -466,44 +557,40 @@ func handlePatAuthCheck( return executor.Result{}, &apperrors.PATError{RawJSON: enrichPATErrorWithOpenBrowser(patErr.RawJSON, openBrowser)} } - bold := color.New(color.Bold).SprintFunc() - cyan := color.New(color.FgCyan).SprintFunc() - greenFn := color.New(color.FgGreen).SprintFunc() - yellowFn := color.New(color.FgYellow).SprintFunc() - redFn := color.New(color.FgRed).SprintFunc() - dim := color.New(color.Faint).SprintFunc() - fmt.Fprintln(output) - fmt.Fprintf(output, "%s %s\n", greenFn("▶"), bold("需要 PAT 授权")) + fmt.Fprintf(output, "%s %s\n", tui.StateMark("warning"), tui.Bold("需要 PAT 授权")) if patData.Data.Desc != "" { - fmt.Fprintf(output, " %s %s\n", dim("ℹ"), patData.Data.Desc) + fmt.Fprintf(output, " %s %s\n", tui.Dim("ℹ"), patData.Data.Desc) } if patData.Data.URI != "" { authURL := apperrors.PATAuthorizationURL(patData.Data.URI) - fmt.Fprintf(output, " %s 授权链接: %s\n", dim("🔗"), cyan(authURL)) - fmt.Fprintf(output, " PAT_AUTHORIZATION_URL=%s\n\n", authURL) + fmt.Fprintf(output, " %s 授权链接: %s\n", tui.Dim("🔗"), authURL) + fmt.Fprintln(output) if openBrowser { _ = openPATAuthorizationURI(authURL) } } // Poll the device flow status until user authorizes, rejects, or timeout. - fmt.Fprintf(output, "%s %s\n", yellowFn("⏳"), bold("等待用户授权...")) - fmt.Fprintf(output, " %s 请在浏览器中完成授权,超时时间: %s\n", dim("ℹ"), patPollTimeout) + fmt.Fprintf(output, "%s %s\n", tui.StateMark("pending"), tui.Bold("等待用户授权...")) + fmt.Fprintf(output, " %s 请在浏览器中完成授权,超时时间: %s\n", tui.Dim("ℹ"), patPollTimeout) fmt.Fprintln(output) pollCtx, cancel := context.WithTimeout(ctx, patPollTimeout) defer cancel() - status, authCode, err := pollPatDeviceFlow(pollCtx, patData.Data.FlowID, configDir, output) + status, authCode, err := pollPatDeviceFlowWithInterval( + pollCtx, patData.Data.FlowID, configDir, output, + resolvePATPollInterval(patData.Data.PollIntervalSecs), + ) if err != nil { - fmt.Fprintf(output, "%s 轮询授权状态失败: %v\n", redFn("✗"), err) + fmt.Fprintf(output, "%s 轮询授权状态失败: %v\n", tui.StateMark("error"), err) return executor.Result{}, patErr } switch status { case authpkg.StatusApproved: - fmt.Fprintf(output, "%s %s\n", greenFn("✓"), bold("授权成功!")) + fmt.Fprintf(output, "%s %s\n", tui.StateMark("ok"), tui.Bold("授权成功!")) fmt.Fprintln(output) if appCfg != nil { @@ -519,11 +606,11 @@ func handlePatAuthCheck( tokenData, exchErr := authpkg.ExchangeCodeForToken(ctx, configDir, authCode) if exchErr != nil { slog.Warn("PAT retry: exchangeCode failed, retrying with existing token", "error", exchErr) - fmt.Fprintf(output, " %s 换取新 token 失败: %v (将使用现有凭证重试)\n", yellowFn("⚠"), exchErr) + fmt.Fprintf(output, " %s 换取新 token 失败: %v (将使用现有凭证重试)\n", tui.StateMark("warning"), exchErr) } else { if err := authpkg.SaveTokenData(configDir, tokenData); err != nil { slog.Warn("PAT retry: failed to save new token", "error", err) - fmt.Fprintf(output, " %s 保存新 token 失败: %v\n", yellowFn("⚠"), err) + fmt.Fprintf(output, " %s 保存新 token 失败: %v\n", tui.StateMark("warning"), err) } else { slog.Debug("PAT retry: token refreshed and saved") } @@ -533,13 +620,23 @@ func handlePatAuthCheck( // Clear token cache so the new credentials take effect. ResetRuntimeTokenCache() + if shouldSkipPATRetryAfterApproval(invocation) { + invocation.Implemented = true + return executor.Result{ + Invocation: invocation, + Response: map[string]any{ + "ok": true, + }, + }, nil + } + // Workaround: brief delay to let server-side authorization state propagate // before retrying. Without this the retry may use stale credentials. slog.Debug("PAT retry: waiting for server-side state propagation", "delay", "1s") time.Sleep(1 * time.Second) // Retry the original invocation with pat-retrying flag to prevent recursion. - fmt.Fprintf(output, "%s %s\n", greenFn("▶"), bold("授权完成,正在重试...")) + fmt.Fprintf(output, "%s %s\n", tui.StateMark("ok"), tui.Bold("授权完成,正在重试...")) fmt.Fprintln(output) slog.Debug("PAT retry: identity env check", "DWS_CLIENT_ID", os.Getenv("DWS_CLIENT_ID"), @@ -548,7 +645,7 @@ func handlePatAuthCheck( return r.Run(retryCtx, invocation) case authpkg.StatusRejected: - fmt.Fprintf(output, "%s %s\n", redFn("✗"), bold("用户已拒绝授权")) + fmt.Fprintf(output, "%s %s\n", tui.StateMark("error"), tui.Bold("用户已拒绝授权")) return executor.Result{}, apperrors.NewAuth( "用户已拒绝授权", apperrors.WithReason("pat_auth_rejected"), @@ -556,7 +653,7 @@ func handlePatAuthCheck( ) case authpkg.StatusExpired: - fmt.Fprintf(output, "%s %s\n", redFn("✗"), bold("授权超时")) + fmt.Fprintf(output, "%s %s\n", tui.StateMark("error"), tui.Bold("授权超时")) return executor.Result{}, apperrors.NewAuth( "授权超时", apperrors.WithReason("pat_auth_expired"), @@ -564,7 +661,7 @@ func handlePatAuthCheck( ) case authpkg.StatusCancelled: - fmt.Fprintf(output, "%s %s\n", redFn("✗"), bold("操作已取消")) + fmt.Fprintf(output, "%s %s\n", tui.StateMark("error"), tui.Bold("操作已取消")) return executor.Result{}, apperrors.NewAuth( "操作已取消", apperrors.WithReason("pat_auth_cancelled"), @@ -572,11 +669,23 @@ func handlePatAuthCheck( ) default: - fmt.Fprintf(output, "%s 未知授权状态: %s\n", redFn("✗"), status) + fmt.Fprintf(output, "%s 未知授权状态: %s\n", tui.StateMark("error"), status) return executor.Result{}, patErr } } +func shouldSkipPATRetryAfterApproval(invocation executor.Invocation) bool { + if invocation.Params == nil { + return false + } + value, ok := invocation.Params["retryAfterApproval"] + if !ok { + return false + } + retry, ok := value.(bool) + return ok && !retry +} + func enrichPATErrorForHostControl(raw string) string { if strings.TrimSpace(raw) == "" { return raw @@ -630,7 +739,7 @@ func buildPATScopeJSON(scopeErr *PatScopeError, includeHostControl bool) string "data": data, } // stderr JSON MUST be single-line. - b, err := json.Marshal(payload) + b, err := jsonutil.Marshal(payload) if err != nil { return `{"success":false,"code":"PAT_SCOPE_AUTH_REQUIRED"}` } @@ -655,6 +764,13 @@ func marshalSingleLineJSONNoHTMLEscape(v any) ([]byte, error) { // state (APPROVED/REJECTED/EXPIRED) is reached or the context is cancelled. // Returns the final status string and the authCode (non-empty only on APPROVED). func pollPatDeviceFlow(ctx context.Context, flowID string, configDir string, output io.Writer) (string, string, error) { + return pollPatDeviceFlowWithInterval(ctx, flowID, configDir, output, patPollInterval) +} + +func pollPatDeviceFlowWithInterval(ctx context.Context, flowID string, configDir string, output io.Writer, interval time.Duration) (string, string, error) { + if interval <= 0 { + interval = patPollInterval + } pollURL := fmt.Sprintf("%s%s?flowId=%s", authpkg.GetMCPBaseURL(), authpkg.DevicePollPath, url.QueryEscape(flowID)) @@ -671,10 +787,9 @@ func pollPatDeviceFlow(ctx context.Context, flowID string, configDir string, out }, } - ticker := time.NewTicker(patPollInterval) + ticker := time.NewTicker(interval) defer ticker.Stop() - dim := color.New(color.Faint).SprintFunc() pollCount := 0 for { @@ -686,7 +801,7 @@ func pollPatDeviceFlow(ctx context.Context, flowID string, configDir string, out return authpkg.StatusExpired, "", nil case <-ticker.C: pollCount++ - fmt.Fprintf(output, "\r%s [%d] 等待授权中... ", dim("⟳"), pollCount) + fmt.Fprintf(output, "\r%s [%d] 等待授权中... ", tui.Dim("⟳"), pollCount) req, err := http.NewRequestWithContext(ctx, http.MethodGet, pollURL, nil) if err != nil { @@ -738,6 +853,20 @@ func pollPatDeviceFlow(ctx context.Context, flowID string, configDir string, out } } +func resolvePATPollInterval(seconds int) time.Duration { + if seconds <= 0 { + return patPollInterval + } + interval := time.Duration(seconds) * time.Second + if interval < time.Second { + return time.Second + } + if interval > patMaxPollInterval { + return patMaxPollInterval + } + return interval +} + func browserOpenCommand(goos, rawURL string) *exec.Cmd { switch goos { case "darwin": diff --git a/internal/app/pat_auth_retry_test.go b/internal/app/pat_auth_retry_test.go index d937cac0..6bddf530 100644 --- a/internal/app/pat_auth_retry_test.go +++ b/internal/app/pat_auth_retry_test.go @@ -608,8 +608,14 @@ func TestEnrichPATErrorWithOpenBrowserKeepsAuthorizationURLAmpersandReadable(t * t.Fatalf("json.Unmarshal(enriched PAT payload) error = %v\nraw=%s", err, out) } data, _ := payload["data"].(map[string]any) - if got, _ := data["authorizationUrl"].(string); got != rawURI { - t.Fatalf("data.authorizationUrl = %q, want %q", got, rawURI) + if got, _ := data["uri"].(string); got != rawURI { + t.Fatalf("data.uri = %q, want %q", got, rawURI) + } + if _, ok := data["authUrl"]; ok { + t.Fatalf("data.authUrl should be omitted from enriched PAT payload") + } + if _, ok := data["authorizationUrl"]; ok { + t.Fatalf("data.authorizationUrl should be omitted from enriched PAT payload") } } @@ -656,6 +662,192 @@ func TestHandlePatAuthCheck_Approved(t *testing.T) { } } +func TestRunDirectPATAuthCheck_ApprovedRetriesCallback(t *testing.T) { + t.Setenv(authpkg.AgentCodeEnv, "") + server, _ := setupHandlePATServer(t, "APPROVED", "") + defer server.Close() + + patErr := &apperrors.PATError{RawJSON: makePATErrorJSONWithURI("flow-direct", "test-client-id", "https://example.com/pat")} + var retried atomic.Bool + var retryHadKey atomic.Bool + err := runDirectPATAuthCheck(context.Background(), &GlobalFlags{}, patErr, func(ctx context.Context) error { + retried.Store(true) + retryHadKey.Store(IsPatRetrying(ctx)) + return nil + }, &bytes.Buffer{}) + if err != nil { + t.Fatalf("runDirectPATAuthCheck error = %v", err) + } + if !retried.Load() { + t.Fatal("expected direct PAT auth retry callback to run") + } + if !retryHadKey.Load() { + t.Fatal("expected direct PAT auth retry context to be marked as PAT retrying") + } +} + +func TestRunDirectPATAuthCheckWaitOnly_ApprovedDoesNotRetry(t *testing.T) { + t.Setenv(authpkg.AgentCodeEnv, "") + server, _ := setupHandlePATServer(t, "APPROVED", "") + defer server.Close() + + patErr := &apperrors.PATError{RawJSON: makePATErrorJSONWithURI("flow-direct", "test-client-id", "https://example.com/pat")} + var out bytes.Buffer + err := runDirectPATAuthCheckWaitOnly(context.Background(), &GlobalFlags{}, patErr, &out) + if err != nil { + t.Fatalf("runDirectPATAuthCheckWaitOnly error = %v", err) + } + if strings.Contains(out.String(), "授权完成,正在重试") { + t.Fatalf("wait-only auth must not print retry prompt, output:\n%s", out.String()) + } + if !strings.Contains(out.String(), "授权成功") { + t.Fatalf("wait-only auth should still report success, output:\n%s", out.String()) + } +} + +func TestRunDirectPATAuthCheckWaitOnly_SuppressesBrowserOpen(t *testing.T) { + t.Setenv(authpkg.AgentCodeEnv, "") + server, configDir := setupHandlePATServer(t, "APPROVED", "") + defer server.Close() + if _, err := pat.SetBrowserPolicy(configDir, "", true); err != nil { + t.Fatalf("SetBrowserPolicy(default) error = %v", err) + } + + var opened bool + origOpenBrowser := openBrowserFunc + openBrowserFunc = func(rawURL string) error { + opened = true + return nil + } + t.Cleanup(func() { openBrowserFunc = origOpenBrowser }) + + patErr := &apperrors.PATError{RawJSON: makePATErrorJSONWithURI("flow-direct", "test-client-id", "https://example.com/pat")} + var out bytes.Buffer + err := runDirectPATAuthCheckWaitOnly(context.Background(), &GlobalFlags{}, patErr, &out) + if err != nil { + t.Fatalf("runDirectPATAuthCheckWaitOnly error = %v", err) + } + if opened { + t.Fatal("wait-only auth must not open a second browser tab") + } + if !strings.Contains(out.String(), "授权链接:") { + t.Fatalf("wait-only auth should still print the authorization URL, output:\n%s", out.String()) + } +} + +func TestResolvePATPollInterval(t *testing.T) { + tests := []struct { + name string + seconds int + want time.Duration + }{ + {name: "default", seconds: 0, want: patPollInterval}, + {name: "server value", seconds: 3, want: 3 * time.Second}, + {name: "cap excessive value", seconds: 90, want: patMaxPollInterval}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := resolvePATPollInterval(tt.seconds); got != tt.want { + t.Fatalf("resolvePATPollInterval(%d) = %s, want %s", tt.seconds, got, tt.want) + } + }) + } +} + +func TestRunDirectPATAuthCheck_JSONModeReturnsStructuredPending(t *testing.T) { + t.Setenv(authpkg.AgentCodeEnv, "") + configDir := t.TempDir() + t.Setenv("DWS_CONFIG_DIR", configDir) + if _, err := pat.SetBrowserPolicy(configDir, "", false); err != nil { + t.Fatalf("SetBrowserPolicy(default) error = %v", err) + } + + rawURI := "https://example.com/personalAuthorization?flowId=flow-json&userCode=ABCD-EFGH" + raw := `{"success":false,"code":"PAT_BATCH_AUTH_PENDING","data":{"flowId":"flow-json","uri":"` + rawURI + `","authUrl":"` + rawURI + `","clientId":"test-client-id"}}` + err := runDirectPATAuthCheck(context.Background(), &GlobalFlags{Format: "json"}, + &apperrors.PATError{RawJSON: raw}, + func(ctx context.Context) error { + t.Fatal("retry callback should not run in structured PAT output mode") + return nil + }, + &bytes.Buffer{}, + ) + if err == nil { + t.Fatal("expected structured PATError") + } + patOut, ok := err.(*apperrors.PATError) + if !ok { + t.Fatalf("expected *PATError, got %T: %v", err, err) + } + var payload map[string]any + if err := json.Unmarshal([]byte(patOut.RawJSON), &payload); err != nil { + t.Fatalf("json.Unmarshal(PAT payload) error = %v\nraw=%s", err, patOut.RawJSON) + } + if got, _ := payload["code"].(string); got != "PAT_BATCH_AUTH_PENDING" { + t.Fatalf("code = %q, want PAT_BATCH_AUTH_PENDING", got) + } + data, _ := payload["data"].(map[string]any) + if got, _ := data["uri"].(string); got != rawURI { + t.Fatalf("data.uri = %q, want %q", got, rawURI) + } + if _, ok := data["authUrl"]; ok { + t.Fatalf("data.authUrl should be omitted from structured PAT output") + } + if _, ok := data["authorizationUrl"]; ok { + t.Fatalf("data.authorizationUrl should be omitted from structured PAT output") + } + if got, ok := data["openBrowser"].(bool); !ok || got { + t.Fatalf("data.openBrowser = %#v, want false", data["openBrowser"]) + } +} + +func TestRunDirectPATAuthCheck_JSONModeBackfillsSingleURIFromAuthURL(t *testing.T) { + t.Setenv(authpkg.AgentCodeEnv, "") + configDir := t.TempDir() + t.Setenv("DWS_CONFIG_DIR", configDir) + if _, err := pat.SetBrowserPolicy(configDir, "", false); err != nil { + t.Fatalf("SetBrowserPolicy(default) error = %v", err) + } + + rawURL := "https://open-dev.dingtalk.com/fe/old#%2FpersonalAuthorization%3FflowId%3Dflow-json%26userCode%3DABCD-EFGH" + wantURL := "https://open-dev.dingtalk.com/fe/old?hash=%23%2FpersonalAuthorization%3FflowId%3Dflow-json%26userCode%3DABCD-EFGH#/personalAuthorization?flowId=flow-json&userCode=ABCD-EFGH" + raw := `{"success":false,"code":"PAT_BATCH_AUTH_PENDING","data":{"flowId":"flow-json","authUrl":"` + rawURL + `","clientId":"test-client-id"}}` + err := runDirectPATAuthCheck(context.Background(), &GlobalFlags{Format: "json"}, + &apperrors.PATError{RawJSON: raw}, + func(ctx context.Context) error { + t.Fatal("retry callback should not run in structured PAT output mode") + return nil + }, + &bytes.Buffer{}, + ) + if err == nil { + t.Fatal("expected structured PATError") + } + patOut, ok := err.(*apperrors.PATError) + if !ok { + t.Fatalf("expected *PATError, got %T: %v", err, err) + } + + var payload map[string]any + if err := json.Unmarshal([]byte(patOut.RawJSON), &payload); err != nil { + t.Fatalf("json.Unmarshal(PAT payload) error = %v\nraw=%s", err, patOut.RawJSON) + } + if strings.Contains(patOut.RawJSON, `\u0026`) { + t.Fatalf("PAT output escaped URL separators: %s", patOut.RawJSON) + } + data, _ := payload["data"].(map[string]any) + if got, _ := data["uri"].(string); got != wantURL { + t.Fatalf("data.uri = %q, want %q", got, wantURL) + } + if _, ok := data["authUrl"]; ok { + t.Fatalf("data.authUrl should be omitted after backfilling data.uri") + } + if _, ok := data["authorizationUrl"]; ok { + t.Fatalf("data.authorizationUrl should be omitted after backfilling data.uri") + } +} + func TestHandlePatAuthCheck_Rejected(t *testing.T) { t.Setenv(authpkg.AgentCodeEnv, "") server, configDir := setupHandlePATServer(t, "REJECTED", "") @@ -951,6 +1143,12 @@ func TestHandlePatAuthCheck_JSONModeCanOpenBrowserWithoutTextOutput(t *testing.T if !ok { t.Fatalf("expected *PATError, got %T: %v", err, err) } + if strings.Contains(patOut.RawJSON, `\u0026`) { + t.Fatalf("PATError RawJSON escaped ampersands in authorization URL: %s", patOut.RawJSON) + } + if !strings.Contains(patOut.RawJSON, "&userCode=98JV-JSBL") { + t.Fatalf("PATError RawJSON missing literal ampersand route separator: %s", patOut.RawJSON) + } var payload map[string]any if err := json.Unmarshal([]byte(patOut.RawJSON), &payload); err != nil { t.Fatalf("json.Unmarshal(json PAT payload) error = %v\nraw=%s", err, patOut.RawJSON) @@ -959,8 +1157,11 @@ func TestHandlePatAuthCheck_JSONModeCanOpenBrowserWithoutTextOutput(t *testing.T if got, _ := data["uri"].(string); got != rawURI { t.Fatalf("data.uri = %q, want verbatim %q", got, rawURI) } - if got, _ := data["authorizationUrl"].(string); got != rawURI { - t.Fatalf("data.authorizationUrl = %q, want %q", got, rawURI) + if _, ok := data["authUrl"]; ok { + t.Fatalf("data.authUrl should be omitted from json PAT output") + } + if _, ok := data["authorizationUrl"]; ok { + t.Fatalf("data.authorizationUrl should be omitted from json PAT output") } if got, ok := data["openBrowser"].(bool); !ok || !got { t.Fatalf("data.openBrowser = %#v, want true", data["openBrowser"]) @@ -995,7 +1196,7 @@ func TestHandlePatAuthCheck_NonJSONModeRespectsBrowserPolicy(t *testing.T) { fallback: mock, globalFlags: &GlobalFlags{Format: "table"}, } - raw := `{"code":"AGENT_CODE_NOT_EXISTS","data":{"desc":"test auth","flowId":"flow-approved","uri":"https://example.com/pat","clientId":"test-client-id"}}` + raw := `{"code":"AGENT_CODE_NOT_EXISTS","data":{"desc":"test auth","flowId":"flow-approved","authorizationUrl":"https://example.com/pat","clientId":"test-client-id"}}` var buf bytes.Buffer _, err := handlePatAuthCheck(context.Background(), runner, executor.Invocation{ @@ -1015,6 +1216,12 @@ func TestHandlePatAuthCheck_NonJSONModeRespectsBrowserPolicy(t *testing.T) { if !strings.Contains(buf.String(), "需要 PAT 授权") { t.Fatalf("expected human-readable PAT output, got %q", buf.String()) } + if !strings.Contains(buf.String(), "授权链接: https://example.com/pat") { + t.Fatalf("expected authorization URL in human-readable PAT output, got %q", buf.String()) + } + if strings.Contains(buf.String(), "PAT_AUTHORIZATION_URL=") { + t.Fatalf("human-readable PAT output should not emit a second machine-readable URL line, got %q", buf.String()) + } } func TestRetryWithPatAuthRetry_JSONModeReturnsStructuredPATError(t *testing.T) { @@ -1246,8 +1453,8 @@ func TestHandlePatAuthCheck_OpensOpaqueURIWithoutRebuild(t *testing.T) { if opened != rawURI { t.Fatalf("opened url = %q, want verbatim %q", opened, rawURI) } - if got := buf.String(); !strings.Contains(got, "PAT_AUTHORIZATION_URL="+rawURI) { - t.Fatalf("output missing copy-safe PAT_AUTHORIZATION_URL line:\n%s", got) + if got := buf.String(); strings.Contains(got, "PAT_AUTHORIZATION_URL=") { + t.Fatalf("human-readable PAT output should not emit PAT_AUTHORIZATION_URL line:\n%s", got) } } @@ -1292,8 +1499,8 @@ func TestHandlePatAuthCheck_NormalizesLegacyHashRouteForBrowserAndOutput(t *test if opened != wantURL { t.Fatalf("opened url = %q, want normalized %q", opened, wantURL) } - if got := buf.String(); !strings.Contains(got, "PAT_AUTHORIZATION_URL="+wantURL) { - t.Fatalf("output missing normalized PAT_AUTHORIZATION_URL line:\n%s", got) + if got := buf.String(); strings.Contains(got, "PAT_AUTHORIZATION_URL=") { + t.Fatalf("human-readable PAT output should not emit PAT_AUTHORIZATION_URL line:\n%s", got) } } diff --git a/internal/app/root.go b/internal/app/root.go index fc1229e5..e96b8b36 100644 --- a/internal/app/root.go +++ b/internal/app/root.go @@ -323,9 +323,10 @@ func NewRootCommandWithEngine(rootCtx context.Context, engine *pipeline.Engine) genSkillsCmd.Hidden = true mcpCmd := newMCPCommand(rootCtx, loader, runner, engine) mcpCmd.Hidden = true + patCaller := newToolCallerAdapter(runner, flags) utilityCommands := []*cobra.Command{ - newAuthCommand(), + newAuthCommand(patCaller), newAPICommand(flags), newSkillCommand(), newCacheCommand(), @@ -354,7 +355,6 @@ func NewRootCommandWithEngine(rootCtx context.Context, engine *pipeline.Engine) } // PAT authorization commands (open-source core) - patCaller := newToolCallerAdapter(runner, flags) pat.RegisterCommands(root, patCaller) if fn := edition.Get().RegisterExtraCommands; fn != nil { @@ -372,8 +372,8 @@ func NewRootCommandWithEngine(rootCtx context.Context, engine *pipeline.Engine) return root } -func newAuthCommand() *cobra.Command { - return buildAuthCommand() +func newAuthCommand(patCaller edition.ToolCaller) *cobra.Command { + return buildAuthCommand(patCaller) } func newSkillCommand() *cobra.Command { diff --git a/internal/app/root_help.go b/internal/app/root_help.go index 0e87c6bd..552142bc 100644 --- a/internal/app/root_help.go +++ b/internal/app/root_help.go @@ -6,6 +6,7 @@ import ( "text/tabwriter" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/i18n" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/tui" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition" "github.com/spf13/cobra" ) @@ -50,38 +51,42 @@ func renderRootHelp(root *cobra.Command) { utilities := visibleUtilityRootCommands(root) w := root.OutOrStdout() + _, _ = fmt.Fprintln(w, tui.Header("Workspace CLI", "DingTalk blue-white technical console")) + _, _ = fmt.Fprintln(w, tui.Rule(76)) + _, _ = fmt.Fprintln(w) + if len(services) == 0 { - _, _ = fmt.Fprintln(w, "No MCP services discovered.") + _, _ = fmt.Fprintf(w, "%s %s\n", tui.StateMark("warning"), tui.Warning("No MCP services discovered.")) _, _ = fmt.Fprintln(w) } else { - _, _ = fmt.Fprintln(w, "Discovered MCP Services:") + _, _ = fmt.Fprintln(w, tui.Section("Discovered MCP Services:")) _, _ = fmt.Fprintln(w) tw := tabwriter.NewWriter(w, 0, 0, 2, ' ', 0) for _, service := range services { - _, _ = fmt.Fprintf(tw, " %s\t%s\n", service.Name(), strings.TrimSpace(service.Short)) + _, _ = fmt.Fprintf(tw, " %s %s\t%s\n", tui.StateMark("ok"), tui.Bold(service.Name()), tui.Dim(strings.TrimSpace(service.Short))) } _ = tw.Flush() _, _ = fmt.Fprintln(w) } - _, _ = fmt.Fprintln(w, "Usage:") - _, _ = fmt.Fprintln(w, " dws [command] [flags]") + _, _ = fmt.Fprintln(w, tui.Section("Usage:")) + _, _ = fmt.Fprintf(w, " %s %s\n", tui.Bullet(), tui.White("dws [command] [flags]")) if len(utilities) > 0 { - _, _ = fmt.Fprintln(w, " dws [flags]") + _, _ = fmt.Fprintf(w, " %s %s\n", tui.Bullet(), tui.White("dws [flags]")) } _, _ = fmt.Fprintln(w) if len(utilities) > 0 { - _, _ = fmt.Fprintln(w, "Utility Commands:") + _, _ = fmt.Fprintln(w, tui.Section("Utility Commands:")) _, _ = fmt.Fprintln(w) tw := tabwriter.NewWriter(w, 0, 0, 2, ' ', 0) for _, utility := range utilities { - _, _ = fmt.Fprintf(tw, " %s\t%s\n", utility.Name(), strings.TrimSpace(utility.Short)) + _, _ = fmt.Fprintf(tw, " %s %s\t%s\n", tui.Bullet(), tui.Bold(utility.Name()), tui.Dim(commandShort(utility))) } _ = tw.Flush() _, _ = fmt.Fprintln(w) } - _, _ = fmt.Fprintln(w, `Use "dws --help" for more information about a discovered MCP service or "dws --help" for utility commands.`) + _, _ = fmt.Fprintf(w, "%s %s\n", tui.Key("Next"), `Use "dws --help" for more information about a discovered MCP service or "dws --help" for utility commands.`) // Render root.Long after the command list so agents see the upgrade // hint (or any other root-level guidance) after browsing all available @@ -90,10 +95,21 @@ func renderRootHelp(root *cobra.Command) { // it and dropped this, so we restore it explicitly here. if long := strings.TrimSpace(root.Long); long != "" { _, _ = fmt.Fprintln(w) - _, _ = fmt.Fprintln(w, long) + _, _ = fmt.Fprintln(w, tui.Dim(long)) } } +func commandShort(cmd *cobra.Command) string { + if cmd == nil { + return "" + } + short := strings.TrimSpace(cmd.Short) + if cmd.Name() == "help" && short == "Help about any command" { + return i18n.T("查看任意命令的帮助信息") + } + return short +} + // resolveVisibleProducts returns the set of top-level product IDs that should // be treated as visible. It unions the edition's VisibleProducts hook (when // set) with DirectRuntimeProductIDs(), so dynamically-registered products — diff --git a/internal/app/runner.go b/internal/app/runner.go index 8b3f17a8..11162162 100644 --- a/internal/app/runner.go +++ b/internal/app/runner.go @@ -717,7 +717,6 @@ func resolveIdentityHeaders() map[string]string { if version != "" { headers[transport.HeaderVersion] = version } - envHeaders := map[string]string{ "x-dingtalk-agent": os.Getenv(envDingtalkAgent), "x-dingtalk-dws-agent-code": agentCode, diff --git a/internal/app/skill_setup.go b/internal/app/skill_setup.go index 8a797a48..a47a5340 100644 --- a/internal/app/skill_setup.go +++ b/internal/app/skill_setup.go @@ -667,7 +667,14 @@ func copyFileContent(src, dst string, mode os.FileMode) error { } func isInteractiveTerminal() bool { - fi, err := os.Stdin.Stat() + return isCharDevice(os.Stdin) && isCharDevice(os.Stdout) && isCharDevice(os.Stderr) +} + +func isCharDevice(file *os.File) bool { + if file == nil { + return false + } + fi, err := file.Stat() if err != nil { return false } diff --git a/internal/app/skill_setup_test.go b/internal/app/skill_setup_test.go index 0d393a3a..f6e3651e 100644 --- a/internal/app/skill_setup_test.go +++ b/internal/app/skill_setup_test.go @@ -47,6 +47,22 @@ func TestResolveSkillSetupModeNonInteractiveDefaultsMono(t *testing.T) { } } +func TestIsCharDeviceRejectsNilAndRegularFiles(t *testing.T) { + if isCharDevice(nil) { + t.Fatal("nil file must not be treated as interactive") + } + + file, err := os.CreateTemp(t.TempDir(), "stdout") + if err != nil { + t.Fatal(err) + } + defer file.Close() + + if isCharDevice(file) { + t.Fatal("regular files must not be treated as interactive terminals") + } +} + func TestResolveSkillSetupSourceFindsMonoRoot(t *testing.T) { tmp := t.TempDir() monoDir := filepath.Join(tmp, "skills", "mono") diff --git a/internal/app/tool_caller_adapter.go b/internal/app/tool_caller_adapter.go index 3617e84f..05b385c0 100644 --- a/internal/app/tool_caller_adapter.go +++ b/internal/app/tool_caller_adapter.go @@ -15,10 +15,10 @@ package app import ( "context" - "encoding/json" "log/slog" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/executor" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/jsonutil" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition" ) @@ -77,7 +77,7 @@ func convertResult(r executor.Result) *edition.ToolResult { contentRaw, ok := resp["content"] if !ok { // Dry-run or echo mode: serialize the whole response as text. - data, _ := json.Marshal(resp) + data, _ := jsonutil.Marshal(resp) return &edition.ToolResult{ Content: []edition.ContentBlock{{Type: "text", Text: string(data)}}, } @@ -98,12 +98,12 @@ func convertResult(r executor.Result) *edition.ToolResult { } return &edition.ToolResult{Content: blocks} case map[string]any: - data, _ := json.Marshal(v) + data, _ := jsonutil.Marshal(v) return &edition.ToolResult{ Content: []edition.ContentBlock{{Type: "text", Text: string(data)}}, } default: - data, _ := json.Marshal(contentRaw) + data, _ := jsonutil.Marshal(contentRaw) return &edition.ToolResult{ Content: []edition.ContentBlock{{Type: "text", Text: string(data)}}, } diff --git a/internal/app/upgrade.go b/internal/app/upgrade.go index 81851023..4b2c33d1 100644 --- a/internal/app/upgrade.go +++ b/internal/app/upgrade.go @@ -15,20 +15,20 @@ import ( "strings" "time" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/tui" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/upgrade" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition" - "github.com/fatih/color" "github.com/spf13/cobra" ) var ( - ugBold = color.New(color.Bold).SprintFunc() - ugGreen = color.New(color.FgGreen).SprintFunc() - ugYellow = color.New(color.FgYellow).SprintFunc() - ugRed = color.New(color.FgRed).SprintFunc() - ugCyan = color.New(color.FgCyan).SprintFunc() - ugDim = color.New(color.Faint).SprintFunc() - ugBoldGrn = color.New(color.Bold, color.FgGreen).SprintFunc() + ugBold = tui.Bold + ugGreen = tui.Success + ugYellow = tui.Warning + ugRed = tui.Danger + ugCyan = tui.Cyan + ugDim = tui.Dim + ugBoldGrn = tui.Success ) const defaultListLimit = 10 diff --git a/internal/app/upgrade_embedded_guard_test.go b/internal/app/upgrade_embedded_guard_test.go index 9ec5084b..e3096e8c 100644 --- a/internal/app/upgrade_embedded_guard_test.go +++ b/internal/app/upgrade_embedded_guard_test.go @@ -11,7 +11,7 @@ import ( "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition" ) -func TestUpgradeCommand_BlockedInEmbeddedMode(t *testing.T) { +func TestUpgradeCommand_BlockedWhenEmbedded(t *testing.T) { prev := edition.Get() edition.Override(&edition.Hooks{IsEmbedded: true, Name: "embedded"}) t.Cleanup(func() { edition.Override(prev) }) diff --git a/internal/auth/device_flow.go b/internal/auth/device_flow.go index 685455f2..928c791b 100644 --- a/internal/auth/device_flow.go +++ b/internal/auth/device_flow.go @@ -27,8 +27,8 @@ import ( "time" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/i18n" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/tui" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/config" - "github.com/fatih/color" ) const ( @@ -147,13 +147,14 @@ type serviceResult struct { } // resetCredentialState clears any stale credential state inherited from -// previous login methods (OAuth, PAT, etc.) so that device flow always -// starts fresh by fetching clientID from MCP. +// previous login methods (OAuth, PAT, etc.) before device flow falls back to +// MCP-managed credentials. // // This is a defensive measure: no matter what a prior login wrote to -// app.json or runtime globals, device flow will re-fetch from MCP and -// set the correct clientIDFromMCP flag, ensuring exchangeCode() uses -// the MCP proxy path (which doesn't require clientSecret). +// app.json, device flow will re-fetch from MCP and set the correct +// clientIDFromMCP flag, ensuring exchangeCode() uses the MCP proxy path +// (which doesn't require clientSecret). Complete runtime AppKey/AppSecret +// overrides intentionally skip this reset. func (p *DeviceFlowProvider) resetCredentialState() { p.clientID = "" clientMu.Lock() @@ -162,22 +163,29 @@ func (p *DeviceFlowProvider) resetCredentialState() { } func (p *DeviceFlowProvider) Login(ctx context.Context) (*TokenData, error) { - // Defensive reset: clear any stale credential state from previous login - // methods (OAuth scan, PAT, etc.) so we always re-fetch from MCP. - // This ensures --device login works regardless of what app.json contains. - p.resetCredentialState() + if runtimeClientID, _, ok := getCompleteRuntimeCredentials(); ok { + p.clientID = runtimeClientID + clientMu.Lock() + clientIDFromMCP = false + clientMu.Unlock() + } else { + // Defensive reset: clear any stale credential state from previous login + // methods (OAuth scan, PAT, etc.) so we can re-fetch from MCP. This + // ensures --device login works regardless of what app.json contains. + p.resetCredentialState() - if p.logger != nil { - p.logger.Debug("fetching client ID from MCP server (device flow always re-fetches)") - } - mcpClientID, mcpErr := FetchClientIDFromMCP(ctx) - if mcpErr != nil { - return nil, fmt.Errorf("%s: %w", i18n.T("获取 Client ID 失败"), mcpErr) - } - p.clientID = mcpClientID - SetClientIDFromMCP(mcpClientID) - if p.logger != nil { - p.logger.Debug("fetched client ID from MCP server", "clientID", mcpClientID) + if p.logger != nil { + p.logger.Debug("fetching client ID from MCP server (device flow always re-fetches)") + } + mcpClientID, mcpErr := FetchClientIDFromMCP(ctx) + if mcpErr != nil { + return nil, fmt.Errorf("%s: %w", i18n.T("获取 Client ID 失败"), mcpErr) + } + p.clientID = mcpClientID + SetClientIDFromMCP(mcpClientID) + if p.logger != nil { + p.logger.Debug("fetched client ID from MCP server", "clientID", mcpClientID) + } } const maxAttempts = 3 @@ -583,20 +591,21 @@ func truncateBody(body []byte, maxLen int) string { } var ( - dfBold = color.New(color.Bold).SprintFunc() - dfGreen = color.New(color.FgGreen).SprintFunc() - dfYellow = color.New(color.FgYellow).SprintFunc() - dfRed = color.New(color.FgRed).SprintFunc() - dfCyan = color.New(color.FgCyan).SprintFunc() - dfDim = color.New(color.Faint).SprintFunc() + dfBold = tui.Bold + dfGreen = tui.Success + dfYellow = tui.Warning + dfRed = tui.Danger + dfCyan = tui.Cyan + dfDim = tui.Dim ) func dfPrintStep(w io.Writer, step int, message string, attempt int) { + label := fmt.Sprintf("Step %d", step) if attempt > 1 { - _, _ = fmt.Fprintf(w, i18n.T("%s (第 %d 次尝试)\\n"), dfBold(fmt.Sprintf("▶ Step %d: %s", step, message)), attempt) + _, _ = fmt.Fprintf(w, i18n.T("%s %s: %s (第 %d 次尝试)\\n"), tui.StateMark("ok"), dfBold(label), message, attempt) return } - _, _ = fmt.Fprintf(w, "%s\n", dfBold(fmt.Sprintf("▶ Step %d: %s", step, message))) + _, _ = fmt.Fprintf(w, "%s %s: %s\n", tui.StateMark("ok"), dfBold(label), message) } func dfPrintDeviceCodeBox(w io.Writer, auth *DeviceAuthResponse) { @@ -622,7 +631,7 @@ func dfPrintDeviceCodeBox(w io.Writer, auth *DeviceAuthResponse) { func dfPrintBox(w io.Writer, lines []string) { maxLen := 0 for _, line := range lines { - if l := dfPlainLength(line); l > maxLen { + if l := tui.PlainRuneWidth(line); l > maxLen { maxLen = l } } @@ -631,38 +640,19 @@ func dfPrintBox(w io.Writer, lines []string) { } border := strings.Repeat("─", maxLen+4) - _, _ = fmt.Fprintf(w, " ┌%s┐\n", border) + _, _ = fmt.Fprintf(w, " %s\n", tui.Blue("╭"+border+"╮")) for _, line := range lines { - pad := maxLen - dfPlainLength(line) + pad := maxLen - tui.PlainRuneWidth(line) if pad < 0 { pad = 0 } - _, _ = fmt.Fprintf(w, " │ %s%s │\n", line, strings.Repeat(" ", pad)) + _, _ = fmt.Fprintf(w, " %s %s%s %s\n", tui.Blue("│"), line, strings.Repeat(" ", pad), tui.Blue("│")) } - _, _ = fmt.Fprintf(w, " └%s┘\n", border) -} - -func dfPlainLength(s string) int { - inEscape := false - length := 0 - for _, r := range s { - if r == '\033' { - inEscape = true - continue - } - if inEscape { - if (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') { - inEscape = false - } - continue - } - length++ - } - return length + _, _ = fmt.Fprintf(w, " %s\n", tui.Blue("╰"+border+"╯")) } func dfPrintPollStatus(w io.Writer, count, elapsedSec int) { - _, _ = fmt.Fprintf(w, " %s ", dfDim(fmt.Sprintf(i18n.T("[%d] 轮询中... (%ds)"), count, elapsedSec))) + _, _ = fmt.Fprintf(w, " %s %s ", tui.StateMark("pending"), dfDim(fmt.Sprintf(i18n.T("[%d] 轮询中... (%ds)"), count, elapsedSec))) } func dfPrintPollResult(w io.Writer, status, message string) { diff --git a/internal/auth/endpoints.go b/internal/auth/endpoints.go index 3f3b3303..b90743bf 100644 --- a/internal/auth/endpoints.go +++ b/internal/auth/endpoints.go @@ -290,6 +290,11 @@ func getRuntimeCredentials() (clientID, clientSecret string) { return runtimeClientID, runtimeClientSecret } +func getCompleteRuntimeCredentials() (clientID, clientSecret string, ok bool) { + clientID, clientSecret = getRuntimeCredentials() + return clientID, clientSecret, strings.TrimSpace(clientID) != "" && strings.TrimSpace(clientSecret) != "" +} + // getDefaultConfigDir returns the default configuration directory. // Priority: DWS_CONFIG_DIR env var > ~/.dws func getDefaultConfigDir() string { diff --git a/internal/auth/oauth_provider.go b/internal/auth/oauth_provider.go index bf669d6e..66a9c461 100644 --- a/internal/auth/oauth_provider.go +++ b/internal/auth/oauth_provider.go @@ -57,8 +57,8 @@ func NewOAuthProvider(configDir string, logger *slog.Logger) *OAuthProvider { } // resetCredentialState clears any stale credential state inherited from -// previous login methods so that OAuth flow always starts fresh by -// fetching clientID from MCP. +// previous login methods before the OAuth flow falls back to MCP-managed +// credentials. Complete runtime AppKey/AppSecret overrides skip this reset. func (p *OAuthProvider) resetCredentialState() { p.clientID = "" clientMu.Lock() @@ -110,22 +110,29 @@ func (p *OAuthProvider) Login(ctx context.Context, force bool) (*TokenData, erro } // Fall through: full browser OAuth flow. - // Defensive reset: clear any stale credential state from previous login - // methods so we always re-fetch clientID from MCP. This ensures - // --force login works regardless of what app.json contains. - p.resetCredentialState() + if runtimeClientID, _, ok := getCompleteRuntimeCredentials(); ok { + p.clientID = runtimeClientID + clientMu.Lock() + clientIDFromMCP = false + clientMu.Unlock() + } else { + // Defensive reset: clear any stale credential state from previous login + // methods so we can re-fetch clientID from MCP. This ensures --force + // login works regardless of what app.json contains. + p.resetCredentialState() - if p.logger != nil { - p.logger.Debug("fetching client ID from MCP server (OAuth flow always re-fetches)") - } - mcpClientID, mcpErr := FetchClientIDFromMCP(ctx) - if mcpErr != nil { - return nil, fmt.Errorf("%s: %w", i18n.T("获取 Client ID 失败"), mcpErr) - } - p.clientID = mcpClientID - SetClientIDFromMCP(mcpClientID) - if p.logger != nil { - p.logger.Debug("fetched client ID from MCP server", "clientID", mcpClientID) + if p.logger != nil { + p.logger.Debug("fetching client ID from MCP server (OAuth flow always re-fetches)") + } + mcpClientID, mcpErr := FetchClientIDFromMCP(ctx) + if mcpErr != nil { + return nil, fmt.Errorf("%s: %w", i18n.T("获取 Client ID 失败"), mcpErr) + } + p.clientID = mcpClientID + SetClientIDFromMCP(mcpClientID) + if p.logger != nil { + p.logger.Debug("fetched client ID from MCP server", "clientID", mcpClientID) + } } // Find a free port for the callback server. diff --git a/internal/cli/canonical_test.go b/internal/cli/canonical_test.go index a52ef5b0..757e2115 100644 --- a/internal/cli/canonical_test.go +++ b/internal/cli/canonical_test.go @@ -1285,7 +1285,7 @@ func TestSchemaCommandOutputsDegradedOnMarketUnreachable(t *testing.T) { degradedErr := &CatalogDegraded{ Reason: DegradedMarketUnreachable, - Hint: "无法连接 MCP 市场 (mcp.dingtalk.com),请检查网络", + Hint: "无法连接 MCP 市场,请检查网络", } cmd := NewSchemaCommand(errorLoader{err: degradedErr}, nil) diff --git a/internal/cli/loader.go b/internal/cli/loader.go index 19dd85bd..df97ea6c 100644 --- a/internal/cli/loader.go +++ b/internal/cli/loader.go @@ -90,7 +90,7 @@ func degradedHint(reason CatalogDegradedReason, serverCount int) string { if embedded { return "无法连接 MCP 市场,请检查网络" } - return "无法连接 MCP 市场 (mcp.dingtalk.com),请检查网络" + return "无法连接 MCP 市场,请检查网络" case DegradedRuntimeAllFailed: if embedded { return fmt.Sprintf("已发现 %d 个服务但连接全部失败,请稍后重试", serverCount) diff --git a/internal/errors/errors.go b/internal/errors/errors.go index 6f0be969..f6f74419 100644 --- a/internal/errors/errors.go +++ b/internal/errors/errors.go @@ -21,6 +21,8 @@ import ( "io" "strings" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/jsonutil" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/tui" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/config" ) @@ -309,7 +311,7 @@ func PrintJSON(w io.Writer, err error) error { } payload := map[string]any{"error": errorPayload} - data, marshalErr := json.MarshalIndent(payload, "", " ") + data, marshalErr := jsonutil.MarshalIndent(payload, "", " ") if marshalErr != nil { _, writeErr := fmt.Fprintf(w, "{\"error\":{\"code\":5,\"category\":\"internal\",\"message\":\"failed to encode error output\"}}\n") return writeErr @@ -344,25 +346,25 @@ func PrintHumanAt(w io.Writer, err error, v Verbosity) error { var typed *Error if !stderrors.As(err, &typed) { - _, writeErr := fmt.Fprintf(w, "Error: %s\n", err.Error()) + _, writeErr := fmt.Fprintf(w, "%s %s\n", tui.StateMark("error"), tui.Danger("Error: "+err.Error())) return writeErr } // Line 1: Error summary lines := []string{ - fmt.Sprintf("Error: [%s] %s", strings.ToUpper(string(typed.Category)), typed.Message), + fmt.Sprintf("%s %s", tui.StateMark("error"), tui.Danger(fmt.Sprintf("Error: [%s] %s", strings.ToUpper(string(typed.Category)), typed.Message))), } // Always shown: hint, actions, retryable if typed.Hint != "" { - lines = append(lines, fmt.Sprintf("Hint: %s", typed.Hint)) + lines = append(lines, tui.Cyan(fmt.Sprintf("Hint: %s", typed.Hint))) } // Add user-friendly hint for specific server error codes switch typed.ServerDiag.ServerErrorCode { case "TOKEN_VERIFIED_FAILED", "CLI_ORG_NOT_AUTHORIZED": - lines = append(lines, "Hint: 该组织尚未开启 CLI 数据访问权限,请联系组织主管理员开启。") - lines = append(lines, "Action: 开启地址: "+config.GetDeveloperSettingsURL()) + lines = append(lines, tui.Cyan("Hint: 该组织尚未开启 CLI 数据访问权限,请联系组织主管理员开启。")) + lines = append(lines, tui.White("Action: 开启地址: "+config.GetDeveloperSettingsURL())) } if len(typed.Actions) > 0 { @@ -370,53 +372,53 @@ func PrintHumanAt(w io.Writer, err error, v Verbosity) error { if strings.TrimSpace(action) == "" { continue } - lines = append(lines, fmt.Sprintf("Action: %s", action)) + lines = append(lines, tui.White(fmt.Sprintf("Action: %s", action))) } } if line := formatAvailableFlagsHumanLine(typed.AvailableFlags); line != "" { - lines = append(lines, line) + lines = append(lines, tui.Dim(line)) } if typed.Retryable { - lines = append(lines, "Retryable: true") + lines = append(lines, tui.Warning("Retryable: true")) } // Always shown when present: Trace ID, Server Code if typed.ServerDiag.TraceID != "" { - lines = append(lines, fmt.Sprintf("Trace ID: %s", typed.ServerDiag.TraceID)) + lines = append(lines, tui.Dim(fmt.Sprintf("Trace ID: %s", typed.ServerDiag.TraceID))) } if typed.ServerDiag.ServerErrorCode != "" { - lines = append(lines, fmt.Sprintf("Server Code: %s", typed.ServerDiag.ServerErrorCode)) + lines = append(lines, tui.Dim(fmt.Sprintf("Server Code: %s", typed.ServerDiag.ServerErrorCode))) } // Verbose+: technical detail, snapshot, reason, server key if v >= VerbosityVerbose { if typed.ServerDiag.TechnicalDetail != "" { - lines = append(lines, fmt.Sprintf("Detail: %s", typed.ServerDiag.TechnicalDetail)) + lines = append(lines, tui.Dim(fmt.Sprintf("Detail: %s", typed.ServerDiag.TechnicalDetail))) } if typed.Reason != "" { - lines = append(lines, fmt.Sprintf("Reason: %s", typed.Reason)) + lines = append(lines, tui.Dim(fmt.Sprintf("Reason: %s", typed.Reason))) } if typed.ServerKey != "" { - lines = append(lines, fmt.Sprintf("Server: %s", typed.ServerKey)) + lines = append(lines, tui.Dim(fmt.Sprintf("Server: %s", typed.ServerKey))) } if typed.Snapshot != "" { - lines = append(lines, fmt.Sprintf("Snapshot: %s", typed.Snapshot)) + lines = append(lines, tui.Dim(fmt.Sprintf("Snapshot: %s", typed.Snapshot))) } if typed.Cause != nil { - lines = append(lines, fmt.Sprintf("Cause: %s", typed.Cause.Error())) + lines = append(lines, tui.Dim(fmt.Sprintf("Cause: %s", typed.Cause.Error()))) } } // Debug: all internal diagnostics if v >= VerbosityDebug { if typed.Operation != "" { - lines = append(lines, fmt.Sprintf("Operation: %s", typed.Operation)) + lines = append(lines, tui.Dim(fmt.Sprintf("Operation: %s", typed.Operation))) } if typed.RPCCode != 0 { - lines = append(lines, fmt.Sprintf("RPC Code: %d", typed.RPCCode)) + lines = append(lines, tui.Dim(fmt.Sprintf("RPC Code: %d", typed.RPCCode))) } if len(typed.RPCData) > 0 { - lines = append(lines, fmt.Sprintf("RPC Data: %s", string(typed.RPCData))) + lines = append(lines, tui.Dim(fmt.Sprintf("RPC Data: %s", string(typed.RPCData)))) } } diff --git a/internal/errors/pat.go b/internal/errors/pat.go index 51b49b4d..62a64d59 100644 --- a/internal/errors/pat.go +++ b/internal/errors/pat.go @@ -21,6 +21,8 @@ import ( "net/url" "strings" "sync" + + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/jsonutil" ) // hostControlProvider returns the host-owned clawType for the current @@ -105,12 +107,10 @@ const ExitCodePermission = 4 // json.Unmarshal-able payload of the form // {"success":false,"code":,"data":{...}}. // -// When the payload includes data.uri, that URL is the authoritative -// server-provided authorization link. Hosts must treat it as opaque and open -// it verbatim instead of parsing and reconstructing it locally, because -// required parameters may live in query, encoded hash, or fragment sections. -// New hosts may prefer data.authorizationUrl when present; it preserves data.uri -// while adding a copy/open-safe URL for legacy DingTalk hash-route variants. +// When the payload includes data.uri/authUrl/authorizationUrl, that value is +// the authoritative server-provided authorization link. The CLI accepts all +// legacy aliases, normalizes the known DingTalk hash-route variant, and emits a +// single data.uri field so terminals and hosts do not need to deduplicate links. type PATError struct { RawJSON string } @@ -147,6 +147,7 @@ var patNoPermissionCodes = map[string]bool{ // `dws auth login --scope `. var patAuthRequiredCodes = map[string]bool{ "AGENT_CODE_NOT_EXISTS": true, + "PAT_BATCH_AUTH_PENDING": true, "PAT_SCOPE_AUTH_REQUIRED": true, } @@ -241,7 +242,7 @@ func isBusinessError(body map[string]any) bool { // Check order: DWS gateway auth > PAT permission. func ClassifyToolResultContent(content map[string]any) error { if _, ok := getDWSGatewayErrorCode(content); ok { - raw, _ := json.Marshal(content) + raw, _ := jsonutil.Marshal(content) return NewAuth(string(raw), WithReason("gateway_auth_expired"), WithHint(authExpiredHint()), @@ -353,8 +354,11 @@ func ApplyHostMutations(out map[string]any) { data = map[string]any{} out["data"] = data } - if rawURI, ok := data["uri"].(string); ok && strings.TrimSpace(rawURI) != "" { - data["authorizationUrl"] = PATAuthorizationURL(rawURI) + if rawURI := patAuthorizationURIFromData(data); rawURI != "" { + authURL := PATAuthorizationURL(rawURI) + data["uri"] = authURL + delete(data, "authUrl") + delete(data, "authorizationUrl") } if block := HostControlBlock(); block != nil { delete(data, "callbacks") @@ -363,6 +367,16 @@ func ApplyHostMutations(out map[string]any) { data["openBrowser"] = PATOpenBrowserValue() } +func patAuthorizationURIFromData(data map[string]any) string { + for _, key := range []string{"uri", "authUrl", "authorizationUrl"} { + value, _ := data[key].(string) + if strings.TrimSpace(value) != "" { + return strings.TrimSpace(value) + } + } + return "" +} + // PATAuthorizationURL returns the best URL for hosts to open or show to users. // It keeps already-complete PAT URLs unchanged. For DingTalk's legacy // /fe/old#%2FpersonalAuthorization?... hash-route form, it adds the explicit @@ -443,9 +457,9 @@ func cleanPATJSON(body map[string]any, code string) string { "code": code, } if data, ok := body["data"]; ok { - // Keep data.uri exactly as returned by the service. Host consumers open - // that link directly, so local normalization would risk dropping - // parameters embedded in query/hash/fragment sections. + // ApplyHostMutations canonicalizes PAT URL aliases into one data.uri + // before JSON encoding, while stripClassFields keeps the rest of the + // service payload intact. out["data"] = stripClassFields(data) } else { fallback := map[string]any{} diff --git a/internal/errors/pat_test.go b/internal/errors/pat_test.go index ee6b0a6e..c6b897c5 100644 --- a/internal/errors/pat_test.go +++ b/internal/errors/pat_test.go @@ -378,6 +378,25 @@ func TestClassifyMCPResponseText_PATAuthRequired(t *testing.T) { } } +func TestClassifyMCPResponseText_PATBatchAuthPending(t *testing.T) { + t.Parallel() + text := `{"success":false,"code":"PAT_BATCH_AUTH_PENDING","data":{"flowId":"flow-1","uri":"https://example.test/auth"}}` + err := ClassifyMCPResponseText(text) + if err == nil { + t.Fatal("expected non-nil error") + } + var patErr *PATError + if !stderrors.As(err, &patErr) { + t.Fatalf("expected *PATError, got %T", err) + } + if !strings.Contains(patErr.RawJSON, "PAT_BATCH_AUTH_PENDING") { + t.Errorf("RawJSON should contain PAT_BATCH_AUTH_PENDING, got: %s", patErr.RawJSON) + } + if !strings.Contains(patErr.RawJSON, "flow-1") { + t.Errorf("RawJSON should preserve flowId, got: %s", patErr.RawJSON) + } +} + func TestClassifyMCPResponseText_BusinessError(t *testing.T) { t.Parallel() text := `{"success":false,"errorMsg":"搜索内容不能为空"}` @@ -729,6 +748,12 @@ func TestCleanPATJSON_PreservesOpaqueURIVerbatim(t *testing.T) { } result := cleanPATJSON(body, "PAT_MEDIUM_RISK_NO_PERMISSION") + if strings.Contains(result, `\u0026`) { + t.Fatalf("cleanPATJSON escaped ampersands in URL: %s", result) + } + if !strings.Contains(result, "&userCode=Q8RY-X6E9") { + t.Fatalf("cleanPATJSON output missing literal ampersand route separator: %s", result) + } if strings.Contains(result, `\u0026`) { t.Fatalf("cleanPATJSON should keep URL ampersands readable for mobile copy/linkify, got: %s", result) @@ -745,8 +770,40 @@ func TestCleanPATJSON_PreservesOpaqueURIVerbatim(t *testing.T) { if got, _ := data["uri"].(string); got != rawURI { t.Fatalf("data.uri = %q, want verbatim %q", got, rawURI) } - if got, _ := data["authorizationUrl"].(string); got != rawURI { - t.Fatalf("data.authorizationUrl = %q, want %q", got, rawURI) + if _, ok := data["authUrl"]; ok { + t.Fatalf("data.authUrl should be omitted when data.uri is present") + } + if _, ok := data["authorizationUrl"]; ok { + t.Fatalf("data.authorizationUrl should be omitted when data.uri is present") + } +} + +func TestCleanPATJSON_BackfillsSingleURIFromAuthURL(t *testing.T) { + t.Parallel() + rawURI := "https://open-dev.dingtalk.com/fe/old?hash=%23%2FpersonalAuthorization%3FflowId%3D50dff7654b7444e88ced7489b07cce8d%26userCode%3DQ8RY-X6E9#/personalAuthorization?flowId=50dff7654b7444e88ced7489b07cce8d&userCode=Q8RY-X6E9" + body := map[string]any{ + "success": false, + "code": "PAT_BATCH_AUTH_PENDING", + "data": map[string]any{ + "flowId": "50dff7654b7444e88ced7489b07cce8d", + "authUrl": rawURI, + }, + } + + result := cleanPATJSON(body, "PAT_BATCH_AUTH_PENDING") + var parsed map[string]any + if err := json.Unmarshal([]byte(result), &parsed); err != nil { + t.Fatalf("unmarshal cleanPATJSON output: %v\nraw=%s", err, result) + } + data, _ := parsed["data"].(map[string]any) + if got, _ := data["uri"].(string); got != rawURI { + t.Fatalf("data.uri = %q, want %q", got, rawURI) + } + if _, ok := data["authUrl"]; ok { + t.Fatalf("data.authUrl should be omitted after backfilling data.uri") + } + if _, ok := data["authorizationUrl"]; ok { + t.Fatalf("data.authorizationUrl should be omitted after backfilling data.uri") } } @@ -802,7 +859,7 @@ func TestPATAuthorizationURL_NormalizesLegacyHashRoutePreservesExtraQuery(t *tes } } -func TestCleanPATJSON_AddsNormalizedAuthorizationURL(t *testing.T) { +func TestCleanPATJSON_NormalizesSingleURI(t *testing.T) { t.Parallel() rawURI := "https://open-dev.dingtalk.com/fe/old#%2FpersonalAuthorization%3FflowId%3D56b12fd3201d4efab9a9138672cf4deb%26userCode%3DCFTC-27ZN" want := "https://open-dev.dingtalk.com/fe/old?hash=%23%2FpersonalAuthorization%3FflowId%3D56b12fd3201d4efab9a9138672cf4deb%26userCode%3DCFTC-27ZN#/personalAuthorization?flowId=56b12fd3201d4efab9a9138672cf4deb&userCode=CFTC-27ZN" @@ -817,17 +874,26 @@ func TestCleanPATJSON_AddsNormalizedAuthorizationURL(t *testing.T) { } result := cleanPATJSON(body, "PAT_MEDIUM_RISK_NO_PERMISSION") + if strings.Contains(result, `\u0026`) { + t.Fatalf("cleanPATJSON escaped ampersands in normalized URL: %s", result) + } + if !strings.Contains(result, "&userCode=CFTC-27ZN") { + t.Fatalf("cleanPATJSON output missing literal ampersand route separator: %s", result) + } var parsed map[string]any if err := json.Unmarshal([]byte(result), &parsed); err != nil { t.Fatalf("unmarshal cleanPATJSON output: %v\nraw=%s", err, result) } data, _ := parsed["data"].(map[string]any) - if got, _ := data["uri"].(string); got != rawURI { - t.Fatalf("data.uri = %q, want verbatim %q", got, rawURI) + if got, _ := data["uri"].(string); got != want { + t.Fatalf("data.uri = %q, want normalized %q", got, want) } - if got, _ := data["authorizationUrl"].(string); got != want { - t.Fatalf("data.authorizationUrl = %q, want %q", got, want) + if _, ok := data["authUrl"]; ok { + t.Fatalf("data.authUrl should be omitted after normalizing data.uri") + } + if _, ok := data["authorizationUrl"]; ok { + t.Fatalf("data.authorizationUrl should be omitted after normalizing data.uri") } } diff --git a/internal/jsonutil/json.go b/internal/jsonutil/json.go new file mode 100644 index 00000000..0dbecb0f --- /dev/null +++ b/internal/jsonutil/json.go @@ -0,0 +1,44 @@ +// Copyright 2026 Alibaba Group +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package jsonutil + +import ( + "bytes" + "encoding/json" +) + +// Marshal matches json.Marshal but keeps &, <, and > unchanged. CLI JSON is +// consumed by shells and host processes, not embedded into HTML, so preserving +// URL query separators makes the output easier to copy and parse. +func Marshal(v any) ([]byte, error) { + var buf bytes.Buffer + enc := json.NewEncoder(&buf) + enc.SetEscapeHTML(false) + if err := enc.Encode(v); err != nil { + return nil, err + } + return bytes.TrimSuffix(buf.Bytes(), []byte("\n")), nil +} + +// MarshalIndent matches json.MarshalIndent with HTML escaping disabled. +func MarshalIndent(v any, prefix, indent string) ([]byte, error) { + var buf bytes.Buffer + enc := json.NewEncoder(&buf) + enc.SetEscapeHTML(false) + enc.SetIndent(prefix, indent) + if err := enc.Encode(v); err != nil { + return nil, err + } + return bytes.TrimSuffix(buf.Bytes(), []byte("\n")), nil +} diff --git a/internal/output/filter.go b/internal/output/filter.go index 15160a05..7fc40533 100644 --- a/internal/output/filter.go +++ b/internal/output/filter.go @@ -20,6 +20,7 @@ import ( "strings" apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/jsonutil" "github.com/itchyny/gojq" ) @@ -171,7 +172,7 @@ func ApplyJQ(w io.Writer, payload any, expr string) error { } first = false - data, marshalErr := json.MarshalIndent(value, "", " ") + data, marshalErr := jsonutil.MarshalIndent(value, "", " ") if marshalErr != nil { return apperrors.NewInternal("failed to encode --jq result") } diff --git a/internal/output/filter_test.go b/internal/output/filter_test.go index ca2f6f75..65532db2 100644 --- a/internal/output/filter_test.go +++ b/internal/output/filter_test.go @@ -219,6 +219,20 @@ func TestApplyJQIdentity(t *testing.T) { } } +func TestApplyJQDoesNotEscapeAmpersands(t *testing.T) { + payload := map[string]any{"url": "https://example.test/auth?a=1&b=2"} + var buf bytes.Buffer + if err := ApplyJQ(&buf, payload, ".url"); err != nil { + t.Fatalf("ApplyJQ error: %v", err) + } + if strings.Contains(buf.String(), `\u0026`) { + t.Fatalf("ApplyJQ escaped ampersand: %s", buf.String()) + } + if !strings.Contains(buf.String(), "a=1&b=2") { + t.Fatalf("ApplyJQ output missing literal ampersand: %s", buf.String()) + } +} + func TestApplyJQFieldAccess(t *testing.T) { payload := map[string]any{"name": "Alice", "age": float64(30)} var buf bytes.Buffer diff --git a/internal/output/formatter.go b/internal/output/formatter.go index 27072978..6275422a 100644 --- a/internal/output/formatter.go +++ b/internal/output/formatter.go @@ -22,6 +22,8 @@ import ( apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/executor" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/jsonutil" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/tui" "github.com/spf13/cobra" "github.com/spf13/pflag" ) @@ -257,7 +259,7 @@ func rootPersistentFlags(cmd *cobra.Command) *pflag.FlagSet { // WriteJSON marshals payload as indented JSON and writes it to w. func WriteJSON(w io.Writer, payload any) error { - data, err := json.MarshalIndent(payload, "", " ") + data, err := jsonutil.MarshalIndent(payload, "", " ") if err != nil { return apperrors.NewInternal("failed to encode command output as JSON") } @@ -270,7 +272,7 @@ func writeRaw(w io.Writer, payload any) error { _, err := fmt.Fprintln(w, SanitizeForTerminal(text)) return err } - data, err := json.Marshal(payload) + data, err := jsonutil.Marshal(payload) if err != nil { return apperrors.NewInternal("failed to encode raw command output") } @@ -452,7 +454,8 @@ func writeKeyValues(w io.Writer, payload map[string]any) error { maxWidth = 24 } for _, key := range keys { - if _, err := fmt.Fprintf(w, "%-*s %s\n", maxWidth, key, formatValue(payload[key])); err != nil { + label := tui.PadRightANSI(tui.Key(key), maxWidth+1) + if _, err := fmt.Fprintf(w, "%s %s\n", label, formatValue(payload[key])); err != nil { return err } } @@ -475,57 +478,70 @@ func writeTable(w io.Writer, headers []string, rows [][]string) error { } } for i := range widths { - if widths[i] > 60 { - widths[i] = 60 + if widths[i] > tui.MaxTableColumnWidth { + widths[i] = tui.MaxTableColumnWidth } } - for i, header := range headers { - if i > 0 { - if _, err := io.WriteString(w, " "); err != nil { - return err - } - } - if _, err := fmt.Fprintf(w, "%-*s", widths[i], truncate(header, widths[i])); err != nil { - return err - } - } - if _, err := fmt.Fprintln(w); err != nil { - return err - } - for i, width := range widths { - if i > 0 { - if _, err := io.WriteString(w, " "); err != nil { - return err - } - } - if _, err := io.WriteString(w, strings.Repeat("-", width)); err != nil { - return err - } - } - if _, err := fmt.Fprintln(w); err != nil { - return err + if len(widths) == 0 { + return nil } - for _, row := range rows { - for i, cell := range row { - if i >= len(widths) { - continue - } + writeRow := func(values []string, render func(string) string) error { + if _, err := io.WriteString(w, tui.Gray("│ ")); err != nil { + return err + } + for i := range widths { if i > 0 { - if _, err := io.WriteString(w, " "); err != nil { + if _, err := io.WriteString(w, tui.Gray(" │ ")); err != nil { return err } } - if _, err := fmt.Fprintf(w, "%-*s", widths[i], truncate(cell, widths[i])); err != nil { + cell := "" + if i < len(values) { + cell = values[i] + } + cell = truncate(cell, widths[i]) + if _, err := io.WriteString(w, tui.PadRightANSI(render(cell), widths[i])); err != nil { return err } } - if _, err := fmt.Fprintln(w); err != nil { + _, err := io.WriteString(w, tui.Gray(" │\n")) + return err + } + writeDivider := func(left, mid, right string, render func(string) string) error { + if _, err := io.WriteString(w, render(left)); err != nil { + return err + } + for i, width := range widths { + if i > 0 { + if _, err := io.WriteString(w, render(mid)); err != nil { + return err + } + } + if _, err := io.WriteString(w, render(strings.Repeat("─", width+2))); err != nil { + return err + } + } + _, err := io.WriteString(w, render(right+"\n")) + return err + } + + if err := writeDivider("╭", "┬", "╮", tui.Blue); err != nil { + return err + } + if err := writeRow(headers, tui.Brand); err != nil { + return err + } + if err := writeDivider("├", "┼", "┤", tui.Gray); err != nil { + return err + } + for _, row := range rows { + if err := writeRow(row, tui.White); err != nil { return err } } - return nil + return writeDivider("╰", "┴", "╯", tui.Blue) } func sortedKeys(keys map[string]struct{}) []string { @@ -544,7 +560,7 @@ func formatValue(value any) string { case string: return SanitizeForTerminal(typed) default: - data, err := json.Marshal(typed) + data, err := jsonutil.Marshal(typed) if err != nil { return SanitizeForTerminal(fmt.Sprintf("%v", typed)) } @@ -553,24 +569,27 @@ func formatValue(value any) string { } func truncate(s string, maxWidth int) string { - if runeWidth(s) <= maxWidth { + if maxWidth <= 0 || runeWidth(s) <= maxWidth { return s } - runes := []rune(s) - if len(runes) > maxWidth-1 { - return string(runes[:maxWidth-1]) + "…" + if maxWidth == 1 { + return "…" } - return s + + var b strings.Builder + used := 0 + limit := maxWidth - 1 + for _, r := range s { + width := runeWidth(string(r)) + if used+width > limit { + break + } + b.WriteRune(r) + used += width + } + return b.String() + "…" } func runeWidth(s string) int { - width := 0 - for _, r := range s { - if r >= 0x2E80 && r <= 0x9FFF { - width += 2 - continue - } - width++ - } - return width + return tui.PlainRuneWidth(s) } diff --git a/internal/output/formatter_extra_test.go b/internal/output/formatter_extra_test.go index 93776677..8e111e99 100644 --- a/internal/output/formatter_extra_test.go +++ b/internal/output/formatter_extra_test.go @@ -22,6 +22,21 @@ func TestWriteJSON_Map(t *testing.T) { } } +func TestWriteJSON_DoesNotEscapeAmpersands(t *testing.T) { + t.Parallel() + var buf bytes.Buffer + url := "https://open-dev.dingtalk.com/fe/old#/personalAuthorization?flowId=flow&userCode=CODE" + if err := WriteJSON(&buf, map[string]any{"authorizationUrl": url}); err != nil { + t.Fatalf("unexpected error: %v", err) + } + if strings.Contains(buf.String(), `\u0026`) { + t.Fatalf("WriteJSON escaped ampersand: %s", buf.String()) + } + if !strings.Contains(buf.String(), "&userCode=CODE") { + t.Fatalf("WriteJSON output missing literal ampersand: %s", buf.String()) + } +} + func TestWriteJSON_Nil(t *testing.T) { t.Parallel() var buf bytes.Buffer @@ -150,6 +165,20 @@ func TestWriteRaw_NonString(t *testing.T) { } } +func TestWriteRaw_DoesNotEscapeAmpersands(t *testing.T) { + t.Parallel() + var buf bytes.Buffer + if err := writeRaw(&buf, map[string]any{"url": "https://example.test/auth?a=1&b=2"}); err != nil { + t.Fatalf("unexpected error: %v", err) + } + if strings.Contains(buf.String(), `\u0026`) { + t.Fatalf("writeRaw escaped ampersand: %s", buf.String()) + } + if !strings.Contains(buf.String(), "a=1&b=2") { + t.Fatalf("writeRaw output missing literal ampersand: %s", buf.String()) + } +} + func TestWriteTableish_Map(t *testing.T) { t.Parallel() var buf bytes.Buffer diff --git a/internal/output/ndjson.go b/internal/output/ndjson.go index d86eb445..909f0089 100644 --- a/internal/output/ndjson.go +++ b/internal/output/ndjson.go @@ -41,6 +41,7 @@ func writeNDJSON(w io.Writer, payload any) error { bw := bufio.NewWriter(w) enc := json.NewEncoder(bw) + enc.SetEscapeHTML(false) // json.Encoder.Encode already appends a trailing newline per call. switch v := normalized.(type) { diff --git a/internal/output/ndjson_csv_test.go b/internal/output/ndjson_csv_test.go index a44bd138..b2aefd71 100644 --- a/internal/output/ndjson_csv_test.go +++ b/internal/output/ndjson_csv_test.go @@ -49,6 +49,11 @@ func TestWriteNDJSON(t *testing.T) { payload: map[string]any{"ok": true}, wantLines: []string{`{"ok":true}`}, }, + { + name: "url keeps ampersand", + payload: map[string]any{"url": "https://example.test/auth?a=1&b=2"}, + wantLines: []string{`{"url":"https://example.test/auth?a=1&b=2"}`}, + }, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { diff --git a/internal/output/pretty.go b/internal/output/pretty.go index e9f02b70..ff2fcd6c 100644 --- a/internal/output/pretty.go +++ b/internal/output/pretty.go @@ -20,7 +20,7 @@ import ( "sort" "strings" - "github.com/fatih/color" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/tui" ) // writePretty renders the payload as ANSI-colored, human-readable text. @@ -45,26 +45,19 @@ func writePretty(w io.Writer, payload any) error { // the single-tool shape (has `tool`). Colours are on by default via fatih/color, // auto-disabled when stdout is not a TTY or when NO_COLOR is set. func writeSchemaPretty(w io.Writer, payload map[string]any) error { - bold := color.New(color.Bold).SprintFunc() - cyan := color.New(color.FgCyan).SprintFunc() - green := color.New(color.FgGreen).SprintFunc() - yellow := color.New(color.FgYellow).SprintFunc() - red := color.New(color.FgRed, color.Bold).SprintFunc() - dim := color.New(color.Faint).SprintFunc() - if tool, ok := payload["tool"].(map[string]any); ok { - return writeSchemaToolPretty(w, payload, tool, bold, cyan, green, yellow, red, dim) + return writeSchemaToolPretty(w, payload, tool) } if products, ok := payload["products"].([]any); ok { - return writeSchemaListPretty(w, payload, products, bold, cyan, dim) + return writeSchemaListPretty(w, payload, products) } if degraded, _ := payload["degraded"].(bool); degraded { reason, _ := payload["reason"].(string) hint, _ := payload["hint"].(string) - fmt.Fprintf(w, "%s discovery degraded: %s\n", red("!"), yellow(reason)) + fmt.Fprintf(w, "%s discovery degraded: %s\n", tui.StateMark("warning"), tui.Warning(reason)) if hint != "" { - fmt.Fprintf(w, " %s %s\n", dim("hint:"), hint) + fmt.Fprintf(w, " %s %s\n", tui.Key("hint"), hint) } return nil } @@ -75,13 +68,13 @@ func writeSchemaListPretty( w io.Writer, payload map[string]any, products []any, - bold, cyan, dim func(...any) string, ) error { count, _ := payload["count"].(float64) if count == 0 { count = float64(len(products)) } - fmt.Fprintf(w, "%s %s products discovered\n", bold("Catalog"), cyan(fmt.Sprintf("%d", int(count)))) + fmt.Fprintf(w, "%s\n", tui.Header("Catalog", fmt.Sprintf("%d products discovered", int(count)))) + fmt.Fprintf(w, "%s\n", tui.Rule(64)) for _, raw := range products { p, ok := raw.(map[string]any) @@ -92,11 +85,11 @@ func writeSchemaListPretty( name, _ := p["name"].(string) desc, _ := p["description"].(string) tools, _ := p["tools"].([]any) - fmt.Fprintf(w, "\n%s %s %s\n", bold("▸"), bold(id), dim(name)) + fmt.Fprintf(w, "\n%s %s %s\n", tui.StateMark("ok"), tui.Bold(id), tui.Dim(name)) if desc != "" && desc != name { - fmt.Fprintf(w, " %s\n", dim(desc)) + fmt.Fprintf(w, " %s\n", tui.Dim(desc)) } - fmt.Fprintf(w, " %s %s\n", dim("tools:"), cyan(fmt.Sprintf("%d", len(tools)))) + fmt.Fprintf(w, " %s %s\n", tui.Key("tools"), tui.Cyan(fmt.Sprintf("%d", len(tools)))) shown := 0 for _, t := range tools { tm, ok := t.(map[string]any) @@ -106,13 +99,13 @@ func writeSchemaListPretty( rpc, _ := tm["name"].(string) cli, _ := tm["cli_name"].(string) if cli == "" || cli == rpc { - fmt.Fprintf(w, " - %s\n", rpc) + fmt.Fprintf(w, " %s %s\n", tui.Bullet(), rpc) } else { - fmt.Fprintf(w, " - %s %s\n", rpc, dim("→ "+cli)) + fmt.Fprintf(w, " %s %s %s %s\n", tui.Bullet(), rpc, tui.Arrow(), tui.Dim(cli)) } shown++ if shown >= 6 && len(tools) > 8 { - fmt.Fprintf(w, " %s\n", dim(fmt.Sprintf("… %d more", len(tools)-shown))) + fmt.Fprintf(w, " %s\n", tui.Dim(fmt.Sprintf("… %d more", len(tools)-shown))) break } } @@ -124,7 +117,6 @@ func writeSchemaToolPretty( w io.Writer, payload map[string]any, tool map[string]any, - bold, cyan, green, yellow, red, dim func(...any) string, ) error { rpc, _ := tool["name"].(string) cli, _ := tool["cli_name"].(string) @@ -135,19 +127,20 @@ func writeSchemaToolPretty( header := rpc if title != "" && title != rpc { - header = fmt.Sprintf("%s %s", rpc, dim(title)) + header = fmt.Sprintf("%s %s", rpc, tui.Dim(title)) } - fmt.Fprintf(w, "%s %s\n", bold("Tool"), header) + fmt.Fprintf(w, "%s\n", tui.Header("Tool "+header, "schema")) + fmt.Fprintf(w, "%s\n", tui.Rule(72)) var productID string if product, ok := payload["product"].(map[string]any); ok { pid, _ := product["id"].(string) pname, _ := product["name"].(string) productID = pid - fmt.Fprintf(w, " %s %s %s\n", dim("product:"), pid, dim(pname)) + fmt.Fprintf(w, " %s %s %s\n", tui.Key("product"), tui.Cyan(pid), tui.Dim(pname)) } if canonical != "" { - fmt.Fprintf(w, " %s %s\n", dim("canonical:"), canonical) + fmt.Fprintf(w, " %s %s\n", tui.Key("canonical"), canonical) } if cli != "" { parts := []string{} @@ -158,26 +151,26 @@ func writeSchemaToolPretty( parts = append(parts, strings.Split(group, ".")...) } parts = append(parts, cli) - fmt.Fprintf(w, " %s %s\n", dim("cli path:"), cyan(strings.Join(parts, " "))) + fmt.Fprintf(w, " %s %s\n", tui.Key("cli path"), tui.Cyan(strings.Join(parts, " "))) } // Sensitivity + annotations in one line. sensitive, _ := tool["sensitive"].(bool) if sensitive { - fmt.Fprintf(w, " %s %s\n", dim("sensitive:"), red("yes (needs --yes)")) + fmt.Fprintf(w, " %s %s\n", tui.Key("sensitive"), tui.Danger("yes (needs --yes)")) } if ann, ok := tool["annotations"].(map[string]any); ok && len(ann) > 0 { var parts []string for _, key := range sortedMapKeys(ann) { parts = append(parts, fmt.Sprintf("%s=%v", key, ann[key])) } - fmt.Fprintf(w, " %s %s\n", dim("annotations:"), yellow(strings.Join(parts, " "))) + fmt.Fprintf(w, " %s %s\n", tui.Key("annotations"), tui.Warning(strings.Join(parts, " "))) } if desc != "" && desc != title { fmt.Fprintln(w) for _, line := range strings.Split(strings.TrimSpace(desc), "\n") { - fmt.Fprintf(w, " %s\n", dim(line)) + fmt.Fprintf(w, " %s\n", tui.Dim(line)) } } @@ -200,18 +193,17 @@ func writeSchemaToolPretty( } } fmt.Fprintln(w) - fmt.Fprintf(w, "%s\n", bold("Parameters")) + fmt.Fprintf(w, "%s\n", tui.Section("Parameters")) for _, name := range sortedMapKeys(params) { prop, _ := params[name].(map[string]any) - writeParamPretty(w, name, prop, required[name], overlay[name], - bold, cyan, green, yellow, red, dim) + writeParamPretty(w, name, prop, required[name], overlay[name]) } } // Output schema, if any. if out, ok := tool["output_schema"].(map[string]any); ok && len(out) > 0 { fmt.Fprintln(w) - fmt.Fprintf(w, "%s\n", bold("Output schema")) + fmt.Fprintf(w, "%s\n", tui.Section("Output schema")) b, _ := json.MarshalIndent(out, " ", " ") fmt.Fprintf(w, " %s\n", string(b)) } @@ -225,25 +217,24 @@ func writeParamPretty( prop map[string]any, required bool, overlay map[string]any, - bold, cyan, green, yellow, red, dim func(...any) string, ) { typeStr := describeType(prop) marker := " " if required { - marker = red("*") + marker = tui.Danger("*") } alias, _ := overlay["alias"].(string) - line := fmt.Sprintf(" %s %s", marker, bold(name)) + line := fmt.Sprintf(" %s %s", marker, tui.Bold(name)) if alias != "" && alias != name { - line += fmt.Sprintf(" %s", cyan("--"+alias)) + line += fmt.Sprintf(" %s", tui.Cyan("--"+alias)) } - line += fmt.Sprintf(" %s", dim(typeStr)) + line += fmt.Sprintf(" %s", tui.Dim(typeStr)) fmt.Fprintln(w, line) if d, _ := prop["description"].(string); d != "" { for _, ln := range strings.Split(strings.TrimSpace(d), "\n") { - fmt.Fprintf(w, " %s\n", dim(ln)) + fmt.Fprintf(w, " %s\n", tui.Dim(ln)) } } @@ -253,7 +244,7 @@ func writeParamPretty( for _, v := range enum { vals = append(vals, fmt.Sprintf("%v", v)) } - fmt.Fprintf(w, " %s %s\n", dim("enum:"), green(strings.Join(vals, ", "))) + fmt.Fprintf(w, " %s %s\n", tui.Key("enum"), tui.Success(strings.Join(vals, ", "))) } // overlay — transform / env default / default. @@ -266,13 +257,13 @@ func writeParamPretty( } extras += "(" + strings.Join(kvs, ", ") + ")" } - fmt.Fprintf(w, " %s %s\n", dim("transform:"), yellow(extras)) + fmt.Fprintf(w, " %s %s\n", tui.Key("transform"), tui.Warning(extras)) } if env, _ := overlay["env_default"].(string); env != "" { - fmt.Fprintf(w, " %s %s\n", dim("env default:"), green("$"+env)) + fmt.Fprintf(w, " %s %s\n", tui.Key("env default"), tui.Success("$"+env)) } if def, _ := overlay["default"].(string); def != "" { - fmt.Fprintf(w, " %s %s\n", dim("default:"), green(def)) + fmt.Fprintf(w, " %s %s\n", tui.Key("default"), tui.Success(def)) } } diff --git a/internal/pat/chmod.go b/internal/pat/chmod.go index b4f4209b..30c03f35 100644 --- a/internal/pat/chmod.go +++ b/internal/pat/chmod.go @@ -18,9 +18,11 @@ import ( "encoding/json" stderrors "errors" "fmt" + "io" "os" "regexp" "strings" + "time" "github.com/fatih/color" "github.com/spf13/cobra" @@ -30,6 +32,46 @@ import ( "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition" ) +type LoginRecommendOptions struct { + // ProductCodes limits the recommend plan to service-owned product domains. + ProductCodes []string + // Confirmed grants the server-selected recommend scopes directly. When + // false, recommend authorization starts the batch confirmation page. + Confirmed bool + // ScopeMode controls whether the plan uses the safe recommended set or + // all scopes under the selected product domains. Empty means recommended. + ScopeMode LoginRecommendScopeMode + // ProductSelector lets interactive callers present the service-owned domain + // list before the final plan. It receives products extracted from the + // initial recommend plan and must return the product codes to grant. + ProductSelector func([]LoginRecommendProduct) ([]string, error) + // InitialPlan lets callers preflight the default recommend plan before any + // interactive selector and then reuse the same dry-run result. + InitialPlan *LoginRecommendPlan +} + +type LoginRecommendScopeMode string + +const ( + LoginRecommendScopeRecommended LoginRecommendScopeMode = "recommend" + LoginRecommendScopeAll LoginRecommendScopeMode = "all" +) + +type LoginRecommendProduct struct { + ProductCode string + ProductName string + Summary string + ScopeCount int + SelectedScopeCount int +} + +type LoginRecommendPlan struct { + Result *edition.ToolResult + Scopes []string + Products []LoginRecommendProduct + AllGranted bool +} + const ( sessionIDEnvDingtalk = "DINGTALK_SESSION_ID" sessionIDEnvDWS = "DWS_SESSION_ID" @@ -160,6 +202,8 @@ const ( patBatchUnsupportedCodeLower = "pat_batch_auth_unsupported" patForgedIdentityCode = "PAT_FORGED_IDENTITY_FIELD" patForgedIdentityCodeLower = "pat_forged_identity_field" + grantTypePermanent = "permanent" + patCallerAuthLoginRecommend = "auth_login_recommend" ) var patBatchMetadataContractCodes = map[string]bool{ @@ -264,6 +308,9 @@ agentCode 配置: if err != nil { return fmt.Errorf("pat chmod plan failed: %w", err) } + if err := ensurePATResultAgentCode(result, agentCode); err != nil { + return err + } return handleToolResult(cmd, c, result) } bold := color.New(color.FgYellow, color.Bold) @@ -290,6 +337,9 @@ agentCode 配置: if err != nil { return fmt.Errorf("pat chmod plan failed: %w", err) } + if err := ensurePATResultAgentCode(planResult, agentCode); err != nil { + return err + } scopes, err = extractSelectedScopes(planResult) if err != nil { return err @@ -344,6 +394,9 @@ agentCode 配置: if err != nil { return fmt.Errorf("pat chmod failed: %w", err) } + if err := ensurePATResultAgentCode(result, agentCode); err != nil { + return err + } return handleToolResult(cmd, c, result) }, @@ -477,9 +530,16 @@ func callPATBatchToolWithIdentityFallback(ctx context.Context, c edition.ToolCal return result, err } compatArgs := cloneWithoutPATIdentityArgs(args) - return withPATContextEnv(agentCode, sessionID, func() (*edition.ToolResult, error) { + result, err = withPATContextEnv(agentCode, sessionID, func() (*edition.ToolResult, error) { return c.CallTool(ctx, "pat", toolName, compatArgs) }) + if err != nil { + return result, err + } + if err := ensurePATIdentityFallbackAgentCode(result, agentCode); err != nil { + return result, err + } + return result, nil } func buildBatchPlanArgs(scopes []string, productCodes []string, recommend bool, grantType string, agentCode string, sessionID string, dryRun bool) map[string]any { @@ -499,6 +559,306 @@ func buildBatchPlanArgs(scopes []string, productCodes []string, recommend bool, return args } +// RunLoginRecommendAuthorization plans the server-owned recommended scope set +// after a successful dws auth login, then starts a batch Device Code Flow for +// the server-selected grantable scopes. The actual browser open, polling, and +// retry are handled by the shared PAT runner once PAT_BATCH_AUTH_PENDING is +// classified as a PAT auth check. +func RunLoginRecommendAuthorization(ctx context.Context, c edition.ToolCaller, output io.Writer) error { + return RunLoginRecommendAuthorizationWithOptions(ctx, c, output, LoginRecommendOptions{}) +} + +func PlanLoginRecommendAuthorization(ctx context.Context, c edition.ToolCaller) (*LoginRecommendPlan, error) { + if c == nil { + return nil, fmt.Errorf("internal error: tool runtime not initialized") + } + planResult, scopes, allGranted, err := planLoginRecommend(ctx, c, nil, true) + if err != nil { + return nil, err + } + products, err := extractLoginRecommendProducts(planResult) + if err != nil { + return nil, err + } + return &LoginRecommendPlan{ + Result: planResult, + Scopes: scopes, + Products: products, + AllGranted: allGranted, + }, nil +} + +func RunLoginRecommendAuthorizationWithOptions(ctx context.Context, c edition.ToolCaller, output io.Writer, opts LoginRecommendOptions) error { + if c == nil { + return fmt.Errorf("internal error: tool runtime not initialized") + } + recommendPlan := opts.ScopeMode != LoginRecommendScopeAll + productCodes := normalizeProductCodes(opts.ProductCodes) + initialRecommendPlan := recommendPlan + if opts.ProductSelector != nil && len(productCodes) == 0 { + // The first plan only discovers the service-owned product-domain list + // for the TUI. The server contract rejects an empty all-scope request + // (recommend=false without productCodes), so all-scope mode is applied + // only after the user selects concrete productCodes. + initialRecommendPlan = true + } + var planResult *edition.ToolResult + var scopes []string + var allGranted bool + if opts.InitialPlan != nil && initialRecommendPlan && len(productCodes) == 0 { + planResult = opts.InitialPlan.Result + scopes = append([]string(nil), opts.InitialPlan.Scopes...) + allGranted = opts.InitialPlan.AllGranted + } else { + var err error + planResult, scopes, allGranted, err = planLoginRecommend(ctx, c, productCodes, initialRecommendPlan) + if err != nil { + return err + } + } + if recommendPlan && (allGranted || len(scopes) == 0) { + if output != nil { + _, _ = fmt.Fprintln(output, "推荐权限已全部授权或没有可授权项") + } + return nil + } + if opts.ProductSelector != nil { + products, err := extractLoginRecommendProducts(planResult) + if err != nil { + return err + } + if len(products) > 0 { + selectedProducts, err := opts.ProductSelector(products) + if err != nil { + return err + } + productCodes = normalizeProductCodes(selectedProducts) + if len(productCodes) == 0 { + return fmt.Errorf("至少选择一个授权业务域") + } + _, scopes, allGranted, err = planLoginRecommend(ctx, c, productCodes, recommendPlan) + if err != nil { + return err + } + } + if len(products) == 0 && !recommendPlan { + return fmt.Errorf("全部授权需要服务端返回可选授权业务域") + } + } + if allGranted || len(scopes) == 0 { + if output != nil { + _, _ = fmt.Fprintln(output, "推荐权限已全部授权或没有可授权项") + } + return nil + } + grantArgs := map[string]any{ + "scopes": scopes, + "grantType": grantTypePermanent, + "caller": patCallerAuthLoginRecommend, + "clientRequestId": newBatchClientRequestID("auth-login-recommend"), + } + if !opts.Confirmed { + grantArgs["startFlow"] = true + grantArgs["noWait"] = true + } + result, err := callPATBatchToolWithIdentityFallback(ctx, c, "", "", patBatchGrantToolName, grantArgs) + if err != nil { + return fmt.Errorf("pat login recommend grant failed: %w", err) + } + return handleToolResultForWriter(output, result, c) +} + +func planLoginRecommend(ctx context.Context, c edition.ToolCaller, productCodes []string, recommend bool) (*edition.ToolResult, []string, bool, error) { + productCodes = normalizeProductCodes(productCodes) + if !recommend && len(productCodes) == 0 { + return nil, nil, false, fmt.Errorf("全部授权需要至少一个授权业务域") + } + planArgs := buildBatchPlanArgs(nil, productCodes, recommend, grantTypePermanent, "", "", true) + planArgs["caller"] = patCallerAuthLoginRecommend + planResult, err := callPATBatchPlan(ctx, c, "", "", planArgs) + if err != nil { + return nil, nil, false, fmt.Errorf("pat login recommend plan failed: %w", err) + } + if err := classifyToolResultText(planResult); err != nil { + return planResult, nil, false, err + } + scopes, err := extractSelectedScopesAllowEmpty(planResult) + if err != nil { + return planResult, nil, false, err + } + allGranted, err := extractBatchPlanAllGranted(planResult) + if err != nil { + return planResult, nil, false, err + } + return planResult, scopes, allGranted, nil +} + +func newBatchClientRequestID(prefix string) string { + prefix = strings.TrimSpace(prefix) + if prefix == "" { + prefix = "batch" + } + return fmt.Sprintf("%s-%d", prefix, time.Now().UnixNano()) +} + +func extractLoginRecommendProducts(result *edition.ToolResult) ([]LoginRecommendProduct, error) { + text := firstToolResultText(result) + if text == "" { + return nil, fmt.Errorf("empty PAT batch plan result") + } + var body map[string]any + if err := json.Unmarshal([]byte(text), &body); err != nil { + return nil, fmt.Errorf("parsing PAT batch plan result: %w", err) + } + data, _ := body["data"].(map[string]any) + if data == nil { + return nil, fmt.Errorf("PAT batch plan result missing data.items") + } + selectedSet := map[string]bool{} + if rawSelected, _ := data["selectedScopes"].([]any); len(rawSelected) > 0 { + for _, raw := range rawSelected { + if scope, ok := raw.(string); ok && strings.TrimSpace(scope) != "" { + selectedSet[strings.TrimSpace(scope)] = true + } + } + } + + rawItems, _ := data["items"].([]any) + type productGroup struct { + item LoginRecommendProduct + summarySeen map[string]bool + summaryParts []string + } + groups := map[string]*productGroup{} + order := make([]string, 0) + for _, raw := range rawItems { + item, _ := raw.(map[string]any) + if item == nil { + continue + } + scope := stringField(item, "scope") + code := stringField(item, "productCode") + if code == "" { + code = productCodeFromScope(scope) + } + if code == "" { + continue + } + group := groups[code] + if group == nil { + group = &productGroup{ + item: LoginRecommendProduct{ + ProductCode: code, + ProductName: stringField(item, "productName"), + }, + summarySeen: map[string]bool{}, + } + if group.item.ProductName == "" { + group.item.ProductName = code + } + groups[code] = group + order = append(order, code) + } + group.item.ScopeCount++ + if selectedSet[scope] { + group.item.SelectedScopeCount++ + } + summary := stringField(item, "operationSummary") + if summary == "" { + summary = stringField(item, "displayName") + } + if summary != "" && !group.summarySeen[summary] && len(group.summaryParts) < 3 { + group.summarySeen[summary] = true + group.summaryParts = append(group.summaryParts, summary) + } + } + + if len(order) == 0 { + scopes, err := extractSelectedScopesAllowEmpty(result) + if err != nil { + return nil, err + } + for _, scope := range scopes { + code := productCodeFromScope(scope) + if code == "" { + continue + } + if groups[code] == nil { + groups[code] = &productGroup{ + item: LoginRecommendProduct{ + ProductCode: code, + ProductName: code, + ScopeCount: 0, + SelectedScopeCount: 0, + }, + } + order = append(order, code) + } + groups[code].item.ScopeCount++ + groups[code].item.SelectedScopeCount++ + } + } + + products := make([]LoginRecommendProduct, 0, len(order)) + for _, code := range order { + group := groups[code] + if group == nil { + continue + } + group.item.Summary = strings.Join(group.summaryParts, "、") + products = append(products, group.item) + } + return products, nil +} + +func normalizeProductCodes(codes []string) []string { + seen := map[string]bool{} + out := make([]string, 0, len(codes)) + for _, raw := range codes { + for _, part := range strings.Split(raw, ",") { + code := strings.TrimSpace(part) + if code == "" || seen[code] { + continue + } + seen[code] = true + out = append(out, code) + } + } + return out +} + +func productCodeFromScope(scope string) string { + scope = strings.TrimSpace(scope) + if scope == "" { + return "" + } + end := len(scope) + if idx := strings.Index(scope, "."); idx >= 0 && idx < end { + end = idx + } + if idx := strings.Index(scope, ":"); idx >= 0 && idx < end { + end = idx + } + return strings.TrimSpace(scope[:end]) +} + +func extractBatchPlanAllGranted(result *edition.ToolResult) (bool, error) { + text := firstToolResultText(result) + if text == "" { + return false, fmt.Errorf("empty PAT batch plan result") + } + var body map[string]any + if err := json.Unmarshal([]byte(text), &body); err != nil { + return false, fmt.Errorf("parsing PAT batch plan result: %w", err) + } + data, _ := body["data"].(map[string]any) + if data == nil { + return false, fmt.Errorf("PAT batch plan result missing data") + } + allGranted, _ := data["allGranted"].(bool) + return allGranted, nil +} + func extractSelectedScopes(result *edition.ToolResult) ([]string, error) { text := firstToolResultText(result) if text == "" { @@ -535,6 +895,30 @@ func extractSelectedScopes(result *edition.ToolResult) ([]string, error) { return scopes, nil } +func extractSelectedScopesAllowEmpty(result *edition.ToolResult) ([]string, error) { + text := firstToolResultText(result) + if text == "" { + return nil, fmt.Errorf("empty PAT batch plan result") + } + var body map[string]any + if err := json.Unmarshal([]byte(text), &body); err != nil { + return nil, fmt.Errorf("parsing PAT batch plan result: %w", err) + } + data, _ := body["data"].(map[string]any) + if data == nil { + return nil, fmt.Errorf("PAT batch plan result missing data.selectedScopes") + } + rawScopes, _ := data["selectedScopes"].([]any) + scopes := make([]string, 0, len(rawScopes)) + for _, raw := range rawScopes { + scope, ok := raw.(string) + if ok && strings.TrimSpace(scope) != "" { + scopes = append(scopes, scope) + } + } + return scopes, nil +} + func firstToolResultText(result *edition.ToolResult) string { if result == nil { return "" @@ -547,6 +931,77 @@ func firstToolResultText(result *edition.ToolResult) string { return "" } +func ensurePATResultAgentCode(result *edition.ToolResult, expectedAgentCode string) error { + expectedAgentCode = strings.TrimSpace(expectedAgentCode) + if expectedAgentCode == "" { + return nil + } + actualAgentCode := patResultAgentCode(result) + if actualAgentCode == "" || actualAgentCode == expectedAgentCode { + return nil + } + return fmt.Errorf( + "pat chmod returned agentCode %q, want %q from --agentCode/%s; authorization was not applied to the requested agent", + actualAgentCode, + expectedAgentCode, + agentCodeEnv, + ) +} + +func ensurePATIdentityFallbackAgentCode(result *edition.ToolResult, expectedAgentCode string) error { + expectedAgentCode = strings.TrimSpace(expectedAgentCode) + if expectedAgentCode == "" { + return nil + } + actualAgentCode := patResultAgentCode(result) + if actualAgentCode == expectedAgentCode { + return nil + } + if actualAgentCode == "" { + return fmt.Errorf( + "pat chmod identity fallback did not return agentCode %q; authorization target cannot be verified", + expectedAgentCode, + ) + } + return fmt.Errorf( + "pat chmod identity fallback returned agentCode %q, want %q from --agentCode/%s; authorization was not applied to the requested agent", + actualAgentCode, + expectedAgentCode, + agentCodeEnv, + ) +} + +func patResultAgentCode(result *edition.ToolResult) string { + text := firstToolResultText(result) + if text == "" { + return "" + } + var body map[string]any + if json.Unmarshal([]byte(text), &body) != nil { + return "" + } + if code := stringField(body, "agentCode"); code != "" { + return code + } + data, _ := body["data"].(map[string]any) + if data != nil { + if code := stringField(data, "agentCode"); code != "" { + return code + } + } + resultBody, _ := body["result"].(map[string]any) + if resultBody != nil { + if code := stringField(resultBody, "agentCode"); code != "" { + return code + } + resultData, _ := resultBody["data"].(map[string]any) + if resultData != nil { + return stringField(resultData, "agentCode") + } + } + return "" +} + func isPATBatchUnsupportedResult(result *edition.ToolResult) bool { return patBatchResultHasCode(result, func(code string) bool { return strings.EqualFold(code, patBatchUnsupportedCode) @@ -813,6 +1268,44 @@ func handleToolResult(cmd *cobra.Command, caller edition.ToolCaller, result *edi return fmt.Errorf("empty PAT authorization result: %s", string(data)) } +func handleToolResultForWriter(w io.Writer, result *edition.ToolResult, caller edition.ToolCaller) error { + if result == nil { + return fmt.Errorf("empty tool result") + } + for _, c := range result.Content { + if c.Type != "text" || c.Text == "" { + continue + } + if respErr := apperrors.ClassifyMCPResponseText(c.Text); respErr != nil { + return respErr + } + if w == nil { + return nil + } + if summary := formatPATAuthorizationSummary(c.Text, caller); summary != "" { + _, _ = fmt.Fprint(w, summary) + return nil + } + _, _ = fmt.Fprintln(w, c.Text) + return nil + } + data, _ := json.Marshal(result) + return fmt.Errorf("empty PAT authorization result: %s", string(data)) +} + +func classifyToolResultText(result *edition.ToolResult) error { + if result == nil { + return fmt.Errorf("empty tool result") + } + for _, c := range result.Content { + if c.Type != "text" || c.Text == "" { + continue + } + return apperrors.ClassifyMCPResponseText(c.Text) + } + return nil +} + func shouldEmitRawPATResult(cmd *cobra.Command) bool { if commandBoolFlag(cmd, "verbose") { return true diff --git a/internal/pat/chmod_test.go b/internal/pat/chmod_test.go index 31f21082..faf631c5 100644 --- a/internal/pat/chmod_test.go +++ b/internal/pat/chmod_test.go @@ -14,6 +14,7 @@ package pat import ( + "bytes" "context" "errors" "io" @@ -298,6 +299,11 @@ func attachRootPATFlags(t *testing.T, cmd *cobra.Command, yes bool, formatChange } } +func setBatchYesForTest(t *testing.T, cmd *cobra.Command) { + t.Helper() + attachRootYesFlag(t, cmd, true) +} + func TestRegisterCommands_OnlyExposesChmodForAuthorization(t *testing.T) { root := &cobra.Command{Use: "dws"} RegisterCommands(root, &fakeToolCaller{}) @@ -367,8 +373,8 @@ func TestPATHelpDocumentsBatchAuthorization(t *testing.T) { "执行批量授权必须显式", "由服务端默认兜底", "aitable.record:read aitable.record:write --grant-type permanent --yes", - "dws pat chmod --product calendar --product aitable", - "dws pat chmod --domain calendar --domain chat", + "dws pat chmod --products calendar,aitable", + "dws pat chmod --recommend --grant-type session", } { if !strings.Contains(chmodHelp, want) { t.Fatalf("pat chmod help missing %q\nhelp:\n%s", want, chmodHelp) @@ -385,7 +391,7 @@ func TestChmod_productsFlagPlansThenGrantsSelectedScopes(t *testing.T) { cmd := newChmodCommand(fake) _ = cmd.Flags().Set("grant-type", "once") _ = cmd.Flags().Set("products", "calendar,aitable") - attachRootYesFlag(t, cmd, true) + setBatchYesForTest(t, cmd) if err := cmd.RunE(cmd, nil); err != nil { t.Fatalf("chmod RunE error = %v", err) @@ -423,14 +429,14 @@ func TestChmod_productsFlagPlansThenGrantsSelectedScopes(t *testing.T) { } } -func TestChmod_productsFlagBlocksGrantWithoutYes(t *testing.T) { +func TestChmod_productsFlagWithoutYesBlocksAfterPlan(t *testing.T) { t.Setenv(agentCodeEnv, "qoderwork") fake := &sequenceToolCaller{responses: []string{ - `{"success":true,"data":{"selectedScopes":["calendar.event:read","aitable.record:read"]}}`, + `{"success":true,"data":{"selectedScopes":["calendar.event:read"]}}`, }} cmd := newChmodCommand(fake) _ = cmd.Flags().Set("grant-type", "once") - _ = cmd.Flags().Set("products", "calendar,aitable") + _ = cmd.Flags().Set("products", "calendar") err := cmd.RunE(cmd, nil) if err == nil { @@ -443,24 +449,25 @@ func TestChmod_productsFlagBlocksGrantWithoutYes(t *testing.T) { t.Fatalf("CallTool count = %d, want plan only", len(fake.calls)) } if fake.calls[0].tool != patBatchPlanToolName { - t.Fatalf("first tool = %q, want %q", fake.calls[0].tool, patBatchPlanToolName) + t.Fatalf("tool = %q, want %q", fake.calls[0].tool, patBatchPlanToolName) } } -func TestChmod_multipleExplicitScopesBlockWithoutYes(t *testing.T) { - fake := &fakeToolCaller{resultOK: true} +func TestChmod_multipleScopesWithoutYesBlocksBeforeMCP(t *testing.T) { + t.Setenv(agentCodeEnv, "qoderwork") + fake := &sequenceToolCaller{} cmd := newChmodCommand(fake) - _ = cmd.Flags().Set("grant-type", "once") + _ = cmd.Flags().Set("grant-type", "permanent") - err := cmd.RunE(cmd, []string{"aitable.record:read", "aitable.record:write"}) + err := cmd.RunE(cmd, []string{"calendar.event:read", "aitable.record:write"}) if err == nil { t.Fatal("chmod RunE error = nil, want batch --yes blocker") } if !strings.Contains(err.Error(), "--yes") || !strings.Contains(err.Error(), "batch PAT authorization blocked") { t.Fatalf("error = %q, want explicit batch --yes blocker", err.Error()) } - if fake.callN != 0 { - t.Fatalf("CallTool was invoked %d times; batch without --yes must not grant", fake.callN) + if len(fake.calls) != 0 { + t.Fatalf("CallTool count = %d, want blocker before MCP", len(fake.calls)) } } @@ -473,7 +480,7 @@ func TestChmod_productsSessionModePassesIdentityArgsAndCompatEnv(t *testing.T) { cmd := newChmodCommand(fake) _ = cmd.Flags().Set("products", "calendar") _ = cmd.Flags().Set("session-id", "session-123") - attachRootYesFlag(t, cmd, true) + setBatchYesForTest(t, cmd) if err := cmd.RunE(cmd, nil); err != nil { t.Fatalf("chmod RunE error = %v", err) @@ -769,75 +776,6 @@ func TestChmod_batchPlanEntryPointsDryRunOnlyReturnPlanAgentCode(t *testing.T) { } } -func TestChmod_batchEntryPointsWithoutYesAreBlocked(t *testing.T) { - cases := []struct { - name string - args []string - setFlags func(*cobra.Command) - wantPlan bool - }{ - { - name: "direct multi scope", - args: []string{"calendar.event:list", "calendar.event:create"}, - setFlags: func(cmd *cobra.Command) { - _ = cmd.Flags().Set("grant-type", "once") - }, - }, - { - name: "product", - setFlags: func(cmd *cobra.Command) { - _ = cmd.Flags().Set("grant-type", "once") - _ = cmd.Flags().Set("products", "calendar") - }, - wantPlan: true, - }, - { - name: "domain", - setFlags: func(cmd *cobra.Command) { - _ = cmd.Flags().Set("grant-type", "once") - _ = cmd.Flags().Set("domains", "calendar") - }, - wantPlan: true, - }, - { - name: "recommend", - setFlags: func(cmd *cobra.Command) { - _ = cmd.Flags().Set("grant-type", "once") - _ = cmd.Flags().Set("recommend", "true") - }, - wantPlan: true, - }, - } - - for _, tc := range cases { - t.Run(tc.name, func(t *testing.T) { - t.Setenv(agentCodeEnv, "qoderwork") - fake := &sequenceToolCaller{responses: []string{ - `{"success":true,"data":{"selectedScopes":["calendar.event:list","calendar.event:create"]}}`, - }} - cmd := newChmodCommand(fake) - tc.setFlags(cmd) - - err := cmd.RunE(cmd, tc.args) - if err == nil { - t.Fatal("chmod RunE error = nil, want batch --yes blocker") - } - if !strings.Contains(err.Error(), "--yes") || !strings.Contains(err.Error(), "batch PAT authorization blocked") { - t.Fatalf("error = %q, want explicit batch --yes blocker", err.Error()) - } - if tc.wantPlan { - if len(fake.calls) != 1 || fake.calls[0].tool != patBatchPlanToolName { - t.Fatalf("calls = %#v, want one plan call before blocker", fake.calls) - } - return - } - if len(fake.calls) != 0 { - t.Fatalf("CallTool count = %d, want no MCP calls for direct multi-scope blocker", len(fake.calls)) - } - }) - } -} - func TestChmod_grantTypeAndSessionParameterMatrix(t *testing.T) { cases := []struct { name string @@ -955,12 +893,13 @@ func TestChmod_batchPlanRetriesWithoutIdentityArgsForCompat(t *testing.T) { }, responses: []string{ "", - `{"success":true,"data":{"allGranted":true,"selectedScopes":[]}}`, + `{"success":true,"data":{"agentCode":"qoderwork","allGranted":true,"selectedScopes":[]}}`, }, } cmd := newChmodCommand(fake) _ = cmd.Flags().Set("grant-type", "once") _ = cmd.Flags().Set("products", "calendar") + setBatchYesForTest(t, cmd) if err := cmd.RunE(cmd, nil); err != nil { t.Fatalf("chmod RunE error = %v", err) @@ -996,7 +935,7 @@ func TestChmod_batchGrantRetriesWithoutIdentityArgsForCompat(t *testing.T) { }, responses: []string{ "", - `{"success":true,"data":{"grantedScopes":["calendar.event:read"]}}`, + `{"success":true,"data":{"agentCode":"qoderwork","grantedScopes":["calendar.event:read"]}}`, }, } cmd := newChmodCommand(fake) @@ -1022,6 +961,75 @@ func TestChmod_batchGrantRetriesWithoutIdentityArgsForCompat(t *testing.T) { } } +func TestChmod_batchGrantIdentityFallbackRejectsMismatchedAgentCode(t *testing.T) { + t.Setenv(agentCodeEnv, "dinglqdkz3mmw2xwvend") + fake := &sequenceToolCaller{ + errs: []error{ + apperrors.NewAPI("PAT batch identity field 'agentCode' must be derived by gateway.", + apperrors.WithReason("business_error"), + apperrors.WithServerDiag(apperrors.ServerDiagnostics{ + ServerErrorCode: patForgedIdentityCode, + }), + ), + nil, + }, + responses: []string{ + "", + `{"success":true,"result":{"agentCode":"dingmbw5n9ktkkbbjv3g","grantedScopes":[],"alreadyGrantedScopes":["chat.message:send"]}}`, + }, + } + cmd := newChmodCommand(fake) + _ = cmd.Flags().Set("grant-type", "permanent") + + err := cmd.RunE(cmd, []string{"chat.message:send"}) + if err == nil { + t.Fatal("chmod RunE error = nil, want identity fallback agentCode mismatch") + } + if !strings.Contains(err.Error(), "identity fallback returned agentCode") || + !strings.Contains(err.Error(), "dingmbw5n9ktkkbbjv3g") || + !strings.Contains(err.Error(), "dinglqdkz3mmw2xwvend") { + t.Fatalf("error = %q, want fallback mismatch details", err.Error()) + } + if len(fake.calls) != 2 { + t.Fatalf("CallTool count = %d, want 2", len(fake.calls)) + } + if _, ok := fake.calls[1].args["agentCode"]; ok { + t.Fatalf("compat retry should still omit agentCode arg, got %#v", fake.calls[1].args) + } + if got := fake.calls[1].agentEnv; got != "dinglqdkz3mmw2xwvend" { + t.Fatalf("compat retry %s = %q, want requested agentCode", agentCodeEnv, got) + } +} + +func TestChmod_batchGrantIdentityFallbackRejectsMissingAgentCode(t *testing.T) { + t.Setenv(agentCodeEnv, "dinglqdkz3mmw2xwvend") + fake := &sequenceToolCaller{ + errs: []error{ + apperrors.NewAPI("PAT batch identity field 'agentCode' must be derived by gateway.", + apperrors.WithReason("business_error"), + apperrors.WithServerDiag(apperrors.ServerDiagnostics{ + ServerErrorCode: patForgedIdentityCode, + }), + ), + nil, + }, + responses: []string{ + "", + `{"success":true,"data":{"grantedScopes":["chat.message:send"]}}`, + }, + } + cmd := newChmodCommand(fake) + _ = cmd.Flags().Set("grant-type", "permanent") + + err := cmd.RunE(cmd, []string{"chat.message:send"}) + if err == nil { + t.Fatal("chmod RunE error = nil, want unverifiable fallback error") + } + if !strings.Contains(err.Error(), "authorization target cannot be verified") { + t.Fatalf("error = %q, want unverifiable fallback details", err.Error()) + } +} + func TestResolveSessionIDFromEnvMatchesHeaderPriority(t *testing.T) { t.Setenv(sessionIDEnvDingtalk, "ding-session") t.Setenv(sessionIDEnvDWS, "dws-session") @@ -1102,7 +1110,7 @@ func TestChmod_recommendFlagPlansThenGrantsWithoutPositionalScopes(t *testing.T) cmd := newChmodCommand(fake) _ = cmd.Flags().Set("grant-type", "once") _ = cmd.Flags().Set("recommend", "true") - attachRootYesFlag(t, cmd, true) + setBatchYesForTest(t, cmd) if err := cmd.RunE(cmd, nil); err != nil { t.Fatalf("chmod RunE error = %v", err) @@ -1122,6 +1130,196 @@ func TestChmod_recommendFlagPlansThenGrantsWithoutPositionalScopes(t *testing.T) } } +func TestLoginRecommendAuthorizationSelectorReplansBySelectedProducts(t *testing.T) { + fake := &sequenceToolCaller{responses: []string{ + `{"success":true,"data":{"items":[{"scope":"calendar.event:read","productCode":"calendar","productName":"日历","displayName":"日程查看","operationSummary":"日历管理、日程创建"},{"scope":"doc.document:read","productCode":"doc","productName":"文档","displayName":"文档读取"}],"selectedScopes":["calendar.event:read","doc.document:read"]}}`, + `{"success":true,"data":{"items":[{"scope":"calendar.event:read","productCode":"calendar","productName":"日历","displayName":"日程查看"}],"selectedScopes":["calendar.event:read"]}}`, + `{"success":true,"data":{"flowId":"flow-1","userCode":"ABCD-EFGH","uri":"https://example.com/auth"}}`, + }} + var selectorProducts []LoginRecommendProduct + err := RunLoginRecommendAuthorizationWithOptions(context.Background(), fake, io.Discard, LoginRecommendOptions{ + ProductSelector: func(products []LoginRecommendProduct) ([]string, error) { + selectorProducts = products + return []string{"calendar"}, nil + }, + }) + if err != nil { + t.Fatalf("RunLoginRecommendAuthorizationWithOptions error = %v", err) + } + if len(selectorProducts) != 2 { + t.Fatalf("selector products = %d, want 2", len(selectorProducts)) + } + if selectorProducts[0].ProductCode != "calendar" || selectorProducts[0].ProductName != "日历" { + t.Fatalf("first selector product = %+v, want calendar/日历", selectorProducts[0]) + } + if selectorProducts[0].Summary != "日历管理、日程创建" { + t.Fatalf("first selector summary = %q", selectorProducts[0].Summary) + } + if len(fake.calls) != 3 { + t.Fatalf("CallTool count = %d, want initial plan + selected plan + grant", len(fake.calls)) + } + if fake.calls[0].tool != patBatchPlanToolName { + t.Fatalf("first tool = %q, want %q", fake.calls[0].tool, patBatchPlanToolName) + } + if got := fake.calls[0].args["productCodes"]; !stringSliceArgEqual(got, nil) { + t.Fatalf("initial plan productCodes = %#v, want empty", got) + } + if got := fake.calls[0].args["recommend"]; got != true { + t.Fatalf("initial plan recommend = %#v, want true", got) + } + if got := fake.calls[1].args["productCodes"]; !stringSliceArgEqual(got, []string{"calendar"}) { + t.Fatalf("selected plan productCodes = %#v, want calendar", got) + } + if got := fake.calls[1].args["recommend"]; got != true { + t.Fatalf("selected plan recommend = %#v, want true", got) + } + if got := fake.calls[1].args["caller"]; got != patCallerAuthLoginRecommend { + t.Fatalf("selected plan caller = %#v, want %q", got, patCallerAuthLoginRecommend) + } + if fake.calls[2].tool != patBatchGrantToolName { + t.Fatalf("third tool = %q, want %q", fake.calls[2].tool, patBatchGrantToolName) + } + if got := fake.calls[2].args["scopes"]; !stringSliceArgEqual(got, []string{"calendar.event:read"}) { + t.Fatalf("grant scopes = %#v, want selected calendar scope", got) + } +} + +func TestLoginRecommendAuthorizationWithoutSelectorKeepsSinglePlan(t *testing.T) { + fake := &sequenceToolCaller{responses: []string{ + `{"success":true,"data":{"items":[{"scope":"calendar.event:read","productCode":"calendar","productName":"日历"}],"selectedScopes":["calendar.event:read"]}}`, + `{"success":true,"data":{"flowId":"flow-1","userCode":"ABCD-EFGH","uri":"https://example.com/auth"}}`, + }} + if err := RunLoginRecommendAuthorizationWithOptions(context.Background(), fake, io.Discard, LoginRecommendOptions{}); err != nil { + t.Fatalf("RunLoginRecommendAuthorizationWithOptions error = %v", err) + } + if len(fake.calls) != 2 { + t.Fatalf("CallTool count = %d, want one plan + grant", len(fake.calls)) + } + if fake.calls[0].tool != patBatchPlanToolName { + t.Fatalf("first tool = %q, want %q", fake.calls[0].tool, patBatchPlanToolName) + } + if got := fake.calls[0].args["productCodes"]; !stringSliceArgEqual(got, nil) { + t.Fatalf("plan productCodes = %#v, want empty", got) + } + if fake.calls[1].tool != patBatchGrantToolName { + t.Fatalf("second tool = %q, want %q", fake.calls[1].tool, patBatchGrantToolName) + } + if got := fake.calls[1].args["startFlow"]; got != true { + t.Fatalf("startFlow = %#v, want true for unconfirmed recommend login", got) + } + if got := fake.calls[1].args["noWait"]; got != true { + t.Fatalf("noWait = %#v, want true for unconfirmed recommend login", got) + } +} + +func TestLoginRecommendAuthorizationRecommendedAlreadyGrantedSkipsSelectorAndGrant(t *testing.T) { + fake := &sequenceToolCaller{responses: []string{ + `{"success":true,"data":{"allGranted":false,"items":[{"scope":"calendar.event:read","productCode":"calendar","productName":"日历"}],"selectedScopes":[]}}`, + }} + var out bytes.Buffer + err := RunLoginRecommendAuthorizationWithOptions(context.Background(), fake, &out, LoginRecommendOptions{ + ScopeMode: LoginRecommendScopeRecommended, + ProductSelector: func([]LoginRecommendProduct) ([]string, error) { + t.Fatal("already-granted recommended plan must not ask for product selection") + return nil, nil + }, + }) + if err != nil { + t.Fatalf("RunLoginRecommendAuthorizationWithOptions error = %v", err) + } + if len(fake.calls) != 1 { + t.Fatalf("CallTool count = %d, want only initial recommend plan", len(fake.calls)) + } + if fake.calls[0].tool != patBatchPlanToolName { + t.Fatalf("tool sequence = %q, want only %q", fake.calls[0].tool, patBatchPlanToolName) + } + if strings.Contains(out.String(), "flowId") { + t.Fatalf("output = %q, must not include authorization flow", out.String()) + } + if !strings.Contains(out.String(), "推荐权限已全部授权或没有可授权项") { + t.Fatalf("output = %q, want already-granted message", out.String()) + } +} + +func TestLoginRecommendAuthorizationAllScopeModePlansAllProductScopes(t *testing.T) { + fake := &sequenceToolCaller{responses: []string{ + `{"success":true,"data":{"items":[{"scope":"calendar.event:read","productCode":"calendar","productName":"日历"},{"scope":"calendar.event:write","productCode":"calendar","productName":"日历"}],"selectedScopes":["calendar.event:read","calendar.event:write"]}}`, + `{"success":true,"data":{"items":[{"scope":"calendar.event:read","productCode":"calendar","productName":"日历"},{"scope":"calendar.event:write","productCode":"calendar","productName":"日历"}],"selectedScopes":["calendar.event:read","calendar.event:write"]}}`, + `{"success":true,"data":{"flowId":"flow-1","userCode":"ABCD-EFGH","uri":"https://example.com/auth"}}`, + }} + err := RunLoginRecommendAuthorizationWithOptions(context.Background(), fake, io.Discard, LoginRecommendOptions{ + ScopeMode: LoginRecommendScopeAll, + ProductSelector: func(products []LoginRecommendProduct) ([]string, error) { + return []string{"calendar"}, nil + }, + }) + if err != nil { + t.Fatalf("RunLoginRecommendAuthorizationWithOptions error = %v", err) + } + if len(fake.calls) != 3 { + t.Fatalf("CallTool count = %d, want initial plan + selected plan + grant", len(fake.calls)) + } + if got := fake.calls[0].args["recommend"]; got != true { + t.Fatalf("initial discovery plan recommend = %#v, want true before product selection", got) + } + if got := fake.calls[0].args["productCodes"]; !stringSliceArgEqual(got, nil) { + t.Fatalf("initial discovery plan productCodes = %#v, want empty", got) + } + if got := fake.calls[1].args["recommend"]; got != false { + t.Fatalf("selected plan recommend = %#v, want false for all scope mode", got) + } + if got := fake.calls[1].args["productCodes"]; !stringSliceArgEqual(got, []string{"calendar"}) { + t.Fatalf("selected plan productCodes = %#v, want calendar", got) + } + if got := fake.calls[2].args["scopes"]; !stringSliceArgEqual(got, []string{"calendar.event:read", "calendar.event:write"}) { + t.Fatalf("grant scopes = %#v, want all selected calendar scopes", got) + } +} + +func TestLoginRecommendAuthorizationAllScopeWithoutProductsFailsBeforePlan(t *testing.T) { + fake := &sequenceToolCaller{} + err := RunLoginRecommendAuthorizationWithOptions(context.Background(), fake, io.Discard, LoginRecommendOptions{ + ScopeMode: LoginRecommendScopeAll, + }) + if err == nil { + t.Fatal("RunLoginRecommendAuthorizationWithOptions error = nil, want product-domain validation error") + } + if !strings.Contains(err.Error(), "至少一个授权业务域") { + t.Fatalf("error = %v, want product-domain validation error", err) + } + if len(fake.calls) != 0 { + t.Fatalf("CallTool count = %d, want no empty all-scope plan call", len(fake.calls)) + } +} + +func TestLoginRecommendAuthorizationConfirmedGrantsDirectly(t *testing.T) { + fake := &sequenceToolCaller{responses: []string{ + `{"success":true,"data":{"items":[{"scope":"calendar.event:read","productCode":"calendar","productName":"日历"}],"selectedScopes":["calendar.event:read"]}}`, + `{"success":true,"data":{"grantedScopes":["calendar.event:read"]}}`, + }} + if err := RunLoginRecommendAuthorizationWithOptions(context.Background(), fake, io.Discard, LoginRecommendOptions{Confirmed: true}); err != nil { + t.Fatalf("RunLoginRecommendAuthorizationWithOptions error = %v", err) + } + if len(fake.calls) != 2 { + t.Fatalf("CallTool count = %d, want one plan + grant", len(fake.calls)) + } + if fake.calls[1].tool != patBatchGrantToolName { + t.Fatalf("second tool = %q, want %q", fake.calls[1].tool, patBatchGrantToolName) + } + if got := fake.calls[0].args["recommend"]; got != true { + t.Fatalf("plan recommend = %#v, want true for confirmed recommend login", got) + } + if _, ok := fake.calls[1].args["startFlow"]; ok { + t.Fatalf("startFlow should be omitted for confirmed recommend login") + } + if _, ok := fake.calls[1].args["noWait"]; ok { + t.Fatalf("noWait should be omitted for confirmed recommend login") + } + if got := fake.calls[1].args["caller"]; got != patCallerAuthLoginRecommend { + t.Fatalf("caller = %#v, want %q", got, patCallerAuthLoginRecommend) + } +} + func TestChmod_productsAllGrantedStopsAfterPlan(t *testing.T) { t.Setenv(agentCodeEnv, "qoderwork") fake := &sequenceToolCaller{responses: []string{ @@ -1130,6 +1328,7 @@ func TestChmod_productsAllGrantedStopsAfterPlan(t *testing.T) { cmd := newChmodCommand(fake) _ = cmd.Flags().Set("grant-type", "once") _ = cmd.Flags().Set("products", "calendar") + setBatchYesForTest(t, cmd) if err := cmd.RunE(cmd, nil); err != nil { t.Fatalf("chmod RunE error = %v", err) @@ -1244,6 +1443,34 @@ func TestChmod_withoutAgentCodeLetsServerDefault(t *testing.T) { } } +func TestChmod_agentCode_envServerMismatchFails(t *testing.T) { + t.Setenv(agentCodeEnv, "dinglqdkz3mmw2xwvend") + + fake := &sequenceToolCaller{responses: []string{ + `{"success":true,"code":"OK","data":{"agentCode":"dingmbw5n9ktkkkbjv3g","grantType":"permanent","grantedScopes":[],"alreadyGrantedScopes":["chat.message:send"]}}`, + }} + cmd := newChmodCommand(fake) + _ = cmd.Flags().Set("grant-type", "permanent") + + err := cmd.RunE(cmd, []string{"chat.message:send"}) + if err == nil { + t.Fatal("chmod RunE error = nil, want agentCode mismatch error") + } + if !strings.Contains(err.Error(), "dingmbw5n9ktkkkbjv3g") || + !strings.Contains(err.Error(), "dinglqdkz3mmw2xwvend") { + t.Fatalf("error = %q, want both returned and expected agentCode", err.Error()) + } + if len(fake.calls) != 1 { + t.Fatalf("CallTool count = %d, want 1", len(fake.calls)) + } + if got := fake.calls[0].args["agentCode"]; got != "dinglqdkz3mmw2xwvend" { + t.Fatalf("batch grant agentCode = %#v, want DINGTALK_DWS_AGENTCODE", got) + } + if got := fake.calls[0].agentEnv; got != "dinglqdkz3mmw2xwvend" { + t.Fatalf("%s during CallTool = %q, want DINGTALK_DWS_AGENTCODE", agentCodeEnv, got) + } +} + func TestCallPATToolWithLegacyFallback_emptyCanonicalResultDoesNotRetryLegacyAlias(t *testing.T) { fake := &fallbackToolCaller{} canonicalArgs := map[string]any{ diff --git a/internal/tui/theme.go b/internal/tui/theme.go new file mode 100644 index 00000000..a72ecbb2 --- /dev/null +++ b/internal/tui/theme.go @@ -0,0 +1,201 @@ +// Copyright 2026 Alibaba Group +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package tui + +import ( + "fmt" + "io" + "strings" + "unicode" + + "github.com/fatih/color" +) + +const ( + MaxTableColumnWidth = 60 + MaxPanelWidth = 96 + MinPanelWidth = 52 +) + +var ( + brandBlue = color.New(color.FgHiBlue, color.Bold).SprintFunc() + blue = color.New(color.FgBlue).SprintFunc() + cyan = color.New(color.FgCyan).SprintFunc() + white = color.New(color.FgHiWhite).SprintFunc() + gray = color.New(color.FgHiBlack).SprintFunc() + success = color.New(color.FgGreen, color.Bold).SprintFunc() + warning = color.New(color.FgYellow, color.Bold).SprintFunc() + danger = color.New(color.FgRed, color.Bold).SprintFunc() + bold = color.New(color.Bold).SprintFunc() + dim = color.New(color.Faint).SprintFunc() +) + +func Brand(v string) string { return brandBlue(v) } +func Blue(v string) string { return blue(v) } +func Cyan(v string) string { return cyan(v) } +func White(v string) string { return white(v) } +func Gray(v string) string { return gray(v) } +func Success(v string) string { return success(v) } +func Warning(v string) string { return warning(v) } +func Danger(v string) string { return danger(v) } +func Bold(v string) string { return bold(v) } +func Dim(v string) string { return dim(v) } + +func Header(title, subtitle string) string { + title = strings.TrimSpace(title) + subtitle = strings.TrimSpace(subtitle) + if subtitle == "" { + return fmt.Sprintf("%s %s", Brand("DingTalk"), White(title)) + } + return fmt.Sprintf("%s %s %s", Brand("DingTalk"), White(title), Dim("· "+subtitle)) +} + +func Section(title string) string { + return fmt.Sprintf("%s %s", Blue("▍"), Bold(title)) +} + +func Key(key string) string { + return Gray(key + ":") +} + +func Bullet() string { + return Cyan("•") +} + +func Arrow() string { + return Dim("→") +} + +func StateMark(kind string) string { + switch strings.ToLower(strings.TrimSpace(kind)) { + case "ok", "success", "pass": + return Success("●") + case "warn", "warning", "retryable": + return Warning("●") + case "error", "danger", "failed", "fail": + return Danger("●") + default: + return Cyan("●") + } +} + +func Border() string { + return Blue("─") +} + +func SoftBorder() string { + return Gray("─") +} + +func Rule(width int) string { + if width <= 0 { + width = MinPanelWidth + } + if width > MaxPanelWidth { + width = MaxPanelWidth + } + return Blue(strings.Repeat("─", width)) +} + +func PlainRuneWidth(s string) int { + width := 0 + inEscape := false + for _, r := range s { + if r == '\x1b' { + inEscape = true + continue + } + if inEscape { + if r == 'm' { + inEscape = false + } + continue + } + if isZeroWidthRune(r) { + continue + } + if isWideRune(r) { + width += 2 + continue + } + width++ + } + return width +} + +func isZeroWidthRune(r rune) bool { + return r == '\u200d' || + (r >= 0xFE00 && r <= 0xFE0F) || + (r >= 0xE0100 && r <= 0xE01EF) || + unicode.Is(unicode.Mn, r) || + unicode.Is(unicode.Me, r) +} + +func isWideRune(r rune) bool { + return (r >= 0x1100 && r <= 0x115F) || + (r >= 0x2329 && r <= 0x232A) || + (r >= 0x2E80 && r <= 0xA4CF) || + (r >= 0xAC00 && r <= 0xD7A3) || + (r >= 0xF900 && r <= 0xFAFF) || + (r >= 0xFE10 && r <= 0xFE19) || + (r >= 0xFE30 && r <= 0xFE6F) || + (r >= 0xFF00 && r <= 0xFF60) || + (r >= 0xFFE0 && r <= 0xFFE6) || + (r >= 0x1F1E6 && r <= 0x1F1FF) || + (r >= 0x1F300 && r <= 0x1FAFF) +} + +func PadRightANSI(s string, width int) string { + pad := width - PlainRuneWidth(s) + if pad <= 0 { + return s + } + return s + strings.Repeat(" ", pad) +} + +func Panel(w io.Writer, title string, lines []string) error { + width := MinPanelWidth + for _, line := range append([]string{title}, lines...) { + if lineWidth := PlainRuneWidth(line) + 4; lineWidth > width { + width = lineWidth + } + } + if width > MaxPanelWidth { + width = MaxPanelWidth + } + + top := Blue("╭" + strings.Repeat("─", width-2) + "╮") + mid := Blue("├" + strings.Repeat("─", width-2) + "┤") + bottom := Blue("╰" + strings.Repeat("─", width-2) + "╯") + if _, err := fmt.Fprintln(w, top); err != nil { + return err + } + if title != "" { + content := " " + title + if _, err := fmt.Fprintf(w, "%s%s%s\n", Blue("│"), PadRightANSI(content, width-2), Blue("│")); err != nil { + return err + } + if _, err := fmt.Fprintln(w, mid); err != nil { + return err + } + } + for _, line := range lines { + content := " " + line + if _, err := fmt.Fprintf(w, "%s%s%s\n", Blue("│"), PadRightANSI(content, width-2), Blue("│")); err != nil { + return err + } + } + _, err := fmt.Fprintln(w, bottom) + return err +} diff --git a/internal/tui/theme_test.go b/internal/tui/theme_test.go new file mode 100644 index 00000000..992f9746 --- /dev/null +++ b/internal/tui/theme_test.go @@ -0,0 +1,37 @@ +// Copyright 2026 Alibaba Group +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package tui + +import "testing" + +func TestPlainRuneWidthSkipsANSIAndCountsCJK(t *testing.T) { + got := PlainRuneWidth("\x1b[34m钉钉\x1b[0m CLI") + if got != 8 { + t.Fatalf("PlainRuneWidth() = %d, want 8", got) + } +} + +func TestPlainRuneWidthCountsFullwidthHangulAndEmoji(t *testing.T) { + got := PlainRuneWidth("A한🟦") + if got != 6 { + t.Fatalf("PlainRuneWidth() = %d, want 6", got) + } +} + +func TestPadRightANSIUsesVisibleWidth(t *testing.T) { + got := PadRightANSI("\x1b[34m钉钉\x1b[0m", 6) + if PlainRuneWidth(got) != 6 { + t.Fatalf("visible width = %d, want 6", PlainRuneWidth(got)) + } +} diff --git a/pkg/edition/default.go b/pkg/edition/default.go index f08c38ee..784cb158 100644 --- a/pkg/edition/default.go +++ b/pkg/edition/default.go @@ -46,7 +46,7 @@ func openSupplementServers() []ServerInfo { { ID: "aitable-form", Name: "AI 多维表(表单)", - Endpoint: "https://pre-mcp-gw.dingtalk.com/server/bb2984ee6b10c1560b4fe943ca620f646bed31f215c551a53abf040b52591a95", + Endpoint: "https://mcp-gw.dingtalk.com/server/bb2984ee6b10c1560b4fe943ca620f646bed31f215c551a53abf040b52591a95", Prefixes: []string{"form", "share_form"}, }, } diff --git a/test/unit/pat_stderr_contract_test.go b/test/unit/pat_stderr_contract_test.go index 7e6f3df9..e162dcab 100644 --- a/test/unit/pat_stderr_contract_test.go +++ b/test/unit/pat_stderr_contract_test.go @@ -248,6 +248,7 @@ func TestPATStderrJSON_CodeEnumFrozen(t *testing.T) { "PAT_MEDIUM_RISK_NO_PERMISSION", "PAT_HIGH_RISK_NO_PERMISSION", "PAT_SCOPE_AUTH_REQUIRED", + "PAT_BATCH_AUTH_PENDING", "AGENT_CODE_NOT_EXISTS", }