* feat(auth): support multi-profile login
* fix(auth): complete multi-org profile acceptance
* feat(auth): 完成多组织 profile 验收
* docs(auth): 补充多组织 Ralph 验收材料
* feat(auth): 支持 auth switch TUI 切换 profile
* feat(auth): logout 默认清理所有组织
* feat(auth): login 默认新增组织授权
* feat(profile): 使用 profile switch 切换组织
* docs(ralph): 更新 profile switch 验收材料
* fix(profile): 展示全部可切换组织
* feat(profile): support multi-org switch tui
* chore(install): add branch source installer
* fix(profile): keep global profile out of tool params
* feat(profile): support csv multi-profile runtime
* ci: add multi-profile e2e workflow
* ci: run multi-profile e2e on all branches
* docs: document multi-profile e2e ci gate
* docs: remove multi-profile test cases from pr
* ci: harden multi-profile e2e gates
* fix(auth): serialize profiles.json RMW and harden multi-profile persistence
Wrap all profiles.json read-modify-write paths (profile switch/use/remove,
status marking, token save, logout) in the existing dual-layer lock via a new
withProfilesLock helper. Split each writer into a public (locking) entry point
plus a lock-free *Locked variant so the non-reentrant lock is never re-acquired;
the refresh path (oauth_helpers) and the load-path legacy migration now call the
lock-free saver to avoid self-deadlock.
Also: write profiles.json and the token marker via per-write random temp names
(uuid) to stop concurrent writers from corrupting a fixed .tmp file; quarantine
an unparseable profiles.json and rebuild an empty config so the CLI can
self-heal instead of locking out auth reset/logout; make DeleteAllTokenData
proceed even if profiles.json cannot be read; and stop SyncLegacyTokenMirror
from deleting the legacy mirror on a transient keychain read error.
* fix(auth): do not fall back to a different org's legacy token slot
When no explicit --profile is given, LoadTokenDataForProfile resolves the
current/primary profile and reads its per-corp keychain slot. If that slot
read failed, the code silently fell through to the legacy single token slot,
which after any drift between the legacy mirror and the current profile could
belong to a different organization. The command would then run as the wrong
org with no indication to the user.
Reproduction (conceptual):
- profiles.json currentProfile = corpA
- corpA's keychain slot is unreadable, legacy single slot still holds corpB
- any read command (no --profile) silently used corpB's token
Fix: when a profile is resolved but its slot read fails and no --profile was
given, only fall back to the legacy single slot when its CorpID matches the
resolved profile (same org); otherwise return the original error instead of
acting as a different organization. The no-profile legacy path (pre-migration
installs with no resolved profile) is unchanged.
Tests:
- Covered by the existing internal/auth suite under go test -race; the
same-org fallback preserves the legacy-mirror case while the cross-org
case now surfaces the read error.
* feat(skill): document multi-org profile usage and always ship dws-shared
The skills had no guidance on the multi-profile capability, so an agent would
treat the CLI as single-org: when a lookup missed in the current org it would
give up or ask the user instead of searching other logged-in orgs. The multi
skill set also referenced a `dws-shared` prerequisite that was never actually
installed, and the only multi-org hints lived inline in three product skills.
This adds, in source only:
- A "multi-org / profile" section in the mono SKILL.md (concept, commands,
cross-org rule, aggregation, safety guardrails) plus a decision-tree entry,
trigger conditions, and a corrected logout danger-table row (logout removes
all orgs by default; removing the primary silently re-elects a new primary,
confirm before removing the primary).
- A standalone skills/multi/dingtalk-profile skill mirroring the same content.
- A new skills/multi/dws-shared skill that carries auth, global flags and the
multi-org rule, so every product skill's PREREQUISITE resolves and all
read/search skills inherit the cross-org behavior without per-skill edits.
- Cross-org fallback notes on dingtalk-aisearch / chat / contact.
To guarantee the prerequisite actually ships, multi-mode install now force-
includes dws-shared even when --skill / --exclude narrows the set (no-op when
the source has no dws-shared, preserving older layouts).
Tests:
- internal/app: TestP1SharedAlwaysIncludedWithSkillFilter installs with
`-s aitable` and asserts dws-shared still lands in the destination;
TestP1SharedNoopWhenAbsent guards the older-layout no-op.
- go test -race ./internal/auth/... ./internal/app/... passes.
---------
Co-authored-by: shangguanxuan.sgx <shangguanxuan.sgx@alibaba-inc.com>
Co-authored-by: qinze <audanye@gmail.com>
1309 lines
44 KiB
Go
1309 lines
44 KiB
Go
// 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 app
|
||
|
||
import (
|
||
"bytes"
|
||
"context"
|
||
"encoding/base64"
|
||
"encoding/json"
|
||
"fmt"
|
||
"io"
|
||
"os"
|
||
"path/filepath"
|
||
"runtime"
|
||
"strings"
|
||
"time"
|
||
|
||
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/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
|
||
Recommend bool
|
||
Yes bool
|
||
TargetCorpID string
|
||
}
|
||
|
||
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: "认证管理",
|
||
Long: "管理钉钉 CLI 的认证凭证。支持 OAuth 扫码登录和 Device Flow。",
|
||
Args: cobra.NoArgs,
|
||
TraverseChildren: true,
|
||
DisableAutoGenTag: true,
|
||
RunE: func(cmd *cobra.Command, args []string) error {
|
||
return cmd.Help()
|
||
},
|
||
}
|
||
|
||
if !edition.Get().HideAuthLogin {
|
||
cmd.AddCommand(newAuthLoginCommand(patCaller))
|
||
}
|
||
cmd.AddCommand(
|
||
newAuthLogoutCommand(),
|
||
newAuthStatusCommand(),
|
||
newAuthExportCommand(),
|
||
newAuthImportCommand(),
|
||
newAuthExchangeCommand(),
|
||
newAuthResetCommand(),
|
||
)
|
||
return cmd
|
||
}
|
||
|
||
func newAuthLoginCommand(patCaller edition.ToolCaller) *cobra.Command {
|
||
cmd := &cobra.Command{
|
||
Use: "login",
|
||
Short: "登录钉钉(自动刷新 token,必要时扫码)",
|
||
Long: `登录钉钉并获取认证凭证。
|
||
|
||
支持的登录方式:
|
||
- OAuth Loopback 流 (默认): 本机自动起 127.0.0.1 监听接收回调,浏览器授权后自动完成
|
||
- OAuth 设备流 (--device): 显示 user_code + 短 URL,适合 SSH 远程 / 容器 / 无头环境
|
||
- 直接提供 Token (--token): 跳过授权,使用已有 token
|
||
|
||
不支持的登录方式:
|
||
- 邮箱/密码登录
|
||
- 手机号/验证码登录
|
||
- 应用凭证 (AppKey/AppSecret) 直接登录
|
||
|
||
注意: SSH 远程或无头环境(无本地浏览器可访问远端的 127.0.0.1)请使用 --device,
|
||
否则 OAuth 回调会跳到本机不可达的 127.0.0.1 链接,授权完成后无法回写 token。
|
||
|
||
示例:
|
||
dws auth login # 本机登录并新增/刷新一个组织 profile
|
||
dws auth login --profile <corpId> # 指定本次授权目标组织,不持久切换当前组织
|
||
dws auth login --recommend # 无交互批量授权服务端推荐权限
|
||
dws auth login --device # SSH 远程 / 无头环境登录 (设备流)
|
||
dws auth login --force # 兼容保留;login 默认已忽略缓存并进入授权流程
|
||
dws auth login --token xxx # 使用指定 token`,
|
||
DisableAutoGenTag: true,
|
||
RunE: func(cmd *cobra.Command, args []string) error {
|
||
cfg, err := resolveAuthLoginConfig(cmd)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
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) != "":
|
||
tokenData = &authpkg.TokenData{
|
||
AccessToken: cfg.Token,
|
||
ExpiresAt: time.Now().Add(config.ManualTokenExpiry),
|
||
}
|
||
if err := authpkg.SaveTokenData(configDir, tokenData); err != nil {
|
||
return apperrors.NewInternal(fmt.Sprintf("failed to persist auth token: %v", err))
|
||
}
|
||
case cfg.Device:
|
||
loginCtx, cancel := context.WithTimeout(cmd.Context(), config.DeviceFlowTimeout)
|
||
defer cancel()
|
||
|
||
provider := authpkg.NewDeviceFlowProvider(configDir, nil)
|
||
provider.Output = cmd.ErrOrStderr()
|
||
provider.NoBrowser, _ = cmd.Flags().GetBool("no-browser")
|
||
tokenData, err = provider.Login(loginCtx)
|
||
if err != nil {
|
||
return apperrors.NewAuth(fmt.Sprintf("device authorization failed: %v", err))
|
||
}
|
||
default:
|
||
loginCtx, cancel := context.WithTimeout(cmd.Context(), config.OAuthFlowTimeout)
|
||
defer cancel()
|
||
|
||
provider := authpkg.NewOAuthProvider(configDir, nil)
|
||
provider.Output = cmd.ErrOrStderr()
|
||
provider.NoBrowser, _ = cmd.Flags().GetBool("no-browser")
|
||
provider.TargetCorpID = cfg.TargetCorpID
|
||
configureOAuthProviderCompatibility(provider, configDir)
|
||
tokenData, err = provider.Login(loginCtx, authLoginForcesAuthorization(cfg))
|
||
if err != nil {
|
||
return apperrors.NewAuth(fmt.Sprintf("dingtalk login failed: %v", err))
|
||
}
|
||
}
|
||
|
||
ResetRuntimeTokenCache()
|
||
clearCompatCache()
|
||
if tokenData != nil && strings.TrimSpace(tokenData.CorpID) != "" {
|
||
_ = enrichAuthLoginProfileFromContact(cmd.Context(), configDir, patCaller, tokenData)
|
||
ResetRuntimeTokenCache()
|
||
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
|
||
if strings.EqualFold(strings.TrimSpace(format), "json") && !humanAuthMode {
|
||
if err := runPostLoginAuthorization(); err != nil {
|
||
return err
|
||
}
|
||
return writeAuthLoginJSON(w, tokenData, authLoginForcesAuthorization(cfg))
|
||
}
|
||
|
||
// Default table output
|
||
if err := runPostLoginAuthorization(); err != nil {
|
||
return err
|
||
}
|
||
fmt.Fprintln(w)
|
||
if !cfg.Device && tokenData != nil && tokenData.IsAccessTokenValid() && !authLoginForcesAuthorization(cfg) {
|
||
fmt.Fprintln(w, authLoginStatusLine("Token 有效,无需重新登录"))
|
||
} else {
|
||
fmt.Fprintln(w, authLoginStatusLine("登录成功!"))
|
||
}
|
||
if tokenData != nil {
|
||
if tokenData.CorpName != "" {
|
||
fmt.Fprintln(w, authLoginInfoLine("企业", tokenData.CorpName))
|
||
}
|
||
if tokenData.CorpID != "" {
|
||
fmt.Fprintln(w, authLoginInfoLine("企业 ID", tokenData.CorpID))
|
||
}
|
||
if tokenData.UserName != "" {
|
||
fmt.Fprintln(w, authLoginInfoLine("用户", tokenData.UserName))
|
||
}
|
||
if expiry := authLoginDisplayExpiry(tokenData); expiry != "" {
|
||
fmt.Fprintln(w, authLoginInfoLine("有效期", expiry))
|
||
}
|
||
}
|
||
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, "兼容保留;login 默认已忽略缓存并进入授权流程")
|
||
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")
|
||
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, "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")
|
||
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 {
|
||
cmd := &cobra.Command{
|
||
Use: "logout",
|
||
Short: "清除认证信息(默认退出所有组织)",
|
||
Long: `清除本机钉钉登录态。
|
||
|
||
默认退出所有已登录组织 profile;指定 --profile 时只退出该组织,不影响其他组织。`,
|
||
Example: ` dws auth logout
|
||
dws auth logout --profile <corpId>
|
||
dws auth logout --profile "钉钉"`,
|
||
DisableAutoGenTag: true,
|
||
RunE: func(cmd *cobra.Command, args []string) error {
|
||
configDir := defaultConfigDir()
|
||
profileSelector, err := cmd.Flags().GetString("profile")
|
||
if err != nil {
|
||
return apperrors.NewInternal("failed to read --profile")
|
||
}
|
||
revokeCtx, cancel := context.WithTimeout(cmd.Context(), 15*time.Second)
|
||
defer cancel()
|
||
if strings.TrimSpace(profileSelector) != "" {
|
||
if err := logoutOneProfile(cmd, revokeCtx, configDir, profileSelector); err != nil {
|
||
return err
|
||
}
|
||
} else {
|
||
if err := logoutAllProfiles(cmd, revokeCtx, configDir); err != nil {
|
||
return err
|
||
}
|
||
}
|
||
ResetRuntimeTokenCache()
|
||
clearCompatCache()
|
||
w := cmd.OutOrStdout()
|
||
fmt.Fprintln(w, "[OK] 已清除认证信息")
|
||
if !edition.Get().IsEmbedded {
|
||
fmt.Fprintln(w, "请运行 dws auth login --recommend 重新登录")
|
||
}
|
||
return nil
|
||
},
|
||
}
|
||
cmd.Flags().String("profile", "", "指定要退出的 profile 名或 corpId")
|
||
return cmd
|
||
}
|
||
|
||
func newAuthStatusCommand() *cobra.Command {
|
||
cmd := &cobra.Command{
|
||
Use: "status",
|
||
Short: "查看认证状态",
|
||
Long: `查看当前或指定组织 profile 的认证状态。
|
||
|
||
指定 --profile 时只读取并刷新被选中的 token slot,不会修改 currentProfile。`,
|
||
Example: ` dws auth status
|
||
dws auth status --profile <corpId>
|
||
dws auth status --profile "钉钉"
|
||
dws auth status --profile <corpId> --format json`,
|
||
DisableAutoGenTag: true,
|
||
RunE: func(cmd *cobra.Command, args []string) error {
|
||
configDir := defaultConfigDir()
|
||
profileSelector, err := cmd.Flags().GetString("profile")
|
||
if err != nil {
|
||
return apperrors.NewInternal("failed to read --profile")
|
||
}
|
||
restoreProfile := pushRuntimeProfile(profileSelector)
|
||
defer restoreProfile()
|
||
|
||
authenticated := false
|
||
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 {
|
||
tokenData = updatedData
|
||
refreshed = true
|
||
}
|
||
} else if edition.Get().AutoPurgeToken {
|
||
_ = authpkg.DeleteTokenData(configDir)
|
||
} else if tokenData != nil {
|
||
_ = authpkg.MarkProfileStatus(configDir, tokenData.CorpID, authpkg.ProfileStatusExpired)
|
||
}
|
||
}
|
||
if authStatusAuthenticated(tokenData) {
|
||
authenticated = true
|
||
}
|
||
}
|
||
|
||
// Check if JSON output is requested
|
||
format, _ := cmd.Root().PersistentFlags().GetString("format")
|
||
if strings.EqualFold(strings.TrimSpace(format), "json") {
|
||
return writeAuthStatusJSON(cmd.OutOrStdout(), authenticated, refreshed, tokenData)
|
||
}
|
||
|
||
// Default table output
|
||
w := cmd.OutOrStdout()
|
||
if authenticated {
|
||
if refreshed {
|
||
fmt.Fprintf(w, "%-16s%s\n", "状态:", "已登录 ✅")
|
||
fmt.Fprintln(w, "Token 已自动刷新")
|
||
} else {
|
||
fmt.Fprintf(w, "%-16s%s\n", "状态:", "已登录 ✅")
|
||
}
|
||
if tokenData != nil {
|
||
if tokenData.CorpName != "" {
|
||
fmt.Fprintf(w, "%-16s%s\n", "企业:", tokenData.CorpName)
|
||
}
|
||
if tokenData.CorpID != "" {
|
||
fmt.Fprintf(w, "%-16s%s\n", "企业 ID:", tokenData.CorpID)
|
||
}
|
||
if tokenData.IsRefreshTokenValid() {
|
||
fmt.Fprintf(w, "%-16s%s\n", "Refresh Token:", "有效 ✅")
|
||
} else {
|
||
fmt.Fprintf(w, "%-16s%s\n", "Refresh Token:", "缺失或已过期 ⚠️")
|
||
}
|
||
}
|
||
if updatedAt := authStatusUpdatedAt(tokenData); updatedAt != "" {
|
||
fmt.Fprintf(w, "%-16s%s\n", "有效期:", updatedAt)
|
||
}
|
||
} else {
|
||
fmt.Fprintf(w, "%-16s%s\n", "状态:", "未登录")
|
||
if !edition.Get().IsEmbedded {
|
||
fmt.Fprintln(w, "运行 dws auth login --recommend 进行登录")
|
||
}
|
||
}
|
||
return nil
|
||
},
|
||
}
|
||
cmd.Flags().String("profile", "", "指定要查看的 profile 名或 corpId")
|
||
return cmd
|
||
}
|
||
|
||
func logoutOneProfile(_ *cobra.Command, ctx context.Context, configDir, selector string) error {
|
||
if _, err := authpkg.ResolveProfile(configDir, selector); err != nil {
|
||
return apperrors.NewValidation(err.Error())
|
||
}
|
||
restoreProfile := pushRuntimeProfile(selector)
|
||
defer restoreProfile()
|
||
_ = authpkg.RevokeTokenRemote(ctx)
|
||
if err := authpkg.DeleteTokenDataForProfile(configDir, selector); err != nil {
|
||
return apperrors.NewInternal(fmt.Sprintf("failed to clear token data: %v", err))
|
||
}
|
||
return nil
|
||
}
|
||
|
||
func logoutAllProfiles(_ *cobra.Command, ctx context.Context, configDir string) error {
|
||
if err := authpkg.EnsureProfilesMigration(configDir); err != nil {
|
||
return apperrors.NewInternal(fmt.Sprintf("failed to migrate profiles: %v", err))
|
||
}
|
||
cfg, err := authpkg.LoadProfiles(configDir)
|
||
if err != nil {
|
||
return apperrors.NewInternal(fmt.Sprintf("failed to load profiles: %v", err))
|
||
}
|
||
if cfg == nil || len(cfg.Profiles) == 0 {
|
||
_ = authpkg.RevokeTokenRemote(ctx)
|
||
} else {
|
||
for _, profile := range cfg.Profiles {
|
||
restoreProfile := pushRuntimeProfile(profile.CorpID)
|
||
_ = authpkg.RevokeTokenRemote(ctx)
|
||
restoreProfile()
|
||
}
|
||
}
|
||
if err := authpkg.DeleteAllTokenData(configDir); err != nil {
|
||
return apperrors.NewInternal(fmt.Sprintf("failed to clear token data: %v", err))
|
||
}
|
||
return nil
|
||
}
|
||
|
||
func pushRuntimeProfile(selector string) func() {
|
||
selector = strings.TrimSpace(selector)
|
||
if selector == "" {
|
||
return func() {}
|
||
}
|
||
previous := authpkg.RuntimeProfile()
|
||
authpkg.SetRuntimeProfile(selector)
|
||
return func() {
|
||
authpkg.SetRuntimeProfile(previous)
|
||
}
|
||
}
|
||
|
||
func cleanupAuthConfigIfNoProfiles(configDir string) {
|
||
cfg, err := authpkg.LoadProfiles(configDir)
|
||
if err == nil && len(cfg.Profiles) > 0 {
|
||
return
|
||
}
|
||
if authpkg.TokenDataExistsKeychain() {
|
||
return
|
||
}
|
||
appKey, _ := authpkg.ResolveAppCredentials(configDir)
|
||
if appKey != "" {
|
||
_ = authpkg.DeleteAppTokenData(appKey)
|
||
}
|
||
_ = authpkg.DeleteAppConfig(configDir)
|
||
_ = os.Remove(filepath.Join(configDir, "mcp_url"))
|
||
_ = os.Remove(filepath.Join(configDir, "token"))
|
||
_ = authpkg.DeleteTokenMarker(configDir)
|
||
}
|
||
|
||
func newAuthExportCommand() *cobra.Command {
|
||
cmd := &cobra.Command{
|
||
Use: "export",
|
||
Short: "导出可迁移认证包",
|
||
Long: `导出包含 refresh token 与解密材料的认证包,便于在另一台 Linux 沙箱中导入。
|
||
|
||
包内包含 ~/.local/share/dws-cli 加密 keychain 与 ~/.dws 必要配置,不含 token 明文。
|
||
|
||
示例:
|
||
dws auth export -o dws-auth.tar.gz
|
||
dws auth export --base64 > dws-auth.b64`,
|
||
DisableAutoGenTag: true,
|
||
RunE: func(cmd *cobra.Command, args []string) error {
|
||
output, err := cmd.Flags().GetString("output")
|
||
if err != nil {
|
||
return apperrors.NewInternal("failed to read --output")
|
||
}
|
||
asBase64, err := cmd.Flags().GetBool("base64")
|
||
if err != nil {
|
||
return apperrors.NewInternal("failed to read --base64")
|
||
}
|
||
output = strings.TrimSpace(output)
|
||
if !asBase64 && output == "" {
|
||
return apperrors.NewValidation("--output is required unless --base64 is used")
|
||
}
|
||
if !authpkg.PortableExportSupported() {
|
||
return apperrors.NewValidation(fmt.Sprintf(
|
||
"macOS 默认将 DEK 存在系统 Keychain,导出的包无法在其它机器解密;请设置 %s=1 后重新登录再导出",
|
||
keychain.DisableKeychainEnv,
|
||
))
|
||
}
|
||
if !authpkg.PortableAuthSourceReady() {
|
||
return apperrors.NewValidation("尚未登录,请先运行 dws auth login --recommend")
|
||
}
|
||
|
||
var bundle bytes.Buffer
|
||
if err := authpkg.ExportPortableAuthBundle(defaultConfigDir(), &bundle); err != nil {
|
||
return apperrors.NewInternal(fmt.Sprintf("failed to export auth bundle: %v", err))
|
||
}
|
||
|
||
if asBase64 {
|
||
payload := []byte(base64.StdEncoding.EncodeToString(bundle.Bytes()) + "\n")
|
||
if output == "" {
|
||
_, err := cmd.OutOrStdout().Write(payload)
|
||
return err
|
||
}
|
||
if err := helpers.AtomicWrite(output, payload, config.FilePerm); err != nil {
|
||
return apperrors.NewInternal(fmt.Sprintf("failed to write auth bundle: %v", err))
|
||
}
|
||
fmt.Fprintf(cmd.OutOrStdout(), "[OK] 已导出认证包: %s\n", output)
|
||
fmt.Fprintf(cmd.ErrOrStderr(), "认证包含敏感凭据,用完请删除: rm -P %s\n", output)
|
||
return nil
|
||
}
|
||
|
||
if err := helpers.AtomicWrite(output, bundle.Bytes(), config.FilePerm); err != nil {
|
||
return apperrors.NewInternal(fmt.Sprintf("failed to write auth bundle: %v", err))
|
||
}
|
||
fmt.Fprintf(cmd.OutOrStdout(), "[OK] 已导出认证包: %s\n", output)
|
||
fmt.Fprintf(cmd.ErrOrStderr(), "认证包含敏感凭据,用完请删除: rm -P %s\n", output)
|
||
return nil
|
||
},
|
||
}
|
||
cmd.Flags().StringP("output", "o", "", "认证包输出路径")
|
||
cmd.Flags().Bool("base64", false, "将认证包编码为 base64,便于复制粘贴")
|
||
return cmd
|
||
}
|
||
|
||
func newAuthImportCommand() *cobra.Command {
|
||
cmd := &cobra.Command{
|
||
Use: "import",
|
||
Short: "导入可迁移认证包",
|
||
Long: `从 dws auth export 生成的 tar.gz 或 base64 文件恢复认证。
|
||
|
||
导入后请运行 dws auth status 确认 refresh token 仍有效。
|
||
|
||
示例:
|
||
dws auth import -i dws-auth.tar.gz
|
||
dws auth import -i dws-auth.b64 --base64`,
|
||
DisableAutoGenTag: true,
|
||
RunE: func(cmd *cobra.Command, args []string) error {
|
||
input, err := cmd.Flags().GetString("input")
|
||
if err != nil {
|
||
return apperrors.NewInternal("failed to read --input")
|
||
}
|
||
input = strings.TrimSpace(input)
|
||
if input == "" {
|
||
return apperrors.NewValidation("--input is required")
|
||
}
|
||
asBase64, err := cmd.Flags().GetBool("base64")
|
||
if err != nil {
|
||
return apperrors.NewInternal("failed to read --base64")
|
||
}
|
||
force, err := cmd.Flags().GetBool("force")
|
||
if err != nil {
|
||
return apperrors.NewInternal("failed to read --force")
|
||
}
|
||
|
||
configDir := defaultConfigDir()
|
||
if !force && authpkg.PortableAuthTargetPopulated(configDir) {
|
||
return apperrors.NewValidation("检测到已有登录态,请使用 --force 确认覆盖")
|
||
}
|
||
|
||
payload, err := os.ReadFile(input)
|
||
if err != nil {
|
||
return apperrors.NewInternal(fmt.Sprintf("failed to read auth bundle: %v", err))
|
||
}
|
||
if asBase64 {
|
||
payload, err = base64.StdEncoding.DecodeString(strings.TrimSpace(string(payload)))
|
||
if err != nil {
|
||
return apperrors.NewValidation(fmt.Sprintf("invalid base64 auth bundle: %v", err))
|
||
}
|
||
}
|
||
report, err := authpkg.ImportPortableAuthBundle(configDir, bytes.NewReader(payload))
|
||
if err != nil {
|
||
return apperrors.NewInternal(fmt.Sprintf("failed to import auth bundle: %v", err))
|
||
}
|
||
if report.OSMismatch {
|
||
fmt.Fprintf(cmd.ErrOrStderr(), "警告: 认证包来自 %s,当前系统为 %s,请确认解密材料兼容\n", report.BundleOS, runtime.GOOS)
|
||
}
|
||
ResetRuntimeTokenCache()
|
||
clearCompatCache()
|
||
fmt.Fprintln(cmd.OutOrStdout(), "[OK] 已导入认证包")
|
||
fmt.Fprintln(cmd.OutOrStdout(), "请运行 dws auth status 验证登录状态")
|
||
return nil
|
||
},
|
||
}
|
||
cmd.Flags().StringP("input", "i", "", "认证包输入路径")
|
||
cmd.Flags().Bool("base64", false, "输入为 base64 编码的认证包")
|
||
cmd.Flags().Bool("force", false, "覆盖已有登录态")
|
||
return cmd
|
||
}
|
||
|
||
func newAuthExchangeCommand() *cobra.Command {
|
||
cmd := &cobra.Command{
|
||
Use: "exchange",
|
||
Short: "Exchange an authorization code for credentials",
|
||
Hidden: true,
|
||
DisableAutoGenTag: true,
|
||
RunE: func(cmd *cobra.Command, args []string) error {
|
||
code, err := cmd.Flags().GetString("code")
|
||
if err != nil {
|
||
return apperrors.NewInternal("failed to read --code")
|
||
}
|
||
code = strings.TrimSpace(code)
|
||
if code == "" {
|
||
return apperrors.NewValidation("--code is required")
|
||
}
|
||
uid, err := cmd.Flags().GetString("uid")
|
||
if err != nil {
|
||
return apperrors.NewInternal("failed to read --uid")
|
||
}
|
||
|
||
configDir := defaultConfigDir()
|
||
provider := authpkg.NewOAuthProvider(configDir, nil)
|
||
configureOAuthProviderCompatibility(provider, configDir)
|
||
exchangeCtx, cancel := context.WithTimeout(cmd.Context(), time.Minute)
|
||
defer cancel()
|
||
tokenData, err := provider.ExchangeAuthCode(exchangeCtx, code, strings.TrimSpace(uid))
|
||
if err != nil {
|
||
return apperrors.NewAuth(fmt.Sprintf("failed to exchange authorization code: %v", err))
|
||
}
|
||
ResetRuntimeTokenCache()
|
||
clearCompatCache()
|
||
|
||
w := cmd.OutOrStdout()
|
||
fmt.Fprintln(w, "[OK] 授权码兑换成功!")
|
||
if strings.TrimSpace(uid) != "" {
|
||
fmt.Fprintf(w, "%-16s%s\n", "用户:", strings.TrimSpace(uid))
|
||
}
|
||
if strings.TrimSpace(tokenData.CorpID) != "" {
|
||
fmt.Fprintf(w, "%-16s%s\n", "企业 ID:", tokenData.CorpID)
|
||
}
|
||
if !tokenData.ExpiresAt.IsZero() {
|
||
fmt.Fprintf(w, "%-16s%s\n", "有效期:", authLoginFormatExpiry(tokenData.ExpiresAt))
|
||
}
|
||
return nil
|
||
},
|
||
}
|
||
cmd.Flags().String("code", "", "Authorization code")
|
||
cmd.Flags().String("uid", "", "Optional user identifier for compatibility")
|
||
cmd.Flags().String("client-id", "", "Compatibility flag")
|
||
cmd.Flags().String("authorize-url", "", "Compatibility flag")
|
||
cmd.Flags().String("token-url", "", "Compatibility flag")
|
||
cmd.Flags().String("refresh-url", "", "Compatibility flag")
|
||
cmd.Flags().String("redirect-url", "", "Compatibility flag")
|
||
cmd.Flags().String("scopes", "", "Compatibility flag")
|
||
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.DeleteAllTokenData(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"))
|
||
_ = authpkg.DeleteAppConfig(configDir)
|
||
ResetRuntimeTokenCache()
|
||
clearCompatCache()
|
||
w := cmd.OutOrStdout()
|
||
fmt.Fprintln(w, "[OK] 认证信息已重置")
|
||
if !edition.Get().IsEmbedded {
|
||
fmt.Fprintln(w, "请运行 dws auth login --recommend 重新登录")
|
||
}
|
||
return nil
|
||
},
|
||
}
|
||
}
|
||
|
||
func timeOrEmpty(t time.Time) string {
|
||
if t.IsZero() {
|
||
return ""
|
||
}
|
||
return t.UTC().Format(time.RFC3339)
|
||
}
|
||
|
||
func authLoginFormatExpiry(t time.Time) string {
|
||
remaining := time.Until(t)
|
||
if remaining <= 0 {
|
||
return "已过期"
|
||
}
|
||
if remaining > 24*time.Hour {
|
||
return fmt.Sprintf("%.0f 天后", remaining.Hours()/24)
|
||
}
|
||
return fmt.Sprintf("%.0f 小时后", remaining.Hours())
|
||
}
|
||
|
||
// authLoginDisplayExpiry 返回用于显示的有效期(优先显示 refresh token 有效期)
|
||
func authLoginDisplayExpiry(data *authpkg.TokenData) string {
|
||
if data == nil {
|
||
return ""
|
||
}
|
||
// 优先使用 refresh token 有效期(更长,对用户更有意义)
|
||
if data.IsRefreshTokenValid() {
|
||
return authLoginFormatExpiry(data.RefreshExpAt)
|
||
}
|
||
// 回退到 access token 有效期
|
||
if !data.ExpiresAt.IsZero() {
|
||
return authLoginFormatExpiry(data.ExpiresAt)
|
||
}
|
||
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 {
|
||
_ = os.RemoveAll(store.Root)
|
||
}
|
||
}
|
||
|
||
func resolveAuthLoginConfig(cmd *cobra.Command) (authLoginConfig, error) {
|
||
token, err := cmd.Flags().GetString("token")
|
||
if err != nil {
|
||
return authLoginConfig{}, apperrors.NewInternal("failed to read --token")
|
||
}
|
||
device, err := cmd.Flags().GetBool("device")
|
||
if err != nil {
|
||
return authLoginConfig{}, apperrors.NewInternal("failed to read --device")
|
||
}
|
||
force, err := cmd.Flags().GetBool("force")
|
||
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
|
||
profileSelector := ""
|
||
if cmd.Root() != nil {
|
||
yes, _ = cmd.Root().PersistentFlags().GetBool("yes")
|
||
profileSelector, _ = cmd.Root().PersistentFlags().GetString("profile")
|
||
}
|
||
targetCorpID, err := resolveAuthLoginTargetCorpID(defaultConfigDir(), profileSelector)
|
||
if err != nil {
|
||
return authLoginConfig{}, err
|
||
}
|
||
return authLoginConfig{
|
||
Token: strings.TrimSpace(token),
|
||
Force: force,
|
||
Device: device,
|
||
Recommend: recommend,
|
||
Yes: yes,
|
||
TargetCorpID: targetCorpID,
|
||
}, nil
|
||
}
|
||
|
||
func authLoginForcesAuthorization(_ authLoginConfig) bool {
|
||
return true
|
||
}
|
||
|
||
func resolveAuthLoginTargetCorpID(configDir, selector string) (string, error) {
|
||
selector = strings.TrimSpace(selector)
|
||
if selector == "" {
|
||
return "", nil
|
||
}
|
||
if profile, err := authpkg.ResolveProfile(configDir, selector); err == nil && profile != nil {
|
||
return strings.TrimSpace(profile.CorpID), nil
|
||
}
|
||
if strings.HasPrefix(selector, "ding") {
|
||
return selector, nil
|
||
}
|
||
return "", apperrors.NewValidation(fmt.Sprintf("profile %q not found", selector))
|
||
}
|
||
|
||
type contactProfileIdentity struct {
|
||
CorpID string
|
||
CorpName string
|
||
UserID string
|
||
UserName string
|
||
}
|
||
|
||
func enrichAuthLoginProfileFromContact(ctx context.Context, configDir string, caller edition.ToolCaller, data *authpkg.TokenData) error {
|
||
if caller == nil || data == nil {
|
||
return nil
|
||
}
|
||
corpID := strings.TrimSpace(data.CorpID)
|
||
if corpID == "" {
|
||
return nil
|
||
}
|
||
if strings.TrimSpace(data.CorpName) != "" && strings.TrimSpace(data.UserID) != "" && strings.TrimSpace(data.UserName) != "" {
|
||
return nil
|
||
}
|
||
|
||
restoreProfile := pushRuntimeProfile(corpID)
|
||
defer restoreProfile()
|
||
ResetRuntimeTokenCache()
|
||
|
||
result, err := caller.CallTool(ctx, "contact", "get_current_user_profile", map[string]any{
|
||
"profile": corpID,
|
||
})
|
||
if err != nil {
|
||
return err
|
||
}
|
||
identity, ok := contactProfileIdentityFromToolResult(result)
|
||
if !ok {
|
||
return nil
|
||
}
|
||
if identity.CorpID != "" && identity.CorpID != corpID {
|
||
return fmt.Errorf("contact profile corpId %q does not match login corpId %q", identity.CorpID, corpID)
|
||
}
|
||
|
||
updated := *data
|
||
if identity.CorpName != "" {
|
||
updated.CorpName = identity.CorpName
|
||
}
|
||
if identity.UserID != "" {
|
||
updated.UserID = identity.UserID
|
||
}
|
||
if identity.UserName != "" {
|
||
updated.UserName = identity.UserName
|
||
}
|
||
if updated.CorpName == data.CorpName && updated.UserID == data.UserID && updated.UserName == data.UserName {
|
||
return nil
|
||
}
|
||
if err := authpkg.SaveTokenData(configDir, &updated); err != nil {
|
||
return err
|
||
}
|
||
*data = updated
|
||
return nil
|
||
}
|
||
|
||
func contactProfileIdentityFromToolResult(result *edition.ToolResult) (contactProfileIdentity, bool) {
|
||
if result == nil {
|
||
return contactProfileIdentity{}, false
|
||
}
|
||
for _, block := range result.Content {
|
||
if strings.TrimSpace(block.Text) == "" {
|
||
continue
|
||
}
|
||
if identity, ok := contactProfileIdentityFromJSON([]byte(block.Text)); ok {
|
||
return identity, true
|
||
}
|
||
}
|
||
return contactProfileIdentity{}, false
|
||
}
|
||
|
||
func contactProfileIdentityFromJSON(data []byte) (contactProfileIdentity, bool) {
|
||
var payload struct {
|
||
Result []struct {
|
||
OrgEmployeeModel struct {
|
||
CorpID string `json:"corpId"`
|
||
OrgName string `json:"orgName"`
|
||
UserID string `json:"userId"`
|
||
UserIDLower string `json:"userid"`
|
||
OrgUserName string `json:"orgUserName"`
|
||
Name string `json:"name"`
|
||
} `json:"orgEmployeeModel"`
|
||
} `json:"result"`
|
||
}
|
||
if err := json.Unmarshal(data, &payload); err != nil {
|
||
return contactProfileIdentity{}, false
|
||
}
|
||
if len(payload.Result) == 0 {
|
||
return contactProfileIdentity{}, false
|
||
}
|
||
org := payload.Result[0].OrgEmployeeModel
|
||
identity := contactProfileIdentity{
|
||
CorpID: strings.TrimSpace(org.CorpID),
|
||
CorpName: strings.TrimSpace(org.OrgName),
|
||
UserID: firstNonEmptyString(org.UserID, org.UserIDLower),
|
||
UserName: firstNonEmptyString(org.OrgUserName, org.Name),
|
||
}
|
||
return identity, identity.CorpID != "" || identity.CorpName != "" || identity.UserID != "" || identity.UserName != ""
|
||
}
|
||
|
||
func firstNonEmptyString(values ...string) string {
|
||
for _, value := range values {
|
||
if trimmed := strings.TrimSpace(value); trimmed != "" {
|
||
return trimmed
|
||
}
|
||
}
|
||
return ""
|
||
}
|
||
|
||
func authStatusAuthenticated(data *authpkg.TokenData) bool {
|
||
if data == nil {
|
||
return false
|
||
}
|
||
return data.IsAccessTokenValid() || data.IsRefreshTokenValid()
|
||
}
|
||
|
||
func authStatusUpdatedAt(data *authpkg.TokenData) string {
|
||
if data == nil {
|
||
return ""
|
||
}
|
||
if data.IsAccessTokenValid() {
|
||
return timeOrEmpty(data.ExpiresAt)
|
||
}
|
||
if data.IsRefreshTokenValid() {
|
||
return timeOrEmpty(data.RefreshExpAt)
|
||
}
|
||
return ""
|
||
}
|
||
|
||
// 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)
|
||
}
|