Merge remote-tracking branch 'origin/main' into HEAD
# Conflicts: # .github/badges/coverage.svg
This commit is contained in:
+413
-23
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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":
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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 —
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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)}},
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) })
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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{}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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{
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -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"},
|
||||
},
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user