Merge branch 'main' of https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli
This commit is contained in:
+187
-38
@@ -15,7 +15,9 @@ package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
@@ -52,15 +54,29 @@ func buildAuthCommand() *cobra.Command {
|
||||
newAuthStatusCommand(),
|
||||
newAuthImportCommand(),
|
||||
newAuthExchangeCommand(),
|
||||
newAuthResetCommand(),
|
||||
)
|
||||
return cmd
|
||||
}
|
||||
|
||||
func newAuthLoginCommand() *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "login",
|
||||
Short: "登录钉钉(自动刷新 token,必要时扫码)",
|
||||
Use: "login",
|
||||
Short: "登录钉钉(自动刷新 token,必要时扫码)",
|
||||
Long: `登录钉钉并获取认证凭证。
|
||||
|
||||
支持的登录方式:
|
||||
- OAuth 设备流 (默认): 通过钉钉扫码授权登录
|
||||
- 直接提供 Token: 通过 --token 参数传入已有 token
|
||||
|
||||
不支持的登录方式:
|
||||
- 邮箱/密码登录
|
||||
- 手机号/验证码登录
|
||||
- 应用凭证 (AppKey/AppSecret) 直接登录
|
||||
|
||||
示例:
|
||||
dws auth login # 扫码登录
|
||||
dws auth login --force # 强制重新登录 (忽略缓存 token)
|
||||
dws auth login --token xxx # 使用指定 token`,
|
||||
DisableAutoGenTag: true,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
cfg, err := resolveAuthLoginConfig(cmd)
|
||||
@@ -105,6 +121,14 @@ func newAuthLoginCommand() *cobra.Command {
|
||||
clearCompatCache()
|
||||
|
||||
w := cmd.OutOrStdout()
|
||||
|
||||
// Check if JSON output is requested
|
||||
format, _ := cmd.Root().PersistentFlags().GetString("format")
|
||||
if strings.EqualFold(strings.TrimSpace(format), "json") {
|
||||
return writeAuthLoginJSON(w, tokenData, cfg.Force)
|
||||
}
|
||||
|
||||
// Default table output
|
||||
fmt.Fprintln(w)
|
||||
if !cfg.Device && tokenData != nil && tokenData.IsAccessTokenValid() && !cfg.Force {
|
||||
fmt.Fprintf(w, "[OK] Token 有效,无需重新登录\n")
|
||||
@@ -130,22 +154,40 @@ func newAuthLoginCommand() *cobra.Command {
|
||||
},
|
||||
}
|
||||
cmd.Flags().String("token", "", "Access token")
|
||||
cmd.Flags().Bool("device", false, "Use device authorization flow (compatibility flag)")
|
||||
cmd.Flags().Bool("force", false, "Force interactive login flow (compatibility flag)")
|
||||
cmd.Flags().String("redirect-url", "", "Loopback redirect URL compatibility flag")
|
||||
cmd.Flags().Bool("device", false, "Use device authorization flow")
|
||||
cmd.Flags().Bool("force", false, "Force interactive login (ignore cached token)")
|
||||
// Hidden compatibility flags
|
||||
cmd.Flags().String("redirect-url", "", "Loopback redirect URL")
|
||||
cmd.Flags().String("scopes", "", "Space-separated DingTalk OAuth scopes")
|
||||
cmd.Flags().String("authorize-url", "", "Override DingTalk authorization URL")
|
||||
cmd.Flags().String("token-url", "", "Override DingTalk token exchange URL")
|
||||
cmd.Flags().String("refresh-url", "", "Override DingTalk refresh token URL")
|
||||
cmd.Flags().Int("login-timeout", 0, "Compatibility flag for login timeout seconds")
|
||||
cmd.Flags().Bool("no-browser", false, "Compatibility flag for browser launch suppression")
|
||||
cmd.Flags().Int("login-timeout", 0, "Login timeout seconds")
|
||||
cmd.Flags().Bool("no-browser", false, "Suppress browser launch")
|
||||
_ = cmd.Flags().MarkHidden("redirect-url")
|
||||
_ = cmd.Flags().MarkHidden("scopes")
|
||||
_ = cmd.Flags().MarkHidden("authorize-url")
|
||||
_ = cmd.Flags().MarkHidden("token-url")
|
||||
_ = cmd.Flags().MarkHidden("refresh-url")
|
||||
_ = cmd.Flags().MarkHidden("login-timeout")
|
||||
_ = cmd.Flags().MarkHidden("no-browser")
|
||||
return cmd
|
||||
}
|
||||
|
||||
func newAuthLogoutCommand() *cobra.Command {
|
||||
return &cobra.Command{
|
||||
Use: "logout",
|
||||
Short: "清除认证信息",
|
||||
Use: "logout",
|
||||
Short: "清除认证信息",
|
||||
Long: `清除本地认证信息并撤销远程授权。
|
||||
|
||||
执行操作:
|
||||
1. 向服务端发送撤销请求 (使 token 失效)
|
||||
2. 删除本地存储的认证数据
|
||||
3. 清除缓存
|
||||
|
||||
使用场景:
|
||||
- 完全登出当前账号
|
||||
- 遇到 "AUTH_TOKEN_EXPIRED" 或 "认证信息损坏" 错误时`,
|
||||
DisableAutoGenTag: true,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
configDir := defaultConfigDir()
|
||||
@@ -159,7 +201,16 @@ func newAuthLogoutCommand() *cobra.Command {
|
||||
_ = os.Remove(filepath.Join(configDir, "mcp_url"))
|
||||
_ = os.Remove(filepath.Join(configDir, "token"))
|
||||
clearCompatCache()
|
||||
|
||||
w := cmd.OutOrStdout()
|
||||
|
||||
// Check if JSON output is requested
|
||||
format, _ := cmd.Root().PersistentFlags().GetString("format")
|
||||
if strings.EqualFold(strings.TrimSpace(format), "json") {
|
||||
return writeAuthLogoutJSON(w)
|
||||
}
|
||||
|
||||
// Default table output
|
||||
fmt.Fprintln(w, "[OK] 已清除所有认证信息")
|
||||
fmt.Fprintln(w, "请运行 dws auth login 重新登录")
|
||||
return nil
|
||||
@@ -169,36 +220,51 @@ func newAuthLogoutCommand() *cobra.Command {
|
||||
|
||||
func newAuthStatusCommand() *cobra.Command {
|
||||
return &cobra.Command{
|
||||
Use: "status",
|
||||
Short: "查看认证状态",
|
||||
Use: "status",
|
||||
Aliases: []string{"whoami"},
|
||||
Short: "查看认证状态",
|
||||
Long: `查看当前认证状态和用户信息。
|
||||
|
||||
示例:
|
||||
dws auth status # 查看认证状态
|
||||
dws auth status -f json # JSON 格式输出
|
||||
dws auth whoami # 别名,与 status 相同`,
|
||||
DisableAutoGenTag: true,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
configDir := defaultConfigDir()
|
||||
|
||||
authenticated := false
|
||||
updatedAt := ""
|
||||
refreshed := false
|
||||
var tokenData *authpkg.TokenData
|
||||
provider := authpkg.NewOAuthProvider(configDir, nil)
|
||||
configureOAuthProviderCompatibility(provider, configDir)
|
||||
if data, err := provider.Status(); err == nil {
|
||||
tokenData = data
|
||||
if !data.IsAccessTokenValid() && data.IsRefreshTokenValid() {
|
||||
refreshCtx, cancel := context.WithTimeout(cmd.Context(), 15*time.Second)
|
||||
_, refreshErr := provider.GetAccessToken(refreshCtx)
|
||||
cancel()
|
||||
if refreshErr == nil {
|
||||
if updatedData, statusErr := provider.Status(); statusErr == nil {
|
||||
data = updatedData
|
||||
tokenData = updatedData
|
||||
refreshed = true
|
||||
}
|
||||
}
|
||||
}
|
||||
if authStatusAuthenticated(data) {
|
||||
if authStatusAuthenticated(tokenData) {
|
||||
authenticated = true
|
||||
updatedAt = authStatusUpdatedAt(data)
|
||||
}
|
||||
}
|
||||
|
||||
w := cmd.OutOrStdout()
|
||||
|
||||
// Check if JSON output is requested
|
||||
format, _ := cmd.Root().PersistentFlags().GetString("format")
|
||||
if strings.EqualFold(strings.TrimSpace(format), "json") {
|
||||
return writeAuthStatusJSON(w, authenticated, refreshed, tokenData)
|
||||
}
|
||||
|
||||
// Default table output
|
||||
if authenticated {
|
||||
if refreshed {
|
||||
fmt.Fprintf(w, "%-16s%s\n", "状态:", "已登录 ✅")
|
||||
@@ -206,7 +272,7 @@ func newAuthStatusCommand() *cobra.Command {
|
||||
} else {
|
||||
fmt.Fprintf(w, "%-16s%s\n", "状态:", "已登录 ✅")
|
||||
}
|
||||
if updatedAt != "" {
|
||||
if updatedAt := authStatusUpdatedAt(tokenData); updatedAt != "" {
|
||||
fmt.Fprintf(w, "%-16s%s\n", "有效期:", updatedAt)
|
||||
}
|
||||
} else {
|
||||
@@ -218,6 +284,110 @@ func newAuthStatusCommand() *cobra.Command {
|
||||
}
|
||||
}
|
||||
|
||||
// authStatusResponse is the JSON response for auth status command.
|
||||
type authStatusResponse struct {
|
||||
Success bool `json:"success"`
|
||||
Authenticated bool `json:"authenticated"`
|
||||
Message string `json:"message,omitempty"`
|
||||
Refreshed bool `json:"refreshed,omitempty"`
|
||||
TokenValid bool `json:"token_valid,omitempty"`
|
||||
RefreshTokenValid bool `json:"refresh_token_valid,omitempty"`
|
||||
ExpiresAt string `json:"expires_at,omitempty"`
|
||||
RefreshExpiresAt string `json:"refresh_expires_at,omitempty"`
|
||||
CorpID string `json:"corp_id,omitempty"`
|
||||
CorpName string `json:"corp_name,omitempty"`
|
||||
UserID string `json:"user_id,omitempty"`
|
||||
UserName string `json:"user_name,omitempty"`
|
||||
}
|
||||
|
||||
func writeAuthStatusJSON(w io.Writer, authenticated, refreshed bool, data *authpkg.TokenData) error {
|
||||
resp := authStatusResponse{
|
||||
Success: true,
|
||||
Authenticated: authenticated,
|
||||
}
|
||||
|
||||
if !authenticated {
|
||||
resp.Message = "未登录"
|
||||
} else if data != nil {
|
||||
resp.Refreshed = refreshed
|
||||
resp.TokenValid = data.IsAccessTokenValid()
|
||||
resp.RefreshTokenValid = data.IsRefreshTokenValid()
|
||||
if !data.ExpiresAt.IsZero() {
|
||||
resp.ExpiresAt = data.ExpiresAt.Format(time.RFC3339Nano)
|
||||
}
|
||||
if !data.RefreshExpAt.IsZero() {
|
||||
resp.RefreshExpiresAt = data.RefreshExpAt.Format(time.RFC3339Nano)
|
||||
}
|
||||
resp.CorpID = data.CorpID
|
||||
resp.CorpName = data.CorpName
|
||||
resp.UserID = data.UserID
|
||||
resp.UserName = data.UserName
|
||||
}
|
||||
|
||||
enc := json.NewEncoder(w)
|
||||
enc.SetIndent("", " ")
|
||||
return enc.Encode(resp)
|
||||
}
|
||||
|
||||
// authLoginResponse is the JSON response for auth login command.
|
||||
type authLoginResponse struct {
|
||||
Success bool `json:"success"`
|
||||
Message string `json:"message"`
|
||||
TokenValid bool `json:"token_valid,omitempty"`
|
||||
RefreshTokenValid bool `json:"refresh_token_valid,omitempty"`
|
||||
ExpiresAt string `json:"expires_at,omitempty"`
|
||||
RefreshExpiresAt string `json:"refresh_expires_at,omitempty"`
|
||||
CorpID string `json:"corp_id,omitempty"`
|
||||
CorpName string `json:"corp_name,omitempty"`
|
||||
UserID string `json:"user_id,omitempty"`
|
||||
UserName string `json:"user_name,omitempty"`
|
||||
}
|
||||
|
||||
func writeAuthLoginJSON(w io.Writer, data *authpkg.TokenData, forced bool) error {
|
||||
resp := authLoginResponse{
|
||||
Success: true,
|
||||
Message: "登录成功",
|
||||
}
|
||||
|
||||
if data != nil {
|
||||
if data.IsAccessTokenValid() && !forced {
|
||||
resp.Message = "Token 有效,无需重新登录"
|
||||
}
|
||||
resp.TokenValid = data.IsAccessTokenValid()
|
||||
resp.RefreshTokenValid = data.IsRefreshTokenValid()
|
||||
if !data.ExpiresAt.IsZero() {
|
||||
resp.ExpiresAt = data.ExpiresAt.Format(time.RFC3339Nano)
|
||||
}
|
||||
if !data.RefreshExpAt.IsZero() {
|
||||
resp.RefreshExpiresAt = data.RefreshExpAt.Format(time.RFC3339Nano)
|
||||
}
|
||||
resp.CorpID = data.CorpID
|
||||
resp.CorpName = data.CorpName
|
||||
resp.UserID = data.UserID
|
||||
resp.UserName = data.UserName
|
||||
}
|
||||
|
||||
enc := json.NewEncoder(w)
|
||||
enc.SetIndent("", " ")
|
||||
return enc.Encode(resp)
|
||||
}
|
||||
|
||||
// authLogoutResponse is the JSON response for auth logout command.
|
||||
type authLogoutResponse struct {
|
||||
Success bool `json:"success"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
func writeAuthLogoutJSON(w io.Writer) error {
|
||||
resp := authLogoutResponse{
|
||||
Success: true,
|
||||
Message: "已清除所有认证信息",
|
||||
}
|
||||
enc := json.NewEncoder(w)
|
||||
enc.SetIndent("", " ")
|
||||
return enc.Encode(resp)
|
||||
}
|
||||
|
||||
func newAuthImportCommand() *cobra.Command {
|
||||
return &cobra.Command{
|
||||
Use: "import <file>",
|
||||
@@ -314,27 +484,6 @@ func newAuthExchangeCommand() *cobra.Command {
|
||||
return cmd
|
||||
}
|
||||
|
||||
func newAuthResetCommand() *cobra.Command {
|
||||
return &cobra.Command{
|
||||
Use: "reset",
|
||||
Short: "重置认证信息(清除本地 Token,触发重新授权)",
|
||||
DisableAutoGenTag: true,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
configDir := defaultConfigDir()
|
||||
if err := authpkg.DeleteTokenData(configDir); err != nil {
|
||||
return apperrors.NewInternal(fmt.Sprintf("failed to reset token data: %v", err))
|
||||
}
|
||||
_ = os.Remove(filepath.Join(configDir, "mcp_url"))
|
||||
_ = os.Remove(filepath.Join(configDir, "token"))
|
||||
clearCompatCache()
|
||||
w := cmd.OutOrStdout()
|
||||
fmt.Fprintln(w, "[OK] 认证信息已重置")
|
||||
fmt.Fprintln(w, "请运行 dws auth login 重新登录")
|
||||
return nil
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func timeOrEmpty(t time.Time) string {
|
||||
if t.IsZero() {
|
||||
return ""
|
||||
|
||||
@@ -69,6 +69,33 @@ func isUnknownCommandError(err error) bool {
|
||||
return err != nil && strings.Contains(err.Error(), "unknown command")
|
||||
}
|
||||
|
||||
// flagErrorWithSuggestions provides helpful suggestions for common flag mistakes.
|
||||
func flagErrorWithSuggestions(cmd *cobra.Command, err error) error {
|
||||
errMsg := err.Error()
|
||||
|
||||
// Common flag aliases and suggestions
|
||||
suggestions := map[string]string{
|
||||
"--json": "提示: 请使用 --format json 或 -f json 来输出 JSON 格式",
|
||||
"--method": "提示: dws auth login 默认使用 OAuth 设备流登录,无需指定 --method",
|
||||
"--device-flow": "提示: dws auth login 默认已使用设备流,无需 --device-flow 参数",
|
||||
"--email": "提示: dws 不支持邮箱/密码登录,请使用 dws auth login 进行扫码登录",
|
||||
"--code": "提示: dws 不支持验证码登录,请使用 dws auth login 进行扫码登录",
|
||||
"--corp-id": "提示: corp-id 会在登录时自动获取,无需手动指定",
|
||||
"--password": "提示: dws 不支持密码登录,请使用 dws auth login 进行扫码登录",
|
||||
"--phone": "提示: dws 不支持手机号登录,请使用 dws auth login 进行扫码登录",
|
||||
"--app-key": "提示: 请使用环境变量 DWS_CLIENT_ID 或 --client-id 设置 AppKey",
|
||||
"--app-secret": "提示: 请使用环境变量 DWS_CLIENT_SECRET 或 --client-secret 设置 AppSecret",
|
||||
}
|
||||
|
||||
for flag, suggestion := range suggestions {
|
||||
if strings.Contains(errMsg, "unknown flag: "+flag) {
|
||||
return fmt.Errorf("%w\n%s", err, suggestion)
|
||||
}
|
||||
}
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
func printExecutionError(root *cobra.Command, stdout, stderr io.Writer, err error) error {
|
||||
if wantsJSONErrors(root) {
|
||||
return apperrors.PrintJSON(stdout, err)
|
||||
@@ -170,6 +197,9 @@ func NewRootCommand(ctx ...context.Context) *cobra.Command {
|
||||
},
|
||||
}
|
||||
|
||||
// Set custom flag error handler for better UX
|
||||
root.SetFlagErrorFunc(flagErrorWithSuggestions)
|
||||
|
||||
bindPersistentFlags(root, flags)
|
||||
|
||||
schemaCmd := newSchemaCommand(loader)
|
||||
|
||||
Reference in New Issue
Block a user