Merge remote-tracking branch 'origin/main' into HEAD

# Conflicts:
#	.github/badges/coverage.svg
This commit is contained in:
修雨
2026-06-25 14:12:58 +08:00
35 changed files with 2816 additions and 437 deletions
+413 -23
View File
@@ -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
}
+457
View File
@@ -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
}
}
+19 -8
View File
@@ -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()
+200 -71
View File
@@ -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":
+216 -9
View File
@@ -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)
}
}
+4 -4
View File
@@ -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 {
+26 -10
View File
@@ -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 <service> [command] [flags]")
_, _ = fmt.Fprintln(w, tui.Section("Usage:"))
_, _ = fmt.Fprintf(w, " %s %s\n", tui.Bullet(), tui.White("dws <service> [command] [flags]"))
if len(utilities) > 0 {
_, _ = fmt.Fprintln(w, " dws <command> [flags]")
_, _ = fmt.Fprintf(w, " %s %s\n", tui.Bullet(), tui.White("dws <command> [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 <service> --help" for more information about a discovered MCP service or "dws <command> --help" for utility commands.`)
_, _ = fmt.Fprintf(w, "%s %s\n", tui.Key("Next"), `Use "dws <service> --help" for more information about a discovered MCP service or "dws <command> --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 —
-1
View File
@@ -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,
+8 -1
View File
@@ -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
}
+16
View File
@@ -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")
+4 -4
View File
@@ -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)}},
}
+8 -8
View File
@@ -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
+1 -1
View File
@@ -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) })
+44 -54
View File
@@ -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) {
+5
View File
@@ -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 {
+24 -17
View File
@@ -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.
+1 -1
View File
@@ -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)
+1 -1
View File
@@ -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)
+21 -19
View File
@@ -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))))
}
}
+26 -12
View File
@@ -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":<frozen enum>,"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 <data.missingScope>`.
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{}
+73 -7
View File
@@ -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")
}
}
+44
View File
@@ -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
}
+2 -1
View File
@@ -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")
}
+14
View File
@@ -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
+73 -54
View File
@@ -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)
}
+29
View File
@@ -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
+1
View File
@@ -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) {
+5
View File
@@ -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) {
+34 -43
View File
@@ -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))
}
}
+494 -1
View File
@@ -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
+313 -86
View File
@@ -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{
+201
View File
@@ -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
}
+37
View File
@@ -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))
}
}
+1 -1
View File
@@ -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"},
},
}
+1
View File
@@ -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",
}