From 74756590f53e44597da3ec4db7db92249c992d62 Mon Sep 17 00:00:00 2001 From: "shangguanxuan.sgx" Date: Thu, 25 Jun 2026 16:24:59 +0800 Subject: [PATCH 01/22] feat(auth): support multi-profile login --- internal/app/auth_command.go | 141 ++++++-- internal/app/flags.go | 2 + internal/app/profile_command.go | 222 +++++++++++++ internal/app/root.go | 19 +- internal/app/runner.go | 51 +-- internal/auth/keychain_store.go | 56 +++- internal/auth/oauth_provider.go | 3 + internal/auth/profiles.go | 558 ++++++++++++++++++++++++++++++++ internal/auth/token.go | 118 ++++++- internal/auth/token_test.go | 177 ++++++++++ 10 files changed, 1293 insertions(+), 54 deletions(-) create mode 100644 internal/app/profile_command.go create mode 100644 internal/auth/profiles.go diff --git a/internal/app/auth_command.go b/internal/app/auth_command.go index 5923b64d..e6ff32bb 100644 --- a/internal/app/auth_command.go +++ b/internal/app/auth_command.go @@ -373,58 +373,65 @@ func selectLoginRecommendScopeMode() (pat.LoginRecommendScopeMode, error) { } func newAuthLogoutCommand() *cobra.Command { - return &cobra.Command{ + cmd := &cobra.Command{ Use: "logout", Short: "清除认证信息", DisableAutoGenTag: true, RunE: func(cmd *cobra.Command, args []string) error { configDir := defaultConfigDir() - revokeCtx, cancel := context.WithTimeout(cmd.Context(), 15*time.Second) - defer cancel() - _ = authpkg.RevokeTokenRemote(revokeCtx) - - // Load token data to get associated clientId before deletion - var storedClientID string - if tokenData, err := authpkg.LoadTokenData(configDir); err == nil && tokenData != nil { - storedClientID = tokenData.ClientID + profileSelector, err := cmd.Flags().GetString("profile") + if err != nil { + return apperrors.NewInternal("failed to read --profile") } - - if err := authpkg.DeleteTokenData(configDir); err != nil { - return apperrors.NewInternal(fmt.Sprintf("failed to clear token data: %v", err)) + all, err := cmd.Flags().GetBool("all") + if err != nil { + return apperrors.NewInternal("failed to read --all") } - // Clean up associated client secret and app token from keychain - if storedClientID != "" { - _ = authpkg.DeleteClientSecret(storedClientID) - _ = authpkg.DeleteAppTokenData(storedClientID) + if all && strings.TrimSpace(profileSelector) != "" { + return apperrors.NewValidation("--profile and --all cannot be used together") } - // Also try cleaning app token using appKey from app config - if appKey, _ := authpkg.ResolveAppCredentials(configDir); appKey != "" && appKey != storedClientID { - _ = authpkg.DeleteAppTokenData(appKey) + revokeCtx, cancel := context.WithTimeout(cmd.Context(), 15*time.Second) + defer cancel() + if all { + if err := logoutNonPrimaryProfiles(cmd, revokeCtx, configDir); err != nil { + return err + } + } else if err := logoutOneProfile(cmd, revokeCtx, configDir, profileSelector); err != nil { + return err } - // Clean up app credentials (app.json + keychain secret) - _ = authpkg.DeleteAppConfig(configDir) - _ = os.Remove(filepath.Join(configDir, "mcp_url")) - _ = os.Remove(filepath.Join(configDir, "token")) - _ = os.Remove(filepath.Join(configDir, "token.json")) + cleanupAuthConfigIfNoProfiles(configDir) ResetRuntimeTokenCache() clearCompatCache() w := cmd.OutOrStdout() - fmt.Fprintln(w, "[OK] 已清除所有认证信息") + if all { + fmt.Fprintln(w, "[OK] 已清除所有非主 profile 认证信息") + } else { + fmt.Fprintln(w, "[OK] 已清除认证信息") + } if !edition.Get().IsEmbedded { fmt.Fprintln(w, "请运行 dws auth login --recommend 重新登录") } return nil }, } + cmd.Flags().String("profile", "", "指定要退出的 profile 名或 corpId") + cmd.Flags().Bool("all", false, "退出所有非主 profile") + return cmd } func newAuthStatusCommand() *cobra.Command { - return &cobra.Command{ + cmd := &cobra.Command{ Use: "status", Short: "查看认证状态", 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 @@ -444,6 +451,8 @@ func newAuthStatusCommand() *cobra.Command { } } else if edition.Get().AutoPurgeToken { _ = authpkg.DeleteTokenData(configDir) + } else if tokenData != nil { + _ = authpkg.MarkProfileStatus(configDir, tokenData.CorpID, authpkg.ProfileStatusExpired) } } if authStatusAuthenticated(tokenData) { @@ -485,6 +494,83 @@ func newAuthStatusCommand() *cobra.Command { return nil }, } + cmd.Flags().String("profile", "", "指定要查看的 profile 名或 corpId") + return cmd +} + +func logoutOneProfile(_ *cobra.Command, ctx context.Context, configDir, selector string) error { + selected, err := authpkg.ResolveProfile(configDir, selector) + if err != nil { + return apperrors.NewValidation(err.Error()) + } + if selected != nil { + cfg, loadErr := authpkg.LoadProfiles(configDir) + if loadErr != nil { + return apperrors.NewInternal(fmt.Sprintf("failed to load profiles: %v", loadErr)) + } + if len(cfg.Profiles) > 1 && selected.CorpID == cfg.PrimaryProfile { + return apperrors.NewValidation("primary profile cannot be logged out while other profiles exist; switch to another profile or use auth reset") + } + } + 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 logoutNonPrimaryProfiles(_ *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)) + } + for _, profile := range cfg.Profiles { + if profile.CorpID == cfg.PrimaryProfile { + continue + } + restoreProfile := pushRuntimeProfile(profile.CorpID) + _ = authpkg.RevokeTokenRemote(ctx) + restoreProfile() + if err := authpkg.DeleteTokenDataForProfile(configDir, profile.CorpID); err != nil { + return apperrors.NewInternal(fmt.Sprintf("failed to clear profile %s: %v", profile.Name, 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 { @@ -683,11 +769,12 @@ func newAuthResetCommand() *cobra.Command { DisableAutoGenTag: true, RunE: func(cmd *cobra.Command, args []string) error { configDir := defaultConfigDir() - if err := authpkg.DeleteTokenData(configDir); err != nil { + 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() diff --git a/internal/app/flags.go b/internal/app/flags.go index 1a869c27..6757c96e 100644 --- a/internal/app/flags.go +++ b/internal/app/flags.go @@ -29,6 +29,7 @@ type GlobalFlags struct { JQ string Mock bool Output string + Profile string Timeout int Token string Verbose bool @@ -46,6 +47,7 @@ func bindPersistentFlags(cmd *cobra.Command, flags *GlobalFlags) { cmd.PersistentFlags().BoolVar(&flags.Mock, "mock", false, "使用 Mock 数据 (开发调试用)") cmd.PersistentFlags().StringVarP(&flags.Output, "output", "o", "", "Write command output to a file") _ = cmd.PersistentFlags().MarkHidden("output") + cmd.PersistentFlags().StringVar(&flags.Profile, "profile", "", "一次性指定本次命令使用的组织 profile 名或 corpId") cmd.PersistentFlags().IntVar(&flags.Timeout, "timeout", 30, "HTTP 请求超时时间 (秒)") cmd.PersistentFlags().StringVar(&flags.Token, "token", "", "Override the configured API token") _ = cmd.PersistentFlags().MarkHidden("token") diff --git a/internal/app/profile_command.go b/internal/app/profile_command.go new file mode 100644 index 00000000..e86bf5fb --- /dev/null +++ b/internal/app/profile_command.go @@ -0,0 +1,222 @@ +// 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 ( + "encoding/json" + "fmt" + "io" + "strings" + + authpkg "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/auth" + apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors" + "github.com/spf13/cobra" +) + +func newProfileCommand() *cobra.Command { + cmd := &cobra.Command{ + Use: "profile", + Short: "组织 profile 管理", + Args: cobra.NoArgs, + TraverseChildren: true, + DisableAutoGenTag: true, + RunE: func(cmd *cobra.Command, args []string) error { + return cmd.Help() + }, + } + cmd.AddCommand(newProfileListCommand(), newProfileUseCommand()) + return cmd +} + +func newProfileListCommand() *cobra.Command { + return &cobra.Command{ + Use: "list", + Aliases: []string{"ls"}, + Short: "列出已登录组织 profile", + Args: cobra.NoArgs, + DisableAutoGenTag: true, + RunE: func(cmd *cobra.Command, args []string) error { + configDir := defaultConfigDir() + 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)) + } + format, _ := cmd.Root().PersistentFlags().GetString("format") + if strings.EqualFold(strings.TrimSpace(format), "json") { + return writeProfileListJSON(cmd.OutOrStdout(), cfg) + } + writeProfileListTable(cmd.OutOrStdout(), cfg) + return nil + }, + } +} + +func newProfileUseCommand() *cobra.Command { + return &cobra.Command{ + Use: "use ", + Short: "切换当前组织 profile", + Args: cobra.ExactArgs(1), + DisableAutoGenTag: true, + RunE: func(cmd *cobra.Command, args []string) error { + configDir := defaultConfigDir() + var ( + profile *authpkg.Profile + err error + ) + if strings.TrimSpace(args[0]) == "-" { + profile, err = authpkg.UsePreviousProfile(configDir) + } else { + profile, err = authpkg.SetCurrentProfile(configDir, args[0]) + } + if err != nil { + return apperrors.NewValidation(err.Error()) + } + ResetRuntimeTokenCache() + clearCompatCache() + format, _ := cmd.Root().PersistentFlags().GetString("format") + if strings.EqualFold(strings.TrimSpace(format), "json") { + return writeProfileUseJSON(cmd.OutOrStdout(), profile) + } + fmt.Fprintf(cmd.OutOrStdout(), "[OK] 当前 profile: %s (%s)\n", profile.Name, profile.CorpID) + return nil + }, + } +} + +type profileListResponse struct { + Success bool `json:"success"` + PrimaryProfile string `json:"primaryProfile,omitempty"` + CurrentProfile string `json:"currentProfile,omitempty"` + PreviousProfile string `json:"previousProfile,omitempty"` + Profiles []profileView `json:"profiles"` +} + +type profileUseResponse struct { + Success bool `json:"success"` + Profile profileView `json:"profile"` +} + +type profileView struct { + Name string `json:"name"` + CorpID string `json:"corpId"` + CorpName string `json:"corpName,omitempty"` + UserID string `json:"userId,omitempty"` + UserName string `json:"userName,omitempty"` + ClientID string `json:"clientId,omitempty"` + Status string `json:"status,omitempty"` + AuthorizedDomains []string `json:"authorizedDomains,omitempty"` + ExpiresAt string `json:"expiresAt,omitempty"` + RefreshExpAt string `json:"refreshExpAt,omitempty"` + LastLoginAt string `json:"lastLoginAt,omitempty"` + LastUsedAt string `json:"lastUsedAt,omitempty"` + IsPrimary bool `json:"isPrimary"` + IsCurrent bool `json:"isCurrent"` +} + +func writeProfileListJSON(w io.Writer, cfg *authpkg.ProfilesConfig) error { + resp := profileListResponse{ + Success: true, + PrimaryProfile: cfg.PrimaryProfile, + CurrentProfile: cfg.CurrentProfile, + PreviousProfile: cfg.PreviousProfile, + Profiles: profileViews(cfg), + } + enc := json.NewEncoder(w) + enc.SetIndent("", " ") + return enc.Encode(resp) +} + +func writeProfileUseJSON(w io.Writer, profile *authpkg.Profile) error { + resp := profileUseResponse{Success: true} + if profile != nil { + resp.Profile = profileViewFromProfile(*profile, profile.CorpID, profile.CorpID) + } + enc := json.NewEncoder(w) + enc.SetIndent("", " ") + return enc.Encode(resp) +} + +func writeProfileListTable(w io.Writer, cfg *authpkg.ProfilesConfig) { + if cfg == nil || len(cfg.Profiles) == 0 { + fmt.Fprintln(w, "未找到已登录 profile") + return + } + fmt.Fprintf(w, "%-3s %-3s %-28s %-34s %-10s %s\n", "CUR", "PRI", "NAME", "CORP_ID", "STATUS", "USER") + for _, p := range cfg.Profiles { + current := "" + if p.CorpID == cfg.CurrentProfile { + current = "*" + } + primary := "" + if p.CorpID == cfg.PrimaryProfile { + primary = "*" + } + user := p.UserName + if user == "" { + user = p.UserID + } + status := p.Status + if status == "" { + status = authpkg.ProfileStatusActive + } + fmt.Fprintf(w, "%-3s %-3s %-28s %-34s %-10s %s\n", current, primary, clipProfileCell(p.Name, 28), clipProfileCell(p.CorpID, 34), status, user) + } +} + +func profileViews(cfg *authpkg.ProfilesConfig) []profileView { + if cfg == nil { + return nil + } + views := make([]profileView, 0, len(cfg.Profiles)) + for _, p := range cfg.Profiles { + views = append(views, profileViewFromProfile(p, cfg.PrimaryProfile, cfg.CurrentProfile)) + } + return views +} + +func profileViewFromProfile(p authpkg.Profile, primaryProfile, currentProfile string) profileView { + return profileView{ + Name: p.Name, + CorpID: p.CorpID, + CorpName: p.CorpName, + UserID: p.UserID, + UserName: p.UserName, + ClientID: p.ClientID, + Status: p.Status, + AuthorizedDomains: p.AuthorizedDomains, + ExpiresAt: p.ExpiresAt, + RefreshExpAt: p.RefreshExpAt, + LastLoginAt: p.LastLoginAt, + LastUsedAt: p.LastUsedAt, + IsPrimary: p.CorpID == primaryProfile, + IsCurrent: p.CorpID == currentProfile, + } +} + +func clipProfileCell(value string, limit int) string { + if limit <= 0 { + return "" + } + runes := []rune(value) + if len(runes) <= limit { + return value + } + if limit <= 3 { + return string(runes[:limit]) + } + return string(runes[:limit-3]) + "..." +} diff --git a/internal/app/root.go b/internal/app/root.go index e96b8b36..766f9652 100644 --- a/internal/app/root.go +++ b/internal/app/root.go @@ -267,6 +267,7 @@ func NewRootCommandWithEngine(rootCtx context.Context, engine *pipeline.Engine) rootCtx = context.Background() } flags := &GlobalFlags{} + authpkg.SetRuntimeProfile(preparseProfileFlag(os.Args[1:])) loader := cli.EnvironmentLoader{ LookupEnv: os.LookupEnv, CatalogBaseURLOverride: DiscoveryBaseURL(), @@ -290,6 +291,7 @@ func NewRootCommandWithEngine(rootCtx context.Context, engine *pipeline.Engine) return cmd.Help() }, PersistentPreRunE: func(cmd *cobra.Command, args []string) error { + authpkg.SetRuntimeProfile(flags.Profile) // Apply OAuth credential overrides from CLI flags (highest priority). if flags.ClientID != "" { authpkg.SetClientID(flags.ClientID) @@ -327,6 +329,7 @@ func NewRootCommandWithEngine(rootCtx context.Context, engine *pipeline.Engine) utilityCommands := []*cobra.Command{ newAuthCommand(patCaller), + newProfileCommand(), newAPICommand(flags), newSkillCommand(), newCacheCommand(), @@ -372,6 +375,19 @@ func NewRootCommandWithEngine(rootCtx context.Context, engine *pipeline.Engine) return root } +func preparseProfileFlag(args []string) string { + for i := 0; i < len(args); i++ { + arg := strings.TrimSpace(args[i]) + switch { + case arg == "--profile" && i+1 < len(args): + return strings.TrimSpace(args[i+1]) + case strings.HasPrefix(arg, "--profile="): + return strings.TrimSpace(strings.TrimPrefix(arg, "--profile=")) + } + } + return "" +} + func newAuthCommand(patCaller edition.ToolCaller) *cobra.Command { return buildAuthCommand(patCaller) } @@ -770,6 +786,7 @@ func hideNonDirectRuntimeCommands(root *cobra.Command) { "completion": true, "skill": true, "plugin": true, + "profile": true, "version": true, "help": true, "recovery": true, @@ -796,7 +813,7 @@ func hideNonDirectRuntimeCommands(root *cobra.Command) { // by a malicious or misconfigured plugin. var reservedCommands = map[string]bool{ "auth": true, "api": true, "login": true, "logout": true, - "plugin": true, "skill": true, "cache": true, + "plugin": true, "profile": true, "skill": true, "cache": true, "config": true, "doctor": true, "completion": true, "recovery": true, "upgrade": true, "version": true, "schema": true, "mcp": true, "help": true, diff --git a/internal/app/runner.go b/internal/app/runner.go index 11162162..0933033a 100644 --- a/internal/app/runner.go +++ b/internal/app/runner.go @@ -586,28 +586,40 @@ func resolveRuntimeAuthToken(ctx context.Context, explicitToken string) string { // Cached token state for process lifetime var ( - cachedRuntimeToken string - cachedRuntimeTokenOnce sync.Once + cachedRuntimeTokenMu sync.Mutex + cachedRuntimeTokens = map[string]string{} ) // getCachedRuntimeToken returns a cached access token, loading it only once per process. // This avoids repeated Keychain access which takes ~70ms each time. func getCachedRuntimeToken(ctx context.Context) string { - cachedRuntimeTokenOnce.Do(func() { - loadStart := time.Now() - defer func() { RecordTiming(ctx, "auth_keychain", time.Since(loadStart)) }() - - configDir := defaultConfigDir() - token, tokenErr := resolveAccessTokenFromDir(ctx, configDir) - if tokenErr != nil && errors.Is(tokenErr, authpkg.ErrTokenDecryption) { - slog.Error(tokenErr.Error()) - return - } - if token != "" { - cachedRuntimeToken = token - } - }) - return cachedRuntimeToken + cacheKey := strings.TrimSpace(authpkg.RuntimeProfile()) + if cacheKey == "" { + cacheKey = "__default__" + } + cachedRuntimeTokenMu.Lock() + if token := cachedRuntimeTokens[cacheKey]; token != "" { + cachedRuntimeTokenMu.Unlock() + return token + } + cachedRuntimeTokenMu.Unlock() + + loadStart := time.Now() + defer func() { RecordTiming(ctx, "auth_keychain", time.Since(loadStart)) }() + + configDir := defaultConfigDir() + token, tokenErr := resolveAccessTokenFromDir(ctx, configDir) + if tokenErr != nil && errors.Is(tokenErr, authpkg.ErrTokenDecryption) { + slog.Error(tokenErr.Error()) + return "" + } + if token == "" { + return "" + } + cachedRuntimeTokenMu.Lock() + cachedRuntimeTokens[cacheKey] = token + cachedRuntimeTokenMu.Unlock() + return token } // generateExecutionID returns a random 16-char hex string used to correlate @@ -622,8 +634,9 @@ func generateExecutionID() string { // ResetRuntimeTokenCache clears the cached token, forcing a reload on next access. // This should be called after login/logout operations. func ResetRuntimeTokenCache() { - cachedRuntimeTokenOnce = sync.Once{} - cachedRuntimeToken = "" + cachedRuntimeTokenMu.Lock() + defer cachedRuntimeTokenMu.Unlock() + cachedRuntimeTokens = map[string]string{} } func newRuntimeContentScanner() safety.Scanner { diff --git a/internal/auth/keychain_store.go b/internal/auth/keychain_store.go index 1ab513f6..e0390430 100644 --- a/internal/auth/keychain_store.go +++ b/internal/auth/keychain_store.go @@ -17,6 +17,7 @@ import ( "encoding/json" "fmt" "log/slog" + "strings" "sync" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/keychain" @@ -30,6 +31,24 @@ var ( // SaveTokenDataKeychain saves TokenData to the platform keychain. // This is the new secure storage method using random master key. func SaveTokenDataKeychain(data *TokenData) error { + return saveTokenDataKeychainAccount(keychain.AccountToken, data) +} + +// TokenAccountForCorpID returns the keychain account used for a corp-bound token. +func TokenAccountForCorpID(corpID string) string { + return keychain.AccountToken + ":" + strings.TrimSpace(corpID) +} + +// SaveTokenDataKeychainForCorpID saves TokenData to a corp-scoped keychain slot. +func SaveTokenDataKeychainForCorpID(corpID string, data *TokenData) error { + corpID = strings.TrimSpace(corpID) + if corpID == "" { + return fmt.Errorf("corpId is required for profile token storage") + } + return saveTokenDataKeychainAccount(TokenAccountForCorpID(corpID), data) +} + +func saveTokenDataKeychainAccount(account string, data *TokenData) error { jsonData, err := json.MarshalIndent(data, "", " ") if err != nil { return fmt.Errorf("marshal token data: %w", err) @@ -41,7 +60,7 @@ func SaveTokenDataKeychain(data *TokenData) error { } }() - if err := keychain.Set(keychain.Service, keychain.AccountToken, string(jsonData)); err != nil { + if err := keychain.Set(keychain.Service, account, string(jsonData)); err != nil { return fmt.Errorf("save to keychain: %w", err) } return nil @@ -49,12 +68,25 @@ func SaveTokenDataKeychain(data *TokenData) error { // LoadTokenDataKeychain loads TokenData from the platform keychain. func LoadTokenDataKeychain() (*TokenData, error) { - jsonStr, err := keychain.Get(keychain.Service, keychain.AccountToken) + return loadTokenDataKeychainAccount(keychain.AccountToken) +} + +// LoadTokenDataKeychainForCorpID loads TokenData from a corp-scoped keychain slot. +func LoadTokenDataKeychainForCorpID(corpID string) (*TokenData, error) { + corpID = strings.TrimSpace(corpID) + if corpID == "" { + return nil, fmt.Errorf("corpId is required for profile token storage") + } + return loadTokenDataKeychainAccount(TokenAccountForCorpID(corpID)) +} + +func loadTokenDataKeychainAccount(account string) (*TokenData, error) { + jsonStr, err := keychain.Get(keychain.Service, account) if err != nil { return nil, fmt.Errorf("load from keychain: %w", err) } if jsonStr == "" { - return nil, fmt.Errorf("no token data in keychain") + return nil, fmt.Errorf("no token data in keychain account %q", account) } var data TokenData @@ -69,11 +101,29 @@ func DeleteTokenDataKeychain() error { return keychain.Remove(keychain.Service, keychain.AccountToken) } +// DeleteTokenDataKeychainForCorpID removes TokenData from a corp-scoped keychain slot. +func DeleteTokenDataKeychainForCorpID(corpID string) error { + corpID = strings.TrimSpace(corpID) + if corpID == "" { + return fmt.Errorf("corpId is required for profile token storage") + } + return keychain.Remove(keychain.Service, TokenAccountForCorpID(corpID)) +} + // TokenDataExistsKeychain checks if token data exists in keychain. func TokenDataExistsKeychain() bool { return keychain.Exists(keychain.Service, keychain.AccountToken) } +// TokenDataExistsKeychainForCorpID checks if a corp-scoped token exists. +func TokenDataExistsKeychainForCorpID(corpID string) bool { + corpID = strings.TrimSpace(corpID) + if corpID == "" { + return false + } + return keychain.Exists(keychain.Service, TokenAccountForCorpID(corpID)) +} + // EnsureMigration performs one-time migration from legacy .data to keychain. // This should be called early in the auth flow (e.g., during GetAccessToken). // The migration is idempotent and thread-safe. diff --git a/internal/auth/oauth_provider.go b/internal/auth/oauth_provider.go index 66a9c461..accb1ea5 100644 --- a/internal/auth/oauth_provider.go +++ b/internal/auth/oauth_provider.go @@ -547,9 +547,12 @@ func (p *OAuthProvider) GetAccessToken(ctx context.Context) (string, error) { if rErr == nil { return refreshed.AccessToken, nil } + _ = MarkProfileStatus(p.configDir, data.CorpID, ProfileStatusExpired) if p.logger != nil { p.logger.Warn(i18n.T("refresh_token 刷新失败"), "error", rErr) } + } else { + _ = MarkProfileStatus(p.configDir, data.CorpID, ProfileStatusExpired) } return "", errors.New(i18n.T("所有凭证已失效,请运行 dws auth login 重新登录")) diff --git a/internal/auth/profiles.go b/internal/auth/profiles.go new file mode 100644 index 00000000..5d2e8229 --- /dev/null +++ b/internal/auth/profiles.go @@ -0,0 +1,558 @@ +// 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 auth + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + "strings" + "sync" + "time" + + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/config" +) + +const profilesJSONFile = "profiles.json" + +const ( + ProfileStatusActive = "active" + ProfileStatusExpired = "expired" + ProfileStatusRevoked = "revoked" +) + +// ProfilesConfig stores non-sensitive profile metadata. Token material stays in keychain. +type ProfilesConfig struct { + Version int `json:"version"` + PrimaryProfile string `json:"primaryProfile,omitempty"` + CurrentProfile string `json:"currentProfile,omitempty"` + PreviousProfile string `json:"previousProfile,omitempty"` + Profiles []Profile `json:"profiles,omitempty"` +} + +// Profile is a logged-in DingTalk organization identity. +type Profile struct { + Name string `json:"name"` + CorpID string `json:"corpId"` + CorpName string `json:"corpName,omitempty"` + UserID string `json:"userId,omitempty"` + UserName string `json:"userName,omitempty"` + ClientID string `json:"clientId,omitempty"` + Status string `json:"status,omitempty"` + AuthorizedDomains []string `json:"authorizedDomains,omitempty"` + ExpiresAt string `json:"expiresAt,omitempty"` + RefreshExpAt string `json:"refreshExpAt,omitempty"` + LastLoginAt string `json:"lastLoginAt,omitempty"` + LastUsedAt string `json:"lastUsedAt,omitempty"` + UpdatedAt string `json:"updatedAt,omitempty"` +} + +var ( + runtimeProfileMu sync.RWMutex + runtimeProfile string +) + +// SetRuntimeProfile sets a process-local one-shot profile override. +func SetRuntimeProfile(profile string) { + runtimeProfileMu.Lock() + defer runtimeProfileMu.Unlock() + runtimeProfile = strings.TrimSpace(profile) +} + +// RuntimeProfile returns the process-local one-shot profile override. +func RuntimeProfile() string { + runtimeProfileMu.RLock() + defer runtimeProfileMu.RUnlock() + return runtimeProfile +} + +// ProfilesPath returns the profile metadata path for a config dir. +func ProfilesPath(configDir string) string { + return filepath.Join(configDir, profilesJSONFile) +} + +// LoadProfiles reads profiles.json. A missing file returns an empty config. +func LoadProfiles(configDir string) (*ProfilesConfig, error) { + path := ProfilesPath(configDir) + data, err := os.ReadFile(path) + if err != nil { + if os.IsNotExist(err) { + return &ProfilesConfig{Version: 1}, nil + } + return nil, fmt.Errorf("read profiles: %w", err) + } + var cfg ProfilesConfig + if err := json.Unmarshal(data, &cfg); err != nil { + return nil, fmt.Errorf("parse profiles: %w", err) + } + normalizeProfilesConfig(&cfg) + return &cfg, nil +} + +// SaveProfiles writes profiles.json atomically. +func SaveProfiles(configDir string, cfg *ProfilesConfig) error { + if cfg == nil { + cfg = &ProfilesConfig{} + } + normalizeProfilesConfig(cfg) + if err := os.MkdirAll(configDir, config.DirPerm); err != nil { + return fmt.Errorf("create config dir: %w", err) + } + data, err := json.MarshalIndent(cfg, "", " ") + if err != nil { + return fmt.Errorf("marshal profiles: %w", err) + } + data = append(data, '\n') + path := ProfilesPath(configDir) + tmp := path + ".tmp" + if err := os.WriteFile(tmp, data, config.FilePerm); err != nil { + return fmt.Errorf("write profiles tmp: %w", err) + } + if err := os.Rename(tmp, path); err != nil { + _ = os.Remove(tmp) + return fmt.Errorf("rename profiles: %w", err) + } + return nil +} + +// EnsureProfilesMigration initializes profiles.json from the legacy auth-token slot when needed. +func EnsureProfilesMigration(configDir string) error { + cfg, err := LoadProfiles(configDir) + if err != nil { + return err + } + if len(cfg.Profiles) > 0 { + return nil + } + if !TokenDataExistsKeychain() { + return nil + } + data, err := LoadTokenDataKeychain() + if err != nil || data == nil || strings.TrimSpace(data.CorpID) == "" { + return nil + } + if err := SaveTokenDataKeychainForCorpID(data.CorpID, data); err != nil { + return err + } + return upsertProfileFromToken(configDir, cfg, data, false) +} + +// UpsertProfileFromToken updates profiles.json after a successful login or refresh. +func UpsertProfileFromToken(configDir string, data *TokenData) error { + return UpsertProfileFromTokenWithCurrent(configDir, data, true) +} + +// UpsertProfileFromTokenWithCurrent updates profiles.json and optionally makes +// the token's corp the persistent current profile. +func UpsertProfileFromTokenWithCurrent(configDir string, data *TokenData, makeCurrent bool) error { + cfg, err := LoadProfiles(configDir) + if err != nil { + return err + } + return upsertProfileFromToken(configDir, cfg, data, makeCurrent) +} + +func upsertProfileFromToken(configDir string, cfg *ProfilesConfig, data *TokenData, makeCurrent bool) error { + if data == nil { + return nil + } + corpID := strings.TrimSpace(data.CorpID) + if corpID == "" { + return nil + } + normalizeProfilesConfig(cfg) + now := time.Now().Format(time.RFC3339) + idx := profileIndexByCorpID(cfg, corpID) + if idx < 0 { + profile := Profile{ + Name: chooseProfileName(cfg, data), + CorpID: corpID, + CorpName: strings.TrimSpace(data.CorpName), + UserID: strings.TrimSpace(data.UserID), + UserName: strings.TrimSpace(data.UserName), + ClientID: strings.TrimSpace(data.ClientID), + Status: ProfileStatusActive, + ExpiresAt: timeOrRFC3339(data.ExpiresAt), + RefreshExpAt: timeOrRFC3339(data.RefreshExpAt), + LastLoginAt: now, + LastUsedAt: now, + UpdatedAt: now, + } + cfg.Profiles = append(cfg.Profiles, profile) + } else { + p := &cfg.Profiles[idx] + if strings.TrimSpace(p.Name) == "" { + p.Name = chooseProfileName(cfg, data) + } + if v := strings.TrimSpace(data.CorpName); v != "" { + p.CorpName = v + } + if v := strings.TrimSpace(data.UserID); v != "" { + p.UserID = v + } + if v := strings.TrimSpace(data.UserName); v != "" { + p.UserName = v + } + if v := strings.TrimSpace(data.ClientID); v != "" { + p.ClientID = v + } + p.Status = ProfileStatusActive + p.ExpiresAt = timeOrRFC3339(data.ExpiresAt) + p.RefreshExpAt = timeOrRFC3339(data.RefreshExpAt) + p.LastLoginAt = now + p.LastUsedAt = now + p.UpdatedAt = now + } + if cfg.PrimaryProfile == "" { + cfg.PrimaryProfile = corpID + } + if makeCurrent && cfg.CurrentProfile != corpID { + if cfg.CurrentProfile != "" { + cfg.PreviousProfile = cfg.CurrentProfile + } + cfg.CurrentProfile = corpID + } + if cfg.CurrentProfile == "" { + cfg.CurrentProfile = corpID + } + return SaveProfiles(configDir, cfg) +} + +// ResolveProfile returns a profile selected by name/corpId or by current/primary fallback. +func ResolveProfile(configDir, selector string) (*Profile, error) { + if err := EnsureProfilesMigration(configDir); err != nil { + return nil, err + } + cfg, err := LoadProfiles(configDir) + if err != nil { + return nil, err + } + selector = strings.TrimSpace(selector) + if selector != "" { + p := findProfile(cfg, selector) + if p == nil { + return nil, fmt.Errorf("profile %q not found", selector) + } + return p, nil + } + if p := findProfile(cfg, cfg.CurrentProfile); p != nil { + return p, nil + } + if p := findProfile(cfg, cfg.PrimaryProfile); p != nil { + return p, nil + } + return nil, nil +} + +func resolveProfileForLoad(configDir, selector string) (*Profile, error) { + if err := EnsureProfilesMigration(configDir); err != nil { + return nil, err + } + cfg, err := LoadProfiles(configDir) + if err != nil { + return nil, err + } + selector = strings.TrimSpace(selector) + if selector != "" { + p := findProfile(cfg, selector) + if p == nil { + return nil, fmt.Errorf("profile %q not found", selector) + } + return p, nil + } + for _, candidate := range []string{cfg.CurrentProfile, cfg.PrimaryProfile} { + if p := findProfile(cfg, candidate); p != nil && TokenDataExistsKeychainForCorpID(p.CorpID) { + return p, nil + } + } + if p := findProfile(cfg, cfg.CurrentProfile); p != nil { + return p, nil + } + if p := findProfile(cfg, cfg.PrimaryProfile); p != nil { + return p, nil + } + return nil, nil +} + +// SetCurrentProfile persists the selected current profile. +func SetCurrentProfile(configDir, selector string) (*Profile, error) { + if err := EnsureProfilesMigration(configDir); err != nil { + return nil, err + } + cfg, err := LoadProfiles(configDir) + if err != nil { + return nil, err + } + p := findProfile(cfg, selector) + if p == nil { + return nil, fmt.Errorf("profile %q not found", strings.TrimSpace(selector)) + } + if cfg.CurrentProfile != p.CorpID { + if cfg.CurrentProfile != "" { + cfg.PreviousProfile = cfg.CurrentProfile + } + cfg.CurrentProfile = p.CorpID + } + touchProfile(cfg, p.CorpID) + if err := SaveProfiles(configDir, cfg); err != nil { + return nil, err + } + if err := SyncLegacyTokenMirror(configDir); err != nil { + return nil, err + } + return findProfile(cfg, p.CorpID), nil +} + +// UsePreviousProfile toggles currentProfile and previousProfile. +func UsePreviousProfile(configDir string) (*Profile, error) { + if err := EnsureProfilesMigration(configDir); err != nil { + return nil, err + } + cfg, err := LoadProfiles(configDir) + if err != nil { + return nil, err + } + prev := strings.TrimSpace(cfg.PreviousProfile) + if prev == "" { + return nil, fmt.Errorf("previous profile is empty") + } + p := findProfile(cfg, prev) + if p == nil { + return nil, fmt.Errorf("previous profile %q not found", prev) + } + cfg.PreviousProfile, cfg.CurrentProfile = cfg.CurrentProfile, p.CorpID + touchProfile(cfg, p.CorpID) + if err := SaveProfiles(configDir, cfg); err != nil { + return nil, err + } + if err := SyncLegacyTokenMirror(configDir); err != nil { + return nil, err + } + return findProfile(cfg, p.CorpID), nil +} + +// RemoveProfile removes a profile from metadata and returns the removed profile. +func RemoveProfile(configDir, selector string) (*Profile, error) { + cfg, err := LoadProfiles(configDir) + if err != nil { + return nil, err + } + p := findProfile(cfg, selector) + if p == nil { + return nil, fmt.Errorf("profile %q not found", strings.TrimSpace(selector)) + } + removed := *p + kept := cfg.Profiles[:0] + for _, profile := range cfg.Profiles { + if profile.CorpID != removed.CorpID { + kept = append(kept, profile) + } + } + cfg.Profiles = kept + if cfg.PrimaryProfile == removed.CorpID { + cfg.PrimaryProfile = firstProfileCorpID(cfg) + } + if cfg.CurrentProfile == removed.CorpID { + cfg.CurrentProfile = cfg.PrimaryProfile + if cfg.CurrentProfile == "" { + cfg.CurrentProfile = firstProfileCorpID(cfg) + } + } + if cfg.PreviousProfile == removed.CorpID { + cfg.PreviousProfile = "" + } + if len(cfg.Profiles) == 0 { + cfg.PrimaryProfile = "" + cfg.CurrentProfile = "" + cfg.PreviousProfile = "" + } + if err := SaveProfiles(configDir, cfg); err != nil { + return nil, err + } + return &removed, nil +} + +// MarkProfileStatus updates a profile status if it exists. +func MarkProfileStatus(configDir, corpID, status string) error { + if strings.TrimSpace(corpID) == "" { + return nil + } + cfg, err := LoadProfiles(configDir) + if err != nil { + return err + } + p := findProfile(cfg, corpID) + if p == nil { + return nil + } + p.Status = strings.TrimSpace(status) + p.UpdatedAt = time.Now().Format(time.RFC3339) + return SaveProfiles(configDir, cfg) +} + +// SyncLegacyTokenMirror mirrors the current profile token into legacy auth-token. +func SyncLegacyTokenMirror(configDir string) error { + cfg, err := LoadProfiles(configDir) + if err != nil { + return err + } + for _, candidate := range []string{cfg.CurrentProfile, cfg.PrimaryProfile} { + if p := findProfile(cfg, candidate); p != nil { + data, loadErr := LoadTokenDataKeychainForCorpID(p.CorpID) + if loadErr == nil && data != nil { + if err := SaveTokenDataKeychain(data); err != nil { + return err + } + return WriteTokenMarker(configDir) + } + } + } + _ = DeleteTokenDataKeychain() + _ = DeleteTokenMarker(configDir) + return nil +} + +func normalizeProfilesConfig(cfg *ProfilesConfig) { + if cfg == nil { + return + } + cfg.Version = 1 + seen := make(map[string]bool, len(cfg.Profiles)) + profiles := cfg.Profiles[:0] + for _, p := range cfg.Profiles { + p.CorpID = strings.TrimSpace(p.CorpID) + if p.CorpID == "" || seen[p.CorpID] { + continue + } + seen[p.CorpID] = true + p.Name = strings.TrimSpace(p.Name) + if p.Name == "" { + p.Name = p.CorpID + } + if p.Status == "" { + p.Status = ProfileStatusActive + } + profiles = append(profiles, p) + } + cfg.Profiles = profiles + if cfg.PrimaryProfile != "" && findProfile(cfg, cfg.PrimaryProfile) == nil { + cfg.PrimaryProfile = "" + } + if cfg.CurrentProfile != "" && findProfile(cfg, cfg.CurrentProfile) == nil { + cfg.CurrentProfile = "" + } + if cfg.PreviousProfile != "" && findProfile(cfg, cfg.PreviousProfile) == nil { + cfg.PreviousProfile = "" + } + if cfg.PrimaryProfile == "" { + cfg.PrimaryProfile = firstProfileCorpID(cfg) + } + if cfg.CurrentProfile == "" { + cfg.CurrentProfile = cfg.PrimaryProfile + } +} + +func chooseProfileName(cfg *ProfilesConfig, data *TokenData) string { + base := strings.TrimSpace(data.CorpName) + if base == "" { + base = strings.TrimSpace(data.CorpID) + } + if base == "" { + base = "profile" + } + if !profileNameTakenByOtherCorp(cfg, base, data.CorpID) { + return base + } + suffix := shortCorpID(data.CorpID) + name := base + "-" + suffix + if !profileNameTakenByOtherCorp(cfg, name, data.CorpID) { + return name + } + for i := 2; ; i++ { + candidate := fmt.Sprintf("%s-%s-%d", base, suffix, i) + if !profileNameTakenByOtherCorp(cfg, candidate, data.CorpID) { + return candidate + } + } +} + +func profileNameTakenByOtherCorp(cfg *ProfilesConfig, name, corpID string) bool { + name = strings.TrimSpace(name) + corpID = strings.TrimSpace(corpID) + for _, p := range cfg.Profiles { + if p.CorpID != corpID && p.Name == name { + return true + } + } + return false +} + +func findProfile(cfg *ProfilesConfig, selector string) *Profile { + if cfg == nil { + return nil + } + selector = strings.TrimSpace(selector) + if selector == "" { + return nil + } + for i := range cfg.Profiles { + if cfg.Profiles[i].CorpID == selector || cfg.Profiles[i].Name == selector { + return &cfg.Profiles[i] + } + } + return nil +} + +func profileIndexByCorpID(cfg *ProfilesConfig, corpID string) int { + if cfg == nil { + return -1 + } + for i := range cfg.Profiles { + if cfg.Profiles[i].CorpID == corpID { + return i + } + } + return -1 +} + +func firstProfileCorpID(cfg *ProfilesConfig) string { + if cfg == nil || len(cfg.Profiles) == 0 { + return "" + } + return cfg.Profiles[0].CorpID +} + +func touchProfile(cfg *ProfilesConfig, corpID string) { + if p := findProfile(cfg, corpID); p != nil { + now := time.Now().Format(time.RFC3339) + p.LastUsedAt = now + p.UpdatedAt = now + } +} + +func timeOrRFC3339(t time.Time) string { + if t.IsZero() { + return "" + } + return t.Format(time.RFC3339) +} + +func shortCorpID(corpID string) string { + corpID = strings.TrimSpace(corpID) + if len(corpID) <= 8 { + return corpID + } + return corpID[len(corpID)-8:] +} diff --git a/internal/auth/token.go b/internal/auth/token.go index 93239a18..b63aa9d3 100644 --- a/internal/auth/token.go +++ b/internal/auth/token.go @@ -22,6 +22,7 @@ import ( "net/url" "os" "path/filepath" + "strings" "time" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition" @@ -91,7 +92,10 @@ func WriteTokenMarker(configDir string) error { // DeleteTokenMarker removes the token.json marker file. func DeleteTokenMarker(configDir string) error { - return os.Remove(filepath.Join(configDir, tokenJSONFile)) + if err := os.Remove(filepath.Join(configDir, tokenJSONFile)); err != nil && !os.IsNotExist(err) { + return err + } + return nil } // SaveTokenData persists TokenData. When an edition hook (SaveToken) is @@ -105,14 +109,43 @@ func SaveTokenData(configDir string, data *TokenData) error { } return h.SaveToken(configDir, jsonData) } - return SaveTokenDataKeychain(data) + if data != nil && strings.TrimSpace(data.CorpID) != "" { + if err := SaveTokenDataKeychainForCorpID(data.CorpID, data); err != nil { + return err + } + makeCurrent := strings.TrimSpace(RuntimeProfile()) == "" + if err := UpsertProfileFromTokenWithCurrent(configDir, data, makeCurrent); err != nil { + return err + } + if makeCurrent { + if err := SaveTokenDataKeychain(data); err != nil { + return err + } + } else if err := SyncLegacyTokenMirror(configDir); err != nil { + return err + } + return WriteTokenMarker(configDir) + } + if err := SaveTokenDataKeychain(data); err != nil { + return err + } + return WriteTokenMarker(configDir) } // LoadTokenData reads TokenData. When an edition hook (LoadToken) is // registered, it delegates entirely to the hook; otherwise it falls back // to keychain with legacy .data migration. func LoadTokenData(configDir string) (*TokenData, error) { + return LoadTokenDataForProfile(configDir, RuntimeProfile()) +} + +// LoadTokenDataForProfile reads TokenData for a profile selector without mutating +// currentProfile. Empty selector follows the default resolution chain. +func LoadTokenDataForProfile(configDir, profile string) (*TokenData, error) { if h := edition.Get(); h.LoadToken != nil { + if strings.TrimSpace(profile) != "" { + return nil, fmt.Errorf("profile selection is not supported by the current auth backend") + } jsonData, err := h.LoadToken(configDir) if err != nil { return nil, err @@ -125,6 +158,19 @@ func LoadTokenData(configDir string) (*TokenData, error) { } // Default: keychain with legacy .data migration + selected, err := resolveProfileForLoad(configDir, profile) + if err != nil { + return nil, err + } + if selected != nil { + data, err := LoadTokenDataKeychainForCorpID(selected.CorpID) + if err == nil { + return data, nil + } + if strings.TrimSpace(profile) != "" { + return nil, err + } + } if TokenDataExistsKeychain() { return LoadTokenDataKeychain() } @@ -132,7 +178,7 @@ func LoadTokenData(configDir string) (*TokenData, error) { if err != nil { return nil, err } - if err := SaveTokenDataKeychain(data); err == nil { + if err := SaveTokenData(configDir, data); err == nil { _ = DeleteSecureData(configDir) } return data, nil @@ -142,15 +188,79 @@ func LoadTokenData(configDir string) (*TokenData, error) { // registered, it delegates entirely to the hook; otherwise it falls back // to keychain + legacy cleanup. func DeleteTokenData(configDir string) error { + return DeleteTokenDataForProfile(configDir, RuntimeProfile()) +} + +// DeleteTokenDataForProfile removes one profile's token data. Empty selector +// removes the current/default profile, falling back to legacy single-slot auth. +func DeleteTokenDataForProfile(configDir, profile string) error { if h := edition.Get(); h.DeleteToken != nil { + if strings.TrimSpace(profile) != "" { + return fmt.Errorf("profile selection is not supported by the current auth backend") + } return h.DeleteToken(configDir) } + selected, err := resolveProfileForLoad(configDir, profile) + if err != nil { + return err + } + if selected != nil { + keychainErr := DeleteTokenDataKeychainForCorpID(selected.CorpID) + _, removeErr := RemoveProfile(configDir, selected.CorpID) + legacyErr := SyncLegacyTokenMirror(configDir) + secureErr := DeleteSecureData(configDir) + if keychainErr != nil { + return keychainErr + } + if removeErr != nil { + return removeErr + } + if legacyErr != nil { + return legacyErr + } + return secureErr + } + keychainErr := DeleteTokenDataKeychain() legacyErr := DeleteSecureData(configDir) + markerErr := DeleteTokenMarker(configDir) if keychainErr != nil { return keychainErr } - return legacyErr + if legacyErr != nil { + return legacyErr + } + return markerErr +} + +// DeleteAllTokenData removes all profile-scoped and legacy token data. +func DeleteAllTokenData(configDir string) error { + if h := edition.Get(); h.DeleteToken != nil { + return h.DeleteToken(configDir) + } + cfg, err := LoadProfiles(configDir) + if err != nil { + return err + } + var firstErr error + for _, profile := range cfg.Profiles { + if err := DeleteTokenDataKeychainForCorpID(profile.CorpID); err != nil && firstErr == nil { + firstErr = err + } + } + if err := os.Remove(ProfilesPath(configDir)); err != nil && !os.IsNotExist(err) && firstErr == nil { + firstErr = err + } + if err := DeleteTokenDataKeychain(); err != nil && firstErr == nil { + firstErr = err + } + if err := DeleteSecureData(configDir); err != nil && firstErr == nil { + firstErr = err + } + if err := DeleteTokenMarker(configDir); err != nil && firstErr == nil { + firstErr = err + } + return firstErr } // RevokeTokenRemote calls the appropriate logout/revoke endpoint to invalidate the access token. diff --git a/internal/auth/token_test.go b/internal/auth/token_test.go index 33f77caa..866b7a9f 100644 --- a/internal/auth/token_test.go +++ b/internal/auth/token_test.go @@ -25,8 +25,10 @@ import ( // written by these tests, and removes test data on completion. func cleanupKeychain(t *testing.T) { t.Helper() + SetRuntimeProfile("") t.Setenv(keychain.StorageDirEnv, t.TempDir()) t.Cleanup(func() { + SetRuntimeProfile("") _ = keychain.Remove(keychain.Service, keychain.AccountToken) }) } @@ -127,6 +129,166 @@ func TestTokenOverwrite(t *testing.T) { } } +func TestMultiProfileSaveLoadAndSwitch(t *testing.T) { + cleanupKeychain(t) + configDir := t.TempDir() + + dataA := testToken("at_a", "corp_a", "A Org") + dataB := testToken("at_b", "corp_b", "B Org") + if err := SaveTokenData(configDir, dataA); err != nil { + t.Fatalf("SaveTokenData(A) error = %v", err) + } + if err := SaveTokenData(configDir, dataB); err != nil { + t.Fatalf("SaveTokenData(B) error = %v", err) + } + + cfg, err := LoadProfiles(configDir) + if err != nil { + t.Fatalf("LoadProfiles() error = %v", err) + } + if cfg.PrimaryProfile != "corp_a" || cfg.CurrentProfile != "corp_b" || cfg.PreviousProfile != "corp_a" { + t.Fatalf("profile pointers = primary %q current %q previous %q", cfg.PrimaryProfile, cfg.CurrentProfile, cfg.PreviousProfile) + } + + loadedB, err := LoadTokenData(configDir) + if err != nil { + t.Fatalf("LoadTokenData() error = %v", err) + } + if loadedB.AccessToken != "at_b" { + t.Fatalf("default token = %q, want at_b", loadedB.AccessToken) + } + loadedA, err := LoadTokenDataForProfile(configDir, "A Org") + if err != nil { + t.Fatalf("LoadTokenDataForProfile(A Org) error = %v", err) + } + if loadedA.AccessToken != "at_a" { + t.Fatalf("profile A token = %q, want at_a", loadedA.AccessToken) + } + + if _, err := SetCurrentProfile(configDir, "corp_a"); err != nil { + t.Fatalf("SetCurrentProfile(A) error = %v", err) + } + loadedA, err = LoadTokenData(configDir) + if err != nil { + t.Fatalf("LoadTokenData() after switch error = %v", err) + } + if loadedA.AccessToken != "at_a" { + t.Fatalf("default token after switch = %q, want at_a", loadedA.AccessToken) + } + if _, err := UsePreviousProfile(configDir); err != nil { + t.Fatalf("UsePreviousProfile() error = %v", err) + } + loadedB, err = LoadTokenData(configDir) + if err != nil { + t.Fatalf("LoadTokenData() after previous error = %v", err) + } + if loadedB.AccessToken != "at_b" { + t.Fatalf("default token after previous = %q, want at_b", loadedB.AccessToken) + } +} + +func TestRuntimeProfileOverrideDoesNotMutateCurrent(t *testing.T) { + cleanupKeychain(t) + configDir := t.TempDir() + + if err := SaveTokenData(configDir, testToken("at_a", "corp_a", "A Org")); err != nil { + t.Fatalf("SaveTokenData(A) error = %v", err) + } + if err := SaveTokenData(configDir, testToken("at_b", "corp_b", "B Org")); err != nil { + t.Fatalf("SaveTokenData(B) error = %v", err) + } + if _, err := SetCurrentProfile(configDir, "corp_a"); err != nil { + t.Fatalf("SetCurrentProfile(A) error = %v", err) + } + + SetRuntimeProfile("corp_b") + if err := SaveTokenData(configDir, testToken("at_b_refreshed", "corp_b", "B Org")); err != nil { + t.Fatalf("SaveTokenData(B refresh) error = %v", err) + } + SetRuntimeProfile("") + + cfg, err := LoadProfiles(configDir) + if err != nil { + t.Fatalf("LoadProfiles() error = %v", err) + } + if cfg.CurrentProfile != "corp_a" { + t.Fatalf("current profile = %q, want corp_a", cfg.CurrentProfile) + } + loadedB, err := LoadTokenDataForProfile(configDir, "corp_b") + if err != nil { + t.Fatalf("LoadTokenDataForProfile(B) error = %v", err) + } + if loadedB.AccessToken != "at_b_refreshed" { + t.Fatalf("profile B token = %q, want at_b_refreshed", loadedB.AccessToken) + } + loadedDefault, err := LoadTokenData(configDir) + if err != nil { + t.Fatalf("LoadTokenData() error = %v", err) + } + if loadedDefault.AccessToken != "at_a" { + t.Fatalf("default token = %q, want at_a", loadedDefault.AccessToken) + } +} + +func TestDeleteProfilePreservesOtherProfiles(t *testing.T) { + cleanupKeychain(t) + configDir := t.TempDir() + + if err := SaveTokenData(configDir, testToken("at_a", "corp_a", "A Org")); err != nil { + t.Fatalf("SaveTokenData(A) error = %v", err) + } + if err := SaveTokenData(configDir, testToken("at_b", "corp_b", "B Org")); err != nil { + t.Fatalf("SaveTokenData(B) error = %v", err) + } + if err := DeleteTokenDataForProfile(configDir, "corp_b"); err != nil { + t.Fatalf("DeleteTokenDataForProfile(B) error = %v", err) + } + if _, err := LoadTokenDataForProfile(configDir, "corp_b"); err == nil { + t.Fatal("LoadTokenDataForProfile(B) error = nil after delete, want failure") + } + loadedA, err := LoadTokenDataForProfile(configDir, "corp_a") + if err != nil { + t.Fatalf("LoadTokenDataForProfile(A) error = %v", err) + } + if loadedA.AccessToken != "at_a" { + t.Fatalf("profile A token = %q, want at_a", loadedA.AccessToken) + } + cfg, err := LoadProfiles(configDir) + if err != nil { + t.Fatalf("LoadProfiles() error = %v", err) + } + if len(cfg.Profiles) != 1 || cfg.CurrentProfile != "corp_a" { + t.Fatalf("profiles after delete = %#v", cfg) + } +} + +func TestLegacyKeychainMigrationInitializesProfile(t *testing.T) { + cleanupKeychain(t) + configDir := t.TempDir() + + legacy := testToken("at_legacy", "corp_legacy", "Legacy Org") + if err := SaveTokenDataKeychain(legacy); err != nil { + t.Fatalf("SaveTokenDataKeychain() error = %v", err) + } + loaded, err := LoadTokenData(configDir) + if err != nil { + t.Fatalf("LoadTokenData() error = %v", err) + } + if loaded.AccessToken != "at_legacy" { + t.Fatalf("loaded token = %q, want at_legacy", loaded.AccessToken) + } + cfg, err := LoadProfiles(configDir) + if err != nil { + t.Fatalf("LoadProfiles() error = %v", err) + } + if cfg.PrimaryProfile != "corp_legacy" || cfg.CurrentProfile != "corp_legacy" { + t.Fatalf("profile pointers after migration = %#v", cfg) + } + if !TokenDataExistsKeychainForCorpID("corp_legacy") { + t.Fatal("corp-scoped token should exist after migration") + } +} + func TestTokenDataExistsKeychain(t *testing.T) { cleanupKeychain(t) @@ -152,6 +314,21 @@ func TestTokenDataExistsKeychain(t *testing.T) { } } +func testToken(accessToken, corpID, corpName string) *TokenData { + now := time.Now().UTC() + return &TokenData{ + AccessToken: accessToken, + RefreshToken: "rt_" + accessToken, + ExpiresAt: now.Add(2 * time.Hour), + RefreshExpAt: now.Add(30 * 24 * time.Hour), + CorpID: corpID, + CorpName: corpName, + UserID: "user_" + corpID, + UserName: "User " + corpID, + ClientID: "client_" + corpID, + } +} + func TestTokenValidityChecks(t *testing.T) { t.Parallel() From 5dc5b0337274917b8137bc2a671fe8eeb52b3a7c Mon Sep 17 00:00:00 2001 From: "shangguanxuan.sgx" Date: Thu, 25 Jun 2026 21:44:44 +0800 Subject: [PATCH 02/22] fix(auth): complete multi-org profile acceptance --- internal/app/auth_command.go | 158 +++++++++++++++++++++++++-- internal/app/auth_command_test.go | 47 ++++++++ internal/app/profile_command.go | 16 ++- internal/app/profile_command_test.go | 49 +++++++++ internal/auth/auth_extra_test.go | 57 ++++++++++ internal/auth/oauth_helpers.go | 29 ++++- internal/auth/oauth_provider.go | 15 +-- internal/auth/profiles.go | 25 ++++- internal/auth/token_test.go | 106 ++++++++++++++++++ 9 files changed, 476 insertions(+), 26 deletions(-) create mode 100644 internal/app/profile_command_test.go diff --git a/internal/app/auth_command.go b/internal/app/auth_command.go index e6ff32bb..4bb1e5da 100644 --- a/internal/app/auth_command.go +++ b/internal/app/auth_command.go @@ -39,11 +39,12 @@ import ( ) type authLoginConfig struct { - Token string - Force bool - Device bool - Recommend bool - Yes bool + Token string + Force bool + Device bool + Recommend bool + Yes bool + TargetCorpID string } type authLoginGuideAction string @@ -154,6 +155,7 @@ func newAuthLoginCommand(patCaller edition.ToolCaller) *cobra.Command { 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, cfg.Force) if err != nil { @@ -163,6 +165,11 @@ func newAuthLoginCommand(patCaller edition.ToolCaller) *cobra.Command { ResetRuntimeTokenCache() clearCompatCache() + if tokenData != nil && strings.TrimSpace(tokenData.CorpID) != "" { + _ = enrichAuthLoginProfileFromContact(cmd.Context(), configDir, patCaller, tokenData) + ResetRuntimeTokenCache() + clearCompatCache() + } w := cmd.OutOrStdout() runPostLoginAuthorization := func() error { @@ -1045,18 +1052,149 @@ func resolveAuthLoginConfig(cmd *cobra.Command) (authLoginConfig, error) { 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, + Token: strings.TrimSpace(token), + Force: force, + Device: device, + Recommend: recommend, + Yes: yes, + TargetCorpID: targetCorpID, }, nil } +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 diff --git a/internal/app/auth_command_test.go b/internal/app/auth_command_test.go index 2844ab70..d425faef 100644 --- a/internal/app/auth_command_test.go +++ b/internal/app/auth_command_test.go @@ -578,6 +578,53 @@ func TestAuthLoginDefaultTUIRunsAfterLoginTokenSaved(t *testing.T) { } } +func TestEnrichAuthLoginProfileFromContactPersistsCorpName(t *testing.T) { + t.Setenv(keychain.DisableKeychainEnv, "1") + t.Setenv(keychain.StorageDirEnv, t.TempDir()) + configDir := t.TempDir() + t.Setenv("DWS_CONFIG_DIR", configDir) + + token := &authpkg.TokenData{ + AccessToken: "access-token", + RefreshToken: "refresh-token", + ExpiresAt: time.Now().Add(time.Hour), + RefreshExpAt: time.Now().Add(24 * time.Hour), + CorpID: "ding32fff839a3e0105d", + ClientID: "client-id", + Source: "mcp", + } + if err := authpkg.SaveTokenData(configDir, token); err != nil { + t.Fatalf("SaveTokenData() error = %v", err) + } + + fake := &authLoginRecommendSequenceCaller{responses: []string{ + `{"success":true,"result":[{"orgEmployeeModel":{"corpId":"ding32fff839a3e0105d","orgName":"钉钉(中国)信息技术有限公司","userId":"011352590165863362195","orgUserName":"玄玦(主用钉)"}}]}`, + }} + if err := enrichAuthLoginProfileFromContact(context.Background(), configDir, fake, token); err != nil { + t.Fatalf("enrichAuthLoginProfileFromContact() error = %v", err) + } + if token.CorpName != "钉钉(中国)信息技术有限公司" { + t.Fatalf("token corpName = %q, want 钉钉(中国)信息技术有限公司", token.CorpName) + } + if token.UserID != "011352590165863362195" || token.UserName != "玄玦(主用钉)" { + t.Fatalf("token user identity = (%q, %q), want contact result", token.UserID, token.UserName) + } + + loaded, err := authpkg.LoadTokenDataForProfile(configDir, "ding32fff839a3e0105d") + if err != nil { + t.Fatalf("LoadTokenDataForProfile() error = %v", err) + } + if loaded.CorpName != "钉钉(中国)信息技术有限公司" { + t.Fatalf("persisted corpName = %q, want 钉钉(中国)信息技术有限公司", loaded.CorpName) + } + if len(fake.tools) != 1 || fake.tools[0] != "get_current_user_profile" { + t.Fatalf("tool calls = %v, want get_current_user_profile", fake.tools) + } + if got := fake.args[0]["profile"]; got != "ding32fff839a3e0105d" { + t.Fatalf("contact profile arg = %#v, want ding32fff839a3e0105d", got) + } +} + type roundTripFunc func(*http.Request) (*http.Response, error) func (f roundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) { diff --git a/internal/app/profile_command.go b/internal/app/profile_command.go index e86bf5fb..b8c05fc3 100644 --- a/internal/app/profile_command.go +++ b/internal/app/profile_command.go @@ -89,7 +89,11 @@ func newProfileUseCommand() *cobra.Command { clearCompatCache() format, _ := cmd.Root().PersistentFlags().GetString("format") if strings.EqualFold(strings.TrimSpace(format), "json") { - return writeProfileUseJSON(cmd.OutOrStdout(), profile) + cfg, loadErr := authpkg.LoadProfiles(configDir) + if loadErr != nil { + return apperrors.NewInternal(fmt.Sprintf("failed to load profiles: %v", loadErr)) + } + return writeProfileUseJSON(cmd.OutOrStdout(), profile, cfg) } fmt.Fprintf(cmd.OutOrStdout(), "[OK] 当前 profile: %s (%s)\n", profile.Name, profile.CorpID) return nil @@ -140,10 +144,16 @@ func writeProfileListJSON(w io.Writer, cfg *authpkg.ProfilesConfig) error { return enc.Encode(resp) } -func writeProfileUseJSON(w io.Writer, profile *authpkg.Profile) error { +func writeProfileUseJSON(w io.Writer, profile *authpkg.Profile, cfg *authpkg.ProfilesConfig) error { resp := profileUseResponse{Success: true} if profile != nil { - resp.Profile = profileViewFromProfile(*profile, profile.CorpID, profile.CorpID) + primaryProfile := "" + currentProfile := "" + if cfg != nil { + primaryProfile = cfg.PrimaryProfile + currentProfile = cfg.CurrentProfile + } + resp.Profile = profileViewFromProfile(*profile, primaryProfile, currentProfile) } enc := json.NewEncoder(w) enc.SetIndent("", " ") diff --git a/internal/app/profile_command_test.go b/internal/app/profile_command_test.go new file mode 100644 index 00000000..e258261b --- /dev/null +++ b/internal/app/profile_command_test.go @@ -0,0 +1,49 @@ +// 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" + "encoding/json" + "testing" + + authpkg "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/auth" +) + +func TestWriteProfileUseJSONKeepsPrimaryAndCurrentDistinct(t *testing.T) { + profile := &authpkg.Profile{ + Name: "B Org", + CorpID: "corp_b", + CorpName: "B Org", + Status: authpkg.ProfileStatusActive, + } + cfg := &authpkg.ProfilesConfig{ + PrimaryProfile: "corp_a", + CurrentProfile: "corp_b", + } + var buf bytes.Buffer + if err := writeProfileUseJSON(&buf, profile, cfg); err != nil { + t.Fatalf("writeProfileUseJSON() error = %v", err) + } + var resp profileUseResponse + if err := json.Unmarshal(buf.Bytes(), &resp); err != nil { + t.Fatalf("Unmarshal() error = %v", err) + } + if !resp.Profile.IsCurrent { + t.Fatalf("isCurrent = false, want true") + } + if resp.Profile.IsPrimary { + t.Fatalf("isPrimary = true, want false") + } +} diff --git a/internal/auth/auth_extra_test.go b/internal/auth/auth_extra_test.go index 2eb5b682..5d702d32 100644 --- a/internal/auth/auth_extra_test.go +++ b/internal/auth/auth_extra_test.go @@ -330,6 +330,63 @@ func TestBuildTokenData_DefaultExpiry(t *testing.T) { } } +func TestParseMCPTokenResponseIncludesCorpName(t *testing.T) { + provider := &OAuthProvider{} + data, err := provider.parseMCPTokenResponse([]byte(`{ + "accessToken": "access-123", + "refreshToken": "refresh-456", + "expiresIn": 7200, + "corpId": "ding123", + "corpName": "钉钉(中国)信息技术有限公司" + }`)) + if err != nil { + t.Fatalf("parseMCPTokenResponse() error = %v", err) + } + if data.CorpID != "ding123" { + t.Fatalf("corp id = %q, want ding123", data.CorpID) + } + if data.CorpName != "钉钉(中国)信息技术有限公司" { + t.Fatalf("corp name = %q, want 钉钉(中国)信息技术有限公司", data.CorpName) + } +} + +func TestParseMCPTokenResponseCorpNameFallbacks(t *testing.T) { + provider := &OAuthProvider{} + for _, tc := range []struct { + name string + body string + want string + }{ + { + name: "snake", + body: `{"accessToken":"access","refreshToken":"refresh","expiresIn":7200,"corpId":"ding123","corp_name":"Snake Corp"}`, + want: "Snake Corp", + }, + { + name: "orgName", + body: `{"accessToken":"access","refreshToken":"refresh","expiresIn":7200,"corpId":"ding123","orgName":"Org Corp"}`, + want: "Org Corp", + }, + } { + t.Run(tc.name, func(t *testing.T) { + data, err := provider.parseMCPTokenResponse([]byte(tc.body)) + if err != nil { + t.Fatalf("parseMCPTokenResponse() error = %v", err) + } + if data.CorpName != tc.want { + t.Fatalf("corp name = %q, want %q", data.CorpName, tc.want) + } + }) + } +} + +func TestBuildAuthURLIncludesTargetCorpID(t *testing.T) { + authURL := buildAuthURL("client-id", "http://127.0.0.1:1234/callback", "ding-target") + if !strings.Contains(authURL, "corpId=ding-target") { + t.Fatalf("auth URL missing target corpId: %s", authURL) + } +} + func buildTokenDataFromResponse(resp tokenResponse) *TokenData { if resp.AccessToken == "" { return nil diff --git a/internal/auth/oauth_helpers.go b/internal/auth/oauth_helpers.go index 372b353a..00ba7ad3 100644 --- a/internal/auth/oauth_helpers.go +++ b/internal/auth/oauth_helpers.go @@ -23,6 +23,7 @@ import ( "net/url" "os" "slices" + "strings" "time" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/config" @@ -143,7 +144,9 @@ func (p *OAuthProvider) refreshWithRefreshToken(ctx context.Context, data *Token updated.CorpID = data.CorpID updated.UserID = data.UserID updated.UserName = data.UserName - updated.CorpName = data.CorpName + if updated.CorpName == "" { + updated.CorpName = data.CorpName + } if err := SaveTokenData(p.configDir, updated); err != nil { return nil, fmt.Errorf("保存刷新后的 token 失败(旧 refresh_token 已失效,请重新登录): %w", err) @@ -185,7 +188,9 @@ func (p *OAuthProvider) refreshViaMCP(ctx context.Context, data *TokenData) (*To updated.CorpID = data.CorpID updated.UserID = data.UserID updated.UserName = data.UserName - updated.CorpName = data.CorpName + if updated.CorpName == "" { + updated.CorpName = data.CorpName + } if err := SaveTokenData(p.configDir, updated); err != nil { return nil, fmt.Errorf("保存刷新后的 token 失败(旧 refresh_token 已失效,请重新登录): %w", err) @@ -259,7 +264,7 @@ func (p *OAuthProvider) parseTokenResponse(body []byte) (*TokenData, error) { } // parseMCPTokenResponse parses token response from MCP proxy. -// MCP OAuth response format: {"accessToken": "...", "refreshToken": "...", "expiresIn": 7200, "corpId": "..."} +// MCP OAuth response format: {"accessToken": "...", "refreshToken": "...", "expiresIn": 7200, "corpId": "...", "corpName": "..."} func (p *OAuthProvider) parseMCPTokenResponse(body []byte) (*TokenData, error) { var resp struct { AccessToken string `json:"accessToken"` @@ -267,6 +272,9 @@ func (p *OAuthProvider) parseMCPTokenResponse(body []byte) (*TokenData, error) { PersistentCode string `json:"persistentCode"` ExpiresIn int64 `json:"expiresIn"` CorpID string `json:"corpId"` + CorpName string `json:"corpName"` + CorpNameSnake string `json:"corp_name"` + OrgName string `json:"orgName"` // Error fields (when request fails) ErrorCode string `json:"errorCode,omitempty"` ErrorMsg string `json:"errorMsg,omitempty"` @@ -293,6 +301,7 @@ func (p *OAuthProvider) parseMCPTokenResponse(body []byte) (*TokenData, error) { ExpiresAt: now.Add(time.Duration(expiresIn) * time.Second), RefreshExpAt: now.Add(config.DefaultRefreshTokenLifetime), CorpID: resp.CorpID, + CorpName: firstNonEmpty(resp.CorpName, resp.CorpNameSnake, resp.OrgName), } if resp.PersistentCode != "" { data.PersistentCode = resp.PersistentCode @@ -300,7 +309,16 @@ func (p *OAuthProvider) parseMCPTokenResponse(body []byte) (*TokenData, error) { return data, nil } -func buildAuthURL(clientID, redirectURI string) string { +func firstNonEmpty(values ...string) string { + for _, v := range values { + if trimmed := strings.TrimSpace(v); trimmed != "" { + return trimmed + } + } + return "" +} + +func buildAuthURL(clientID, redirectURI, targetCorpID string) string { params := url.Values{ "client_id": {clientID}, "redirect_uri": {redirectURI}, @@ -308,6 +326,9 @@ func buildAuthURL(clientID, redirectURI string) string { "scope": {DefaultScopes}, "prompt": {"consent"}, } + if targetCorpID = strings.TrimSpace(targetCorpID); targetCorpID != "" { + params.Set("corpId", targetCorpID) + } return AuthorizeURL + "?" + params.Encode() } diff --git a/internal/auth/oauth_provider.go b/internal/auth/oauth_provider.go index accb1ea5..3d08da25 100644 --- a/internal/auth/oauth_provider.go +++ b/internal/auth/oauth_provider.go @@ -37,12 +37,13 @@ var oauthHTTPClient = &http.Client{ // OAuthProvider handles the DingTalk OAuth 2.0 authorization code flow. type OAuthProvider struct { - configDir string - clientID string - logger *slog.Logger - Output io.Writer - httpClient *http.Client - NoBrowser bool + configDir string + clientID string + logger *slog.Logger + Output io.Writer + httpClient *http.Client + NoBrowser bool + TargetCorpID string } // NewOAuthProvider creates a new OAuth provider. @@ -397,7 +398,7 @@ func (p *OAuthProvider) Login(ctx context.Context, force bool) (*TokenData, erro _ = server.Shutdown(shutCtx) }() - authURL := buildAuthURL(p.clientID, redirectURI) + authURL := buildAuthURL(p.clientID, redirectURI, p.TargetCorpID) if p.logger != nil { p.logger.Debug("authorization URL", "url", authURL) } diff --git a/internal/auth/profiles.go b/internal/auth/profiles.go index 5d2e8229..bb353d61 100644 --- a/internal/auth/profiles.go +++ b/internal/auth/profiles.go @@ -193,7 +193,7 @@ func upsertProfileFromToken(configDir string, cfg *ProfilesConfig, data *TokenDa cfg.Profiles = append(cfg.Profiles, profile) } else { p := &cfg.Profiles[idx] - if strings.TrimSpace(p.Name) == "" { + if shouldRefreshProfileName(p, data) { p.Name = chooseProfileName(cfg, data) } if v := strings.TrimSpace(data.CorpName); v != "" { @@ -441,6 +441,9 @@ func normalizeProfilesConfig(cfg *ProfilesConfig) { if p.Name == "" { p.Name = p.CorpID } + if corpName := strings.TrimSpace(p.CorpName); p.Name == p.CorpID && corpName != "" && !profileNameTakenByOtherCorp(cfg, corpName, p.CorpID) { + p.Name = corpName + } if p.Status == "" { p.Status = ProfileStatusActive } @@ -488,6 +491,17 @@ func chooseProfileName(cfg *ProfilesConfig, data *TokenData) string { } } +func shouldRefreshProfileName(p *Profile, data *TokenData) bool { + if p == nil || data == nil { + return false + } + name := strings.TrimSpace(p.Name) + if name == "" { + return true + } + return strings.TrimSpace(data.CorpName) != "" && name == strings.TrimSpace(p.CorpID) +} + func profileNameTakenByOtherCorp(cfg *ProfilesConfig, name, corpID string) bool { name = strings.TrimSpace(name) corpID = strings.TrimSpace(corpID) @@ -507,12 +521,19 @@ func findProfile(cfg *ProfilesConfig, selector string) *Profile { if selector == "" { return nil } + var corpNameMatch *Profile for i := range cfg.Profiles { if cfg.Profiles[i].CorpID == selector || cfg.Profiles[i].Name == selector { return &cfg.Profiles[i] } + if strings.TrimSpace(cfg.Profiles[i].CorpName) == selector { + if corpNameMatch != nil { + return nil + } + corpNameMatch = &cfg.Profiles[i] + } } - return nil + return corpNameMatch } func profileIndexByCorpID(cfg *ProfilesConfig, corpID string) int { diff --git a/internal/auth/token_test.go b/internal/auth/token_test.go index 866b7a9f..4651b1b8 100644 --- a/internal/auth/token_test.go +++ b/internal/auth/token_test.go @@ -14,6 +14,7 @@ package auth import ( + "os" "testing" "time" @@ -262,6 +263,111 @@ func TestDeleteProfilePreservesOtherProfiles(t *testing.T) { } } +func TestUpsertProfileFromTokenOverwritesSameCorp(t *testing.T) { + cleanupKeychain(t) + configDir := t.TempDir() + + first := testToken("at_first", "corp_same", "旧组织名") + if err := SaveTokenData(configDir, first); err != nil { + t.Fatalf("SaveTokenData(first) error = %v", err) + } + second := testToken("at_second", "corp_same", "新组织名") + second.UserID = "user_updated" + second.UserName = "Updated User" + second.ClientID = "client_updated" + if err := SaveTokenData(configDir, second); err != nil { + t.Fatalf("SaveTokenData(second) error = %v", err) + } + + cfg, err := LoadProfiles(configDir) + if err != nil { + t.Fatalf("LoadProfiles() error = %v", err) + } + if len(cfg.Profiles) != 1 { + t.Fatalf("profiles len = %d, want 1: %#v", len(cfg.Profiles), cfg.Profiles) + } + profile := cfg.Profiles[0] + if profile.CorpName != "新组织名" { + t.Fatalf("corpName = %q, want 新组织名", profile.CorpName) + } + if profile.UserID != "user_updated" || profile.UserName != "Updated User" || profile.ClientID != "client_updated" { + t.Fatalf("profile metadata was not overwritten: %#v", profile) + } + loaded, err := LoadTokenDataForProfile(configDir, "corp_same") + if err != nil { + t.Fatalf("LoadTokenDataForProfile() error = %v", err) + } + if loaded.AccessToken != "at_second" { + t.Fatalf("access token = %q, want at_second", loaded.AccessToken) + } +} + +func TestUpsertProfileFromTokenPromotesCorpIDNameToCorpName(t *testing.T) { + cleanupKeychain(t) + configDir := t.TempDir() + + first := testToken("at_first", "corp_same", "") + if err := SaveTokenData(configDir, first); err != nil { + t.Fatalf("SaveTokenData(first) error = %v", err) + } + second := testToken("at_second", "corp_same", "新组织名") + if err := SaveTokenData(configDir, second); err != nil { + t.Fatalf("SaveTokenData(second) error = %v", err) + } + + cfg, err := LoadProfiles(configDir) + if err != nil { + t.Fatalf("LoadProfiles() error = %v", err) + } + if len(cfg.Profiles) != 1 { + t.Fatalf("profiles len = %d, want 1: %#v", len(cfg.Profiles), cfg.Profiles) + } + if cfg.Profiles[0].Name != "新组织名" { + t.Fatalf("profile name = %q, want 新组织名", cfg.Profiles[0].Name) + } + + resolved, err := ResolveProfile(configDir, "新组织名") + if err != nil { + t.Fatalf("ResolveProfile(corpName) error = %v", err) + } + if resolved.CorpID != "corp_same" { + t.Fatalf("resolved corpId = %q, want corp_same", resolved.CorpID) + } +} + +func TestLoadProfilesPromotesLegacyCorpIDNameToCorpName(t *testing.T) { + configDir := t.TempDir() + raw := `{ + "version": 1, + "primaryProfile": "corp_same", + "currentProfile": "corp_same", + "profiles": [ + { + "name": "corp_same", + "corpId": "corp_same", + "corpName": "新组织名" + } + ] +}` + if err := os.MkdirAll(configDir, 0o700); err != nil { + t.Fatalf("MkdirAll() error = %v", err) + } + if err := os.WriteFile(ProfilesPath(configDir), []byte(raw), 0o600); err != nil { + t.Fatalf("WriteFile(profiles.json) error = %v", err) + } + + cfg, err := LoadProfiles(configDir) + if err != nil { + t.Fatalf("LoadProfiles() error = %v", err) + } + if len(cfg.Profiles) != 1 { + t.Fatalf("profiles len = %d, want 1", len(cfg.Profiles)) + } + if cfg.Profiles[0].Name != "新组织名" { + t.Fatalf("profile name = %q, want 新组织名", cfg.Profiles[0].Name) + } +} + func TestLegacyKeychainMigrationInitializesProfile(t *testing.T) { cleanupKeychain(t) configDir := t.TempDir() From 756c7d14a6ad2fed6fd93b94a544dd546a237bf7 Mon Sep 17 00:00:00 2001 From: "shangguanxuan.sgx" Date: Fri, 26 Jun 2026 10:32:03 +0800 Subject: [PATCH 03/22] =?UTF-8?q?feat(auth):=20=E5=AE=8C=E6=88=90=E5=A4=9A?= =?UTF-8?q?=E7=BB=84=E7=BB=87=20profile=20=E9=AA=8C=E6=94=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/app/auth_command.go | 15 +- internal/app/auth_command_test.go | 210 +++++++++++++++++++++++++ internal/app/help_source_test.go | 7 +- internal/app/profile_command.go | 42 ++++- internal/app/profile_command_test.go | 142 +++++++++++++++++ internal/app/root_execute_test.go | 2 +- internal/app/root_help.go | 49 ++++++ prd.json | 219 +++++++++++++++++++++++++++ 8 files changed, 677 insertions(+), 9 deletions(-) create mode 100644 prd.json diff --git a/internal/app/auth_command.go b/internal/app/auth_command.go index 4bb1e5da..48e2da70 100644 --- a/internal/app/auth_command.go +++ b/internal/app/auth_command.go @@ -412,9 +412,10 @@ func newAuthLogoutCommand() *cobra.Command { w := cmd.OutOrStdout() if all { fmt.Fprintln(w, "[OK] 已清除所有非主 profile 认证信息") - } else { - fmt.Fprintln(w, "[OK] 已清除认证信息") + fmt.Fprintln(w, "主 profile 已保留;如需清除全部认证信息,请运行 dws auth reset") + return nil } + fmt.Fprintln(w, "[OK] 已清除认证信息") if !edition.Get().IsEmbedded { fmt.Fprintln(w, "请运行 dws auth login --recommend 重新登录") } @@ -483,6 +484,12 @@ func newAuthStatusCommand() *cobra.Command { 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 { @@ -515,8 +522,8 @@ func logoutOneProfile(_ *cobra.Command, ctx context.Context, configDir, selector if loadErr != nil { return apperrors.NewInternal(fmt.Sprintf("failed to load profiles: %v", loadErr)) } - if len(cfg.Profiles) > 1 && selected.CorpID == cfg.PrimaryProfile { - return apperrors.NewValidation("primary profile cannot be logged out while other profiles exist; switch to another profile or use auth reset") + if selected.CorpID == cfg.PrimaryProfile { + return apperrors.NewValidation("primary profile cannot be logged out; use auth reset to clear all profiles") } } restoreProfile := pushRuntimeProfile(selector) diff --git a/internal/app/auth_command_test.go b/internal/app/auth_command_test.go index d425faef..3f6ef8f3 100644 --- a/internal/app/auth_command_test.go +++ b/internal/app/auth_command_test.go @@ -184,6 +184,178 @@ func TestAuthStatusRefreshFailureLeavesStoredTokenIntact(t *testing.T) { } } +func TestAuthStatusTableIncludesCorpName(t *testing.T) { + setupAuthLogoutProfiles(t, authLogoutTestToken("corp_primary")) + + cmd := NewRootCommand() + var out bytes.Buffer + cmd.SetOut(&out) + cmd.SetErr(&out) + cmd.SetArgs([]string{"--format", "table", "auth", "status"}) + if err := cmd.Execute(); err != nil { + t.Fatalf("auth status --format table error = %v\noutput:\n%s", err, out.String()) + } + for _, want := range []string{"企业:", "corp_primary org", "企业 ID:", "corp_primary"} { + if !bytes.Contains(out.Bytes(), []byte(want)) { + t.Fatalf("auth status table missing %q in output:\n%s", want, out.String()) + } + } +} + +func TestAuthStatusProfileOverrideDoesNotSwitchCurrentProfile(t *testing.T) { + configDir := setupAuthLogoutProfiles(t, + authLogoutTestToken("corp_primary"), + authLogoutTestToken("corp_secondary"), + ) + + cmd := NewRootCommand() + var out bytes.Buffer + cmd.SetOut(&out) + cmd.SetErr(&out) + cmd.SetArgs([]string{"--format", "table", "auth", "status", "--profile", "corp_primary"}) + if err := cmd.Execute(); err != nil { + t.Fatalf("auth status --profile error = %v\noutput:\n%s", err, out.String()) + } + for _, want := range []string{"corp_primary org", "corp_primary"} { + if !bytes.Contains(out.Bytes(), []byte(want)) { + t.Fatalf("auth status --profile output missing %q:\n%s", want, out.String()) + } + } + if bytes.Contains(out.Bytes(), []byte("corp_secondary org")) { + t.Fatalf("auth status --profile should render selected profile, got:\n%s", out.String()) + } + cfg, err := authpkg.LoadProfiles(configDir) + if err != nil { + t.Fatalf("LoadProfiles() error = %v", err) + } + if cfg.CurrentProfile != "corp_secondary" { + t.Fatalf("currentProfile = %q, want unchanged corp_secondary", cfg.CurrentProfile) + } +} + +func TestAuthLogoutPrimarySingleProfileReturnsValidationAndKeepsToken(t *testing.T) { + for _, tc := range []struct { + name string + args []string + }{ + {name: "default current primary", args: []string{"auth", "logout"}}, + {name: "explicit primary profile", args: []string{"auth", "logout", "--profile", "corp_primary"}}, + } { + t.Run(tc.name, func(t *testing.T) { + configDir := setupAuthLogoutProfiles(t, authLogoutTestToken("corp_primary")) + + revokeCalls := 0 + originalTransport := http.DefaultTransport + t.Cleanup(func() { + http.DefaultTransport = originalTransport + }) + http.DefaultTransport = roundTripFunc(func(req *http.Request) (*http.Response, error) { + revokeCalls++ + return nil, errors.New("unexpected remote revoke for protected primary profile") + }) + + cmd := NewRootCommand() + var out bytes.Buffer + cmd.SetOut(&out) + cmd.SetErr(&out) + cmd.SetArgs(tc.args) + + err := cmd.Execute() + if err == nil { + t.Fatalf("Execute(%v) succeeded, want validation error", tc.args) + } + var appErr *apperrors.Error + if !errors.As(err, &appErr) || appErr.Category != apperrors.CategoryValidation { + t.Fatalf("expected validation error, got %T: %v", err, err) + } + if !strings.Contains(err.Error(), "auth reset") { + t.Fatalf("error = %v, want auth reset hint", err) + } + if revokeCalls != 0 { + t.Fatalf("remote revoke calls = %d, want 0 for protected primary profile", revokeCalls) + } + + cfg, err := authpkg.LoadProfiles(configDir) + if err != nil { + t.Fatalf("LoadProfiles() error = %v", err) + } + if cfg.PrimaryProfile != "corp_primary" || cfg.CurrentProfile != "corp_primary" { + t.Fatalf("profiles pointers = primary %q current %q, want corp_primary/corp_primary", cfg.PrimaryProfile, cfg.CurrentProfile) + } + if len(cfg.Profiles) != 1 || cfg.Profiles[0].CorpID != "corp_primary" { + t.Fatalf("profiles = %#v, want only corp_primary retained", cfg.Profiles) + } + if !authpkg.TokenDataExistsKeychainForCorpID("corp_primary") { + t.Fatal("primary profile token should be retained") + } + loaded, err := authpkg.LoadTokenDataForProfile(configDir, "corp_primary") + if err != nil { + t.Fatalf("LoadTokenDataForProfile(primary) error = %v", err) + } + if loaded.AccessToken != "access-corp_primary" { + t.Fatalf("primary access token = %q, want retained token", loaded.AccessToken) + } + if !authpkg.TokenDataExistsKeychain() { + t.Fatal("legacy auth-token mirror should remain after rejected primary logout") + } + }) + } +} + +func TestAuthLogoutAllKeepsPrimaryAndDeletesNonPrimary(t *testing.T) { + configDir := setupAuthLogoutProfiles(t, + authLogoutTestToken("corp_primary"), + authLogoutTestToken("corp_secondary"), + ) + + originalTransport := http.DefaultTransport + t.Cleanup(func() { + http.DefaultTransport = originalTransport + }) + http.DefaultTransport = roundTripFunc(func(req *http.Request) (*http.Response, error) { + return nil, errors.New("remote revoke disabled in unit test") + }) + + cmd := NewRootCommand() + var out bytes.Buffer + cmd.SetOut(&out) + cmd.SetErr(&out) + cmd.SetArgs([]string{"auth", "logout", "--all"}) + if err := cmd.Execute(); err != nil { + t.Fatalf("auth logout --all error = %v\noutput:\n%s", err, out.String()) + } + if !strings.Contains(out.String(), "主 profile 已保留") { + t.Fatalf("auth logout --all output should mention retained primary profile:\n%s", out.String()) + } + if strings.Contains(out.String(), "重新登录") { + t.Fatalf("auth logout --all output should not ask for re-login while primary remains:\n%s", out.String()) + } + + cfg, err := authpkg.LoadProfiles(configDir) + if err != nil { + t.Fatalf("LoadProfiles() error = %v", err) + } + if cfg.PrimaryProfile != "corp_primary" || cfg.CurrentProfile != "corp_primary" { + t.Fatalf("profiles pointers = primary %q current %q, want corp_primary/corp_primary", cfg.PrimaryProfile, cfg.CurrentProfile) + } + if len(cfg.Profiles) != 1 || cfg.Profiles[0].CorpID != "corp_primary" { + t.Fatalf("profiles = %#v, want only corp_primary retained", cfg.Profiles) + } + if !authpkg.TokenDataExistsKeychainForCorpID("corp_primary") { + t.Fatal("primary profile token should be retained") + } + if authpkg.TokenDataExistsKeychainForCorpID("corp_secondary") { + t.Fatal("non-primary profile token should be deleted") + } + loaded, err := authpkg.LoadTokenData(configDir) + if err != nil { + t.Fatalf("LoadTokenData() error = %v", err) + } + if loaded.CorpID != "corp_primary" || loaded.AccessToken != "access-corp_primary" { + t.Fatalf("default token = (%q, %q), want retained primary token", loaded.CorpID, loaded.AccessToken) + } +} + func TestAuthLoginPostLoginTUIModeRespectsRecommendAndFormat(t *testing.T) { newRoot := func(t *testing.T) *cobra.Command { t.Helper() @@ -689,3 +861,41 @@ func stringSliceArgEqual(got any, want []string) bool { return false } } + +func setupAuthLogoutProfiles(t *testing.T, tokens ...*authpkg.TokenData) string { + t.Helper() + root := t.TempDir() + configDir := filepath.Join(root, "config") + t.Setenv(keychain.DisableKeychainEnv, "1") + t.Setenv(keychain.StorageDirEnv, filepath.Join(root, "keychain")) + t.Setenv("DWS_CONFIG_DIR", configDir) + authpkg.SetRuntimeProfile("") + ResetRuntimeTokenCache() + clearCompatCache() + t.Cleanup(func() { + authpkg.SetRuntimeProfile("") + ResetRuntimeTokenCache() + clearCompatCache() + }) + + for _, token := range tokens { + if err := authpkg.SaveTokenData(configDir, token); err != nil { + t.Fatalf("SaveTokenData(%s) error = %v", token.CorpID, err) + } + } + return configDir +} + +func authLogoutTestToken(corpID string) *authpkg.TokenData { + return &authpkg.TokenData{ + AccessToken: "access-" + corpID, + RefreshToken: "refresh-" + corpID, + ExpiresAt: time.Now().Add(time.Hour), + RefreshExpAt: time.Now().Add(24 * time.Hour), + CorpID: corpID, + CorpName: corpID + " org", + UserID: "user-" + corpID, + UserName: "User " + corpID, + ClientID: "client-" + corpID, + } +} diff --git a/internal/app/help_source_test.go b/internal/app/help_source_test.go index 15433a26..48e102ff 100644 --- a/internal/app/help_source_test.go +++ b/internal/app/help_source_test.go @@ -161,11 +161,16 @@ func TestRootHelpUsesMCPOnlySummary(t *testing.T) { t.Fatalf("root help missing %q:\n%s", want, got) } } - for _, unwanted := range []string{"快速开始:", "更多信息:", "auth 认证管理", "Flags:"} { + for _, unwanted := range []string{"快速开始:", "更多信息:", "auth 认证管理"} { if strings.Contains(got, unwanted) { t.Fatalf("root help unexpectedly contains %q:\n%s", unwanted, got) } } + for _, want := range []string{"Global Flags:", "--profile"} { + if !strings.Contains(got, want) { + t.Fatalf("root help missing %q:\n%s", want, got) + } + } } func TestRootHelpCustomizationDoesNotAffectSubcommandHelp(t *testing.T) { diff --git a/internal/app/profile_command.go b/internal/app/profile_command.go index b8c05fc3..37b906b3 100644 --- a/internal/app/profile_command.go +++ b/internal/app/profile_command.go @@ -95,7 +95,7 @@ func newProfileUseCommand() *cobra.Command { } return writeProfileUseJSON(cmd.OutOrStdout(), profile, cfg) } - fmt.Fprintf(cmd.OutOrStdout(), "[OK] 当前 profile: %s (%s)\n", profile.Name, profile.CorpID) + fmt.Fprintln(cmd.OutOrStdout(), profileUseMessage(profile)) return nil }, } @@ -165,7 +165,7 @@ func writeProfileListTable(w io.Writer, cfg *authpkg.ProfilesConfig) { fmt.Fprintln(w, "未找到已登录 profile") return } - fmt.Fprintf(w, "%-3s %-3s %-28s %-34s %-10s %s\n", "CUR", "PRI", "NAME", "CORP_ID", "STATUS", "USER") + fmt.Fprintf(w, "%-3s %-3s %-24s %-28s %-34s %-10s %s\n", "CUR", "PRI", "PROFILE", "ORG_NAME", "CORP_ID", "STATUS", "USER") for _, p := range cfg.Profiles { current := "" if p.CorpID == cfg.CurrentProfile { @@ -183,10 +183,46 @@ func writeProfileListTable(w io.Writer, cfg *authpkg.ProfilesConfig) { if status == "" { status = authpkg.ProfileStatusActive } - fmt.Fprintf(w, "%-3s %-3s %-28s %-34s %-10s %s\n", current, primary, clipProfileCell(p.Name, 28), clipProfileCell(p.CorpID, 34), status, user) + fmt.Fprintf( + w, + "%-3s %-3s %-24s %-28s %-34s %-10s %s\n", + current, + primary, + clipProfileCell(p.Name, 24), + clipProfileCell(profileOrgName(p), 28), + clipProfileCell(p.CorpID, 34), + status, + user, + ) } } +func profileUseMessage(profile *authpkg.Profile) string { + if profile == nil { + return "[OK] 当前 profile 已切换" + } + name := strings.TrimSpace(profile.Name) + corpID := strings.TrimSpace(profile.CorpID) + if name == "" { + name = corpID + } + orgName := strings.TrimSpace(profile.CorpName) + if orgName == "" { + orgName = name + } + return fmt.Sprintf("[OK] 当前 profile: %s | 组织: %s (%s)", name, orgName, corpID) +} + +func profileOrgName(p authpkg.Profile) string { + if v := strings.TrimSpace(p.CorpName); v != "" { + return v + } + if v := strings.TrimSpace(p.Name); v != "" { + return v + } + return strings.TrimSpace(p.CorpID) +} + func profileViews(cfg *authpkg.ProfilesConfig) []profileView { if cfg == nil { return nil diff --git a/internal/app/profile_command_test.go b/internal/app/profile_command_test.go index e258261b..1ab7f2ee 100644 --- a/internal/app/profile_command_test.go +++ b/internal/app/profile_command_test.go @@ -47,3 +47,145 @@ func TestWriteProfileUseJSONKeepsPrimaryAndCurrentDistinct(t *testing.T) { t.Fatalf("isPrimary = true, want false") } } + +func TestProfileListRootCommandJSONIncludesCorpName(t *testing.T) { + setupAuthLogoutProfiles(t, + authLogoutTestToken("corp_primary"), + authLogoutTestToken("corp_secondary"), + ) + + cmd := NewRootCommand() + var out bytes.Buffer + cmd.SetOut(&out) + cmd.SetErr(&out) + cmd.SetArgs([]string{"--format", "json", "profile", "list"}) + if err := cmd.Execute(); err != nil { + t.Fatalf("profile list --format json error = %v\noutput:\n%s", err, out.String()) + } + var resp profileListResponse + if err := json.Unmarshal(out.Bytes(), &resp); err != nil { + t.Fatalf("Unmarshal() error = %v\noutput:\n%s", err, out.String()) + } + if !resp.Success { + t.Fatal("success = false, want true") + } + if resp.PrimaryProfile != "corp_primary" || resp.CurrentProfile != "corp_secondary" || resp.PreviousProfile != "corp_primary" { + t.Fatalf("profile pointers = primary %q current %q previous %q, want corp_primary/corp_secondary/corp_primary", resp.PrimaryProfile, resp.CurrentProfile, resp.PreviousProfile) + } + if len(resp.Profiles) != 2 { + t.Fatalf("profiles len = %d, want 2", len(resp.Profiles)) + } + for _, p := range resp.Profiles { + if p.CorpName == "" { + t.Fatalf("profile %s missing corpName in JSON response: %#v", p.CorpID, p) + } + } +} + +func TestProfileUseRootCommandSwitchesOrganizationAndLegacyMirror(t *testing.T) { + configDir := setupAuthLogoutProfiles(t, + authLogoutTestToken("corp_primary"), + authLogoutTestToken("corp_secondary"), + ) + + cmd := NewRootCommand() + var out bytes.Buffer + cmd.SetOut(&out) + cmd.SetErr(&out) + cmd.SetArgs([]string{"--format", "table", "profile", "use", "corp_primary"}) + if err := cmd.Execute(); err != nil { + t.Fatalf("profile use corp_primary error = %v\noutput:\n%s", err, out.String()) + } + if !bytes.Contains(out.Bytes(), []byte("组织: corp_primary org")) { + t.Fatalf("profile use output should include organization name:\n%s", out.String()) + } + cfg, err := authpkg.LoadProfiles(configDir) + if err != nil { + t.Fatalf("LoadProfiles() error = %v", err) + } + if cfg.CurrentProfile != "corp_primary" || cfg.PreviousProfile != "corp_secondary" { + t.Fatalf("profile pointers = current %q previous %q, want corp_primary/corp_secondary", cfg.CurrentProfile, cfg.PreviousProfile) + } + legacyToken, err := authpkg.LoadTokenData(configDir) + if err != nil { + t.Fatalf("LoadTokenData() error = %v", err) + } + if legacyToken.CorpID != "corp_primary" { + t.Fatalf("legacy token corp = %q, want corp_primary", legacyToken.CorpID) + } + + cmd = NewRootCommand() + out.Reset() + cmd.SetOut(&out) + cmd.SetErr(&out) + cmd.SetArgs([]string{"--format", "table", "profile", "use", "-"}) + if err := cmd.Execute(); err != nil { + t.Fatalf("profile use - error = %v\noutput:\n%s", err, out.String()) + } + if !bytes.Contains(out.Bytes(), []byte("组织: corp_secondary org")) { + t.Fatalf("profile use - output should include organization name:\n%s", out.String()) + } + cfg, err = authpkg.LoadProfiles(configDir) + if err != nil { + t.Fatalf("LoadProfiles() error = %v", err) + } + if cfg.CurrentProfile != "corp_secondary" || cfg.PreviousProfile != "corp_primary" { + t.Fatalf("profile pointers = current %q previous %q, want corp_secondary/corp_primary", cfg.CurrentProfile, cfg.PreviousProfile) + } + legacyToken, err = authpkg.LoadTokenData(configDir) + if err != nil { + t.Fatalf("LoadTokenData() error = %v", err) + } + if legacyToken.CorpID != "corp_secondary" { + t.Fatalf("legacy token corp = %q, want corp_secondary", legacyToken.CorpID) + } +} + +func TestWriteProfileListTableIncludesCorpName(t *testing.T) { + cfg := &authpkg.ProfilesConfig{ + PrimaryProfile: "corp_a", + CurrentProfile: "corp_b", + Profiles: []authpkg.Profile{ + { + Name: "DingTalk China", + CorpID: "corp_a", + CorpName: "钉钉(中国)信息技术有限公司", + UserName: "alice", + Status: authpkg.ProfileStatusActive, + }, + { + Name: "B Org", + CorpID: "corp_b", + CorpName: "B 组织", + UserID: "bob-id", + }, + }, + } + var buf bytes.Buffer + writeProfileListTable(&buf, cfg) + out := buf.String() + for _, want := range []string{ + "ORG_NAME", + "钉钉(中国)信息技术有限公司", + "B 组织", + "corp_a", + "corp_b", + } { + if !bytes.Contains(buf.Bytes(), []byte(want)) { + t.Fatalf("profile list table missing %q in output:\n%s", want, out) + } + } +} + +func TestProfileUseMessageIncludesCorpName(t *testing.T) { + got := profileUseMessage(&authpkg.Profile{ + Name: "DingTalk China", + CorpID: "ding8196", + CorpName: "钉钉(中国)信息技术有限公司", + }) + for _, want := range []string{"DingTalk China", "组织: 钉钉(中国)信息技术有限公司", "ding8196"} { + if !bytes.Contains([]byte(got), []byte(want)) { + t.Fatalf("profileUseMessage() missing %q in %q", want, got) + } + } +} diff --git a/internal/app/root_execute_test.go b/internal/app/root_execute_test.go index 4a2e57b0..7ec88e8b 100644 --- a/internal/app/root_execute_test.go +++ b/internal/app/root_execute_test.go @@ -263,7 +263,7 @@ func TestRootHelpDoesNotRequirePINOrLogin(t *testing.T) { if !strings.Contains(out.String(), "Discovered MCP Services:") { t.Fatalf("root help output missing MCP summary:\n%s", out.String()) } - for _, want := range []string{"Utility Commands:", "skill", "auth", "version"} { + for _, want := range []string{"Utility Commands:", "skill", "auth", "profile", "version", "Global Flags:", "--profile"} { if !strings.Contains(out.String(), want) { t.Fatalf("root help output missing %q:\n%s", want, out.String()) } diff --git a/internal/app/root_help.go b/internal/app/root_help.go index 552142bc..451b1129 100644 --- a/internal/app/root_help.go +++ b/internal/app/root_help.go @@ -9,6 +9,7 @@ import ( "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/tui" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition" "github.com/spf13/cobra" + "github.com/spf13/pflag" ) func configureRootHelp(root *cobra.Command) { @@ -86,6 +87,7 @@ func renderRootHelp(root *cobra.Command) { _ = tw.Flush() _, _ = fmt.Fprintln(w) } + renderRootGlobalFlags(root) _, _ = fmt.Fprintf(w, "%s %s\n", tui.Key("Next"), `Use "dws --help" for more information about a discovered MCP service or "dws --help" for utility commands.`) // Render root.Long after the command list so agents see the upgrade @@ -99,6 +101,53 @@ func renderRootHelp(root *cobra.Command) { } } +func renderRootGlobalFlags(root *cobra.Command) { + if root == nil { + return + } + flags := visiblePersistentFlags(root) + if len(flags) == 0 { + return + } + w := root.OutOrStdout() + _, _ = fmt.Fprintln(w, tui.Section("Global Flags:")) + _, _ = fmt.Fprintln(w) + tw := tabwriter.NewWriter(w, 0, 0, 2, ' ', 0) + for _, flag := range flags { + _, _ = fmt.Fprintf(tw, " %s\t%s\n", formatRootFlag(flag), tui.Dim(strings.TrimSpace(flag.Usage))) + } + _ = tw.Flush() + _, _ = fmt.Fprintln(w) +} + +func visiblePersistentFlags(root *cobra.Command) []*pflag.Flag { + if root == nil { + return nil + } + flags := make([]*pflag.Flag, 0) + root.PersistentFlags().VisitAll(func(flag *pflag.Flag) { + if flag == nil || flag.Hidden { + return + } + flags = append(flags, flag) + }) + return flags +} + +func formatRootFlag(flag *pflag.Flag) string { + if flag == nil { + return "" + } + name := "--" + flag.Name + if flag.Value != nil && flag.Value.Type() != "bool" { + name += " " + flag.Value.Type() + } + if flag.Shorthand == "" { + return " " + name + } + return "-" + flag.Shorthand + ", " + name +} + func commandShort(cmd *cobra.Command) string { if cmd == nil { return "" diff --git a/prd.json b/prd.json new file mode 100644 index 00000000..1f86ce6a --- /dev/null +++ b/prd.json @@ -0,0 +1,219 @@ +{ + "version": 1, + "title": "dws multi-profile organization login", + "generatedAt": "2026-06-26", + "method": "ralph-core-architect", + "sourceDocuments": [ + { + "title": "dws 多组织 CLI 技术方案", + "nodeId": "mweZ92PV6O36dZbnsMLBrOLGJxEKBD6p", + "url": "https://alidocs.dingtalk.com/i/nodes/mweZ92PV6O36dZbnsMLBrOLGJxEKBD6p?utm_scene=person_space", + "role": "primary_technical_design", + "receipt": "dws doc read logId 2127d89817824391288753543e0756" + }, + { + "title": "方案补充", + "nodeId": "MyQA2dXW7oOA63YacZgBYPbmWzlwrZgb", + "url": "https://alidocs.dingtalk.com/i/nodes/MyQA2dXW7oOA63YacZgBYPbmWzlwrZgb?utm_scene=person_space", + "role": "technical_scope_supplement", + "receipt": "dws doc read logId 0bab027317824391287061282e092c" + }, + { + "title": "dws支持多组织", + "nodeId": "MyQA2dXW7oOA63YacZ4vrQ1RWzlwrZgb", + "url": "https://alidocs.dingtalk.com/i/nodes/MyQA2dXW7oOA63YacZ4vrQ1RWzlwrZgb?utm_scene=person_space", + "role": "product_logic_reference", + "receipt": "dws doc read logId 0b5deb3217824391290727610e08f9" + } + ], + "problem": { + "summary": "同一个自然人可能属于多个钉钉组织,现有 dws 单槽 token 会让 CLI 默认只能稳定代理一个组织,和用户对个人 Agent 跨组织整理个人数据的心智不一致。", + "whyNow": "当前 TokenData 已包含 CorpID/CorpName/UserID/UserName 等组织绑定字段,缺口集中在单槽 keychain 到多槽 profile 的演进,以及运行时 profile 选择指针。", + "evidence": [ + "TokenData 是组织绑定,一份 token 对应一个 corp profile。", + "旧 keychain account 固定为 auth-token,是单组织覆盖的主要卡点。", + "client-secret: 已有键控多槽先例,可复用同类设计。" + ] + }, + "goals": [ + "同一用户可在本机 dws 登录多个钉钉组织 profile。", + "每个组织 token 独立存储、刷新、删除,互不覆盖。", + "dws 顶层直接暴露 profile 管理能力,终端调度可通过 dws profile 命中。", + "命令执行前能解析当前 profile,并把 UserID/CorpID 注入运行时。", + "保留旧单槽 auth-token 镜像,兼容旧版二进制和宿主探测。" + ], + "nonGoals": [ + "本期不做 real/嵌入宿主多组织 hook 协议扩展。", + "本期不内置 --all-orgs 跨组织业务聚合;聚合由 agent 编排多次 --profile 调用完成。", + "本期不自动发现用户所属的所有组织,只展示用户主动登录过的 profile。", + "本期不把权限授权链路、额外业务命令扩展作为多组织登录交付条件。" + ], + "technicalDecisions": [ + { + "topic": "command_naming", + "decision": "采用技术方案命名:auth 管凭证,profile 管组织身份选择;用 dws profile list/use 和全局 --profile,替代产品草案中的 auth list/auth switch/--组织corp ID。", + "reason": "与飞书 CLI 的 auth/profile 分层一致,避免把凭证动作和上下文选择混在同一命令组。" + }, + { + "topic": "storage", + "decision": "Token 写入 auth-token: keychain 槽;profiles.json 只保存非敏感 profile 元数据和 current/primary/previous 指针。", + "reason": "保持 secret/config 分离,profile list 不需要解密所有 token。" + }, + { + "topic": "compatibility", + "decision": "当前可用 profile 镜像写入 legacy auth-token,并保留 token.json marker。", + "reason": "确保旧 binary、旧宿主或只检查 marker 的逻辑不立即失效。" + }, + { + "topic": "runtime_resolution", + "decision": "profile 解析优先级为 --profile > currentProfile > primaryProfile > legacy auth-token。", + "reason": "满足一次性组织覆盖、持久切换和旧单槽回退。" + } + ], + "features": [ + { + "id": "F1", + "priority": "P0", + "name": "多组织登录写入 profile", + "description": "用户重复执行 dws auth login 时,按登录结果中的 corpId 新增或刷新组织 profile;新组织成为 currentProfile,首次组织成为 primaryProfile。", + "acceptance": [ + "首次 auth login 创建 profiles.json,primaryProfile=currentProfile=登录 corpId。", + "第二个组织 auth login 新增 profile,不覆盖第一个组织 token。", + "同组织重复 auth login 只刷新该 profile 的 token 和元数据,不新增重复 profile。", + "登录成功后 token 写入 auth-token:,并同步 legacy auth-token 镜像。" + ] + }, + { + "id": "F2", + "priority": "P0", + "name": "profile 元数据列表", + "description": "dws 顶层提供 dws profile list/ls,展示已登录组织 profile、current/primary 标记、状态、用户和有效期等非敏感信息。", + "acceptance": [ + "dws profile --help 在顶层 Utility Commands 中可见。", + "dws profile list --format json 返回 success、primaryProfile、currentProfile、previousProfile 和 profiles 数组。", + "profile list 的 JSON profiles 项必须包含 corpName;表格输出必须显式展示组织名,避免只有 corpId。", + "profile list 只读取 profiles.json 渲染列表,不要求解密所有 token。", + "profile 名可由 corpName 生成;重复名称回退为带 corpId 后缀的稳定名称。" + ] + }, + { + "id": "F3", + "priority": "P0", + "name": "切换当前组织", + "description": "dws 顶层提供 dws profile use ,用于持久切换默认组织上下文或切回上一个组织。", + "acceptance": [ + "dws profile use 将 currentProfile 更新为目标 corpId。", + "切换时 previousProfile 记录切换前的 currentProfile。", + "dws profile use - 使用 previousProfile 切回,并在 current/previous 间 toggle。", + "切换成功后重置进程内 token/runtime 缓存,后续无 --profile 命令默认使用新的 currentProfile。", + "切换后 legacy auth-token 镜像同步为当前 profile token。", + "切换成功的人类可读输出必须包含当前组织名和 corpId,便于终端 agent 确认已切到正确组织。", + "不存在或歧义 profile 返回 validation error,不静默回退。" + ] + }, + { + "id": "F4", + "priority": "P0", + "name": "单次命令临时指定组织", + "description": "全局 --profile 作为一次性 profile override,适用于任意 dws 取数命令,不持久修改 currentProfile。", + "acceptance": [ + "dws --help 展示全局 --profile 标志。", + "带 --profile 的命令只影响本次运行,命令结束后 currentProfile 不变化。", + "runtime token cache 按 profile selector 分桶,A/B profile 连续命令不串 token。", + "插件 UserContext 注入前已完成 profile resolution,UserID/CorpID 对应被选 profile。" + ] + }, + { + "id": "F5", + "priority": "P0", + "name": "按 profile 查看认证状态", + "description": "dws auth status 支持查看当前或指定 profile 的认证状态,必要时只刷新被选中的 token slot。", + "acceptance": [ + "dws auth status 默认查看 currentProfile。", + "dws auth status --profile 查看指定 profile,不改变 currentProfile。", + "auth status 的 JSON 与人类可读输出必须包含对应组织名和 corpId。", + "access token 过期但 refresh token 有效时,只刷新被选 profile 的 auth-token:。", + "refresh 失败时标记该 profile 为 expired,并保留其他 profile 可用。" + ] + }, + { + "id": "F6", + "priority": "P0", + "name": "退出与重置", + "description": "dws auth logout 支持按 profile 清理,dws auth logout --all 仅清非主 profile,dws auth reset 清理所有 profile 与 legacy 状态。", + "acceptance": [ + "dws auth logout --profile 删除目标 auth-token: 和 profile 元数据,不影响其他 profile。", + "dws auth logout --all 删除所有非 primaryProfile,保留主 profile。", + "primaryProfile 不应通过 logout 被误删;需要全量清理时使用 auth reset。", + "auth reset 删除 profiles.json、所有 auth-token:、legacy auth-token、token.json marker 和 app config。" + ] + }, + { + "id": "F7", + "priority": "P1", + "name": "兼容旧单槽迁移", + "description": "新 CLI 在 profiles.json 缺失但 legacy auth-token 存在时,自动把旧 token 迁移为 primary/current profile。", + "acceptance": [ + "旧 auth-token 中包含 corpId 时,首次 LoadTokenData 初始化 profiles.json。", + "迁移后 auth-token: 存在,primaryProfile=currentProfile=corpId。", + "profiles.json 对旧版 CLI 为增量文件,旧版忽略不破坏旧逻辑。" + ] + }, + { + "id": "F8", + "priority": "P1", + "name": "agent 编排跨组织聚合原语", + "description": "CLI 只提供 profile list 和单次 --profile 取数原语,跨组织聚合由 agent 调度多次命令完成。", + "acceptance": [ + "不提供 --all-orgs 内置聚合 flag。", + "agent 可通过 dws profile list 获取已登录且授权的 profile 列表。", + "agent 对每个 profile 带 --profile 调一次业务命令,结果自行合并并标注来源组织。", + "部分 profile 失败时,agent 能保留成功结果并标注失败组织。" + ] + } + ], + "implementationSlices": [ + { + "id": "S1", + "owner": "BE/auth", + "writeSet": [ + "internal/auth/profiles.go", + "internal/auth/token.go", + "internal/auth/keychain_store.go", + "internal/auth/oauth_provider.go" + ], + "doneWhen": "profile 元数据、多槽 token、legacy mirror、profile resolution、单 profile 刷新全部有单元测试。" + }, + { + "id": "S2", + "owner": "CLI/app", + "writeSet": [ + "internal/app/profile_command.go", + "internal/app/auth_command.go", + "internal/app/flags.go", + "internal/app/root.go", + "internal/app/runner.go" + ], + "doneWhen": "dws profile 顶层命令、全局 --profile、auth status/logout/reset 与 runtime cache 均通过 CLI 级测试。" + }, + { + "id": "S3", + "owner": "QA", + "writeSet": [ + "internal/auth/*_test.go", + "internal/app/*_test.go", + "test/cli/*_test.go" + ], + "doneWhen": "覆盖 F1-F7 验收用例,至少运行 go test ./internal/auth ./internal/app ./test/cli。" + } + ], + "reviewChecklist": [ + "切换组织能力必须在 dws 顶层出现,不接受只在内部函数可用。", + "profile list/use 与 auth status 的人类可读输出必须显式包含组织名,不接受只展示 profile name 或 corpId。", + "产品草案中的 auth switch/auth list/--associated/--组织corp ID 若与技术方案冲突,以技术方案命名为准,并在 PRD 中记录裁决。", + "profiles.json 不得包含 access_token、refresh_token、persistent_code 或 client_secret。", + "全局 --profile 不得持久改 currentProfile。", + "primaryProfile 不得被 logout 误删;全量删除只允许 auth reset。", + "real/embedded hook 签名不得为本期多组织改造破坏。" + ] +} From 29db17d79e7ee6979b150423e0c3e54032f28760 Mon Sep 17 00:00:00 2001 From: "shangguanxuan.sgx" Date: Fri, 26 Jun 2026 11:45:29 +0800 Subject: [PATCH 04/22] =?UTF-8?q?docs(auth):=20=E8=A1=A5=E5=85=85=E5=A4=9A?= =?UTF-8?q?=E7=BB=84=E7=BB=87=20Ralph=20=E9=AA=8C=E6=94=B6=E6=9D=90?= =?UTF-8?q?=E6=96=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../ralph/dws-multi-profile-login/analysis.md | 111 +++++++ .../comments/pr-comment.md | 29 ++ docs/ralph/dws-multi-profile-login/prd.json | 219 +++++++++++++ .../review/acceptance-review.md | 61 ++++ ...XW7oOA63YacZ4vrQ1RWzlwrZgb-product-plan.md | 292 ++++++++++++++++++ ...2dXW7oOA63YacZgBYPbmWzlwrZgb-supplement.md | 158 ++++++++++ ...V6O36dZbnsMLBrOLGJxEKBD6p-tech-solution.md | 147 +++++++++ .../source/source-manifest.json | 31 ++ .../technical-solution.md | 127 ++++++++ 9 files changed, 1175 insertions(+) create mode 100644 docs/ralph/dws-multi-profile-login/analysis.md create mode 100644 docs/ralph/dws-multi-profile-login/comments/pr-comment.md create mode 100644 docs/ralph/dws-multi-profile-login/prd.json create mode 100644 docs/ralph/dws-multi-profile-login/review/acceptance-review.md create mode 100644 docs/ralph/dws-multi-profile-login/source/MyQA2dXW7oOA63YacZ4vrQ1RWzlwrZgb-product-plan.md create mode 100644 docs/ralph/dws-multi-profile-login/source/MyQA2dXW7oOA63YacZgBYPbmWzlwrZgb-supplement.md create mode 100644 docs/ralph/dws-multi-profile-login/source/mweZ92PV6O36dZbnsMLBrOLGJxEKBD6p-tech-solution.md create mode 100644 docs/ralph/dws-multi-profile-login/source/source-manifest.json create mode 100644 docs/ralph/dws-multi-profile-login/technical-solution.md diff --git a/docs/ralph/dws-multi-profile-login/analysis.md b/docs/ralph/dws-multi-profile-login/analysis.md new file mode 100644 index 00000000..42958130 --- /dev/null +++ b/docs/ralph/dws-multi-profile-login/analysis.md @@ -0,0 +1,111 @@ +# dws 多组织登录综合分析 + +生成时间:2026-06-26 + +## 本地资料 + +- 源文档索引:`docs/ralph/dws-multi-profile-login/source/source-manifest.json` +- 技术方案原文导出:`docs/ralph/dws-multi-profile-login/source/mweZ92PV6O36dZbnsMLBrOLGJxEKBD6p-tech-solution.md` +- 方案补充原文导出:`docs/ralph/dws-multi-profile-login/source/MyQA2dXW7oOA63YacZgBYPbmWzlwrZgb-supplement.md` +- 产品方案原文导出:`docs/ralph/dws-multi-profile-login/source/MyQA2dXW7oOA63YacZ4vrQ1RWzlwrZgb-product-plan.md` +- PRD:`docs/ralph/dws-multi-profile-login/prd.json` +- 收敛技术方案:`docs/ralph/dws-multi-profile-login/technical-solution.md` +- 验收评审:`docs/ralph/dws-multi-profile-login/review/acceptance-review.md` +- PR 评论稿:`docs/ralph/dws-multi-profile-login/comments/pr-comment.md` + +导出说明:源文档通过 `dws doc info/read` 拉取,本地 Markdown 中的阿里文档 OSS 签名图片 URL 已替换为 `redacted://alidocs-signed-image`,避免把临时签名链接固化进仓库。 + +## 决策结论 + +本轮以技术方案为主线。产品稿中提到的 `auth list`、`auth switch`、`auth login --associated`、`--组织corp ID` 属于被技术方案替换的命名或交互,不作为当前实现验收口径。当前 P0 采用: + +- `dws auth login --force`:继续登录第二个、第三个组织。 +- `dws profile list`:查看已登录组织。 +- `dws profile use `:持久切换当前组织。 +- `dws --profile `:单次命令临时指定组织。 + +这个拆分符合“auth 管凭证、profile 管组织上下文”的边界,也让终端调度 dws 时能在顶层直接命中 profile 管理能力。 + +## 第二、第三个组织怎么处理 + +### 登录第二个组织 + +已有第一个组织后,继续执行: + +```bash +dws auth login --force --format json +``` + +浏览器授权页里选择第二个目标组织并授权。登录成功后,CLI 根据返回 token 中的 `corpId` 写入独立 keychain 槽 `auth-token:`,并在 `profiles.json` 中新增 profile。新登录的组织会成为 `currentProfile`,原来的 current 会进入 `previousProfile`。 + +如果是 SSH/headless 环境,使用设备码模式: + +```bash +dws auth login --device --force --format json +``` + +登录后检查: + +```bash +dws profile list --format json +``` + +当前本机验收时已经有两个 profile:主组织 `ding8196cd9a2b2405da24f2f5cc6abecb85`,以及当前组织 `ding32fff839a3e0105d`。这说明第二组织已经按新 profile 槽位进入本机 dws。 + +### 登录第三个组织 + +第三个组织不需要新命令,重复第二组织流程: + +```bash +dws auth login --force --format json +dws profile list --format json +``` + +选择第三个组织授权后,如果返回的是新的 `corpId`,它会新增为第三个 profile;如果返回的是已经存在的 `corpId`,只刷新该 profile 的 token 和组织/用户元数据,不产生重复项。 + +### 切换与临时使用 + +持久切换默认组织: + +```bash +dws profile use --format json +``` + +切回上一个组织: + +```bash +dws profile use - --format json +``` + +单次命令指定组织,不改变默认 current: + +```bash +dws --profile --format json +``` + +跨组织聚合由 agent 编排:先 `dws profile list --format json` 拿到 profile,再对每个 profile 带 `--profile ` 分别调用业务命令,最后由 agent 合并结果并标注来源组织。当前 CLI 不提供内置 `--all-orgs`。 + +## 实现对应关系 + +- 多槽 profile 元数据与 current/primary/previous 指针:`internal/auth/profiles.go` +- token 按组织独立存储,legacy `auth-token` 镜像兼容旧逻辑:`internal/auth/token.go` +- 顶层 `dws profile list/use`:`internal/app/profile_command.go` +- 全局 `--profile` 预解析与运行时注入:`internal/app/root.go` +- PRD 与验收口径:`prd.json` + +## 关键边界 + +- P0 不自动发现用户属于的所有组织,只列出用户主动登录过的 profile。 +- P0 不扩展 real/embedded hook 协议;hook 后端显式 profile 选择仍会返回“不支持”。 +- 主 profile 不通过普通 logout 误删;需要全量清理时走 `dws auth reset`。 +- profile 元数据不保存 access token、refresh token、persistent code 或 client secret。 + +## 验收状态 + +聚焦多组织能力的单元测试已通过: + +```bash +go test ./internal/auth ./internal/app -run 'Test(MultiProfile|RuntimeProfile|DeleteProfile|UpsertProfile|LoadProfiles|LegacyKeychain|WriteProfile|ProfileList|ProfileUse|AuthStatus|AuthLogout|AuthLogin|ResolveAuthLogin|EnrichAuthLogin|RootHelp|RootShortHelp|RootCommand)' +``` + +全量 `go test ./internal/auth ./internal/app` 中 `internal/auth` 通过,但 `internal/app` 被升级模块用例 `TestValidateNewBinary_RecoversFromUnsignedDarwin` 阻塞,错误是测试二进制执行时被 macOS kill。该失败发生在 upgrade 验签/回滚路径,不在多组织登录代码改动面内,需作为独立 CI/本机签名环境问题跟进。 diff --git a/docs/ralph/dws-multi-profile-login/comments/pr-comment.md b/docs/ralph/dws-multi-profile-login/comments/pr-comment.md new file mode 100644 index 00000000..0efa98bd --- /dev/null +++ b/docs/ralph/dws-multi-profile-login/comments/pr-comment.md @@ -0,0 +1,29 @@ +## Ralph 验收补充 + +本轮按技术方案优先收敛了多组织登录能力,并把在线资料、PRD、技术方案、验收评审都落到了 `docs/ralph/dws-multi-profile-login/`。 + +### 关键结论 + +- 第二/第三个组织不新增特殊命令,继续执行 `dws auth login --force --format json`,在 OAuth 页选择目标组织。 +- 新 `corpId` 会新增 profile;已存在 `corpId` 只刷新 token 和元数据。 +- 查看组织:`dws profile list --format json`。 +- 持久切换:`dws profile use --format json`。 +- 单次命令指定组织:`dws --profile --format json`。 +- 产品稿里的 `auth list/auth switch/--associated/--组织corp ID` 不进入 P0,分别由 `profile list/use`、重复 `auth login --force` 和全局 `--profile` 替代。 + +### 验证 + +```bash +go test ./internal/auth ./internal/app -run 'Test(MultiProfile|RuntimeProfile|DeleteProfile|UpsertProfile|LoadProfiles|LegacyKeychain|WriteProfile|ProfileList|ProfileUse|AuthStatus|AuthLogout|AuthLogin|ResolveAuthLogin|EnrichAuthLogin|RootHelp|RootShortHelp|RootCommand)' +``` + +结果:`internal/auth` 与多组织相关 `internal/app` 用例通过。 + +另已验证本地打包安装: + +- `dws version`:`v1.0.41-SNAPSHOT`,commit `756c7d1` +- `dws profile list --format json`:本机已有两个 profile,一个 primary,一个 current + +### 残余说明 + +全量 `go test ./internal/auth ./internal/app` 中 `internal/app` 被 upgrade 模块用例 `TestValidateNewBinary_RecoversFromUnsignedDarwin` 阻塞,错误是测试二进制执行被 macOS kill。该失败不在本次多组织登录改动面内,建议作为独立本机签名/隔离环境问题跟进。 diff --git a/docs/ralph/dws-multi-profile-login/prd.json b/docs/ralph/dws-multi-profile-login/prd.json new file mode 100644 index 00000000..1f86ce6a --- /dev/null +++ b/docs/ralph/dws-multi-profile-login/prd.json @@ -0,0 +1,219 @@ +{ + "version": 1, + "title": "dws multi-profile organization login", + "generatedAt": "2026-06-26", + "method": "ralph-core-architect", + "sourceDocuments": [ + { + "title": "dws 多组织 CLI 技术方案", + "nodeId": "mweZ92PV6O36dZbnsMLBrOLGJxEKBD6p", + "url": "https://alidocs.dingtalk.com/i/nodes/mweZ92PV6O36dZbnsMLBrOLGJxEKBD6p?utm_scene=person_space", + "role": "primary_technical_design", + "receipt": "dws doc read logId 2127d89817824391288753543e0756" + }, + { + "title": "方案补充", + "nodeId": "MyQA2dXW7oOA63YacZgBYPbmWzlwrZgb", + "url": "https://alidocs.dingtalk.com/i/nodes/MyQA2dXW7oOA63YacZgBYPbmWzlwrZgb?utm_scene=person_space", + "role": "technical_scope_supplement", + "receipt": "dws doc read logId 0bab027317824391287061282e092c" + }, + { + "title": "dws支持多组织", + "nodeId": "MyQA2dXW7oOA63YacZ4vrQ1RWzlwrZgb", + "url": "https://alidocs.dingtalk.com/i/nodes/MyQA2dXW7oOA63YacZ4vrQ1RWzlwrZgb?utm_scene=person_space", + "role": "product_logic_reference", + "receipt": "dws doc read logId 0b5deb3217824391290727610e08f9" + } + ], + "problem": { + "summary": "同一个自然人可能属于多个钉钉组织,现有 dws 单槽 token 会让 CLI 默认只能稳定代理一个组织,和用户对个人 Agent 跨组织整理个人数据的心智不一致。", + "whyNow": "当前 TokenData 已包含 CorpID/CorpName/UserID/UserName 等组织绑定字段,缺口集中在单槽 keychain 到多槽 profile 的演进,以及运行时 profile 选择指针。", + "evidence": [ + "TokenData 是组织绑定,一份 token 对应一个 corp profile。", + "旧 keychain account 固定为 auth-token,是单组织覆盖的主要卡点。", + "client-secret: 已有键控多槽先例,可复用同类设计。" + ] + }, + "goals": [ + "同一用户可在本机 dws 登录多个钉钉组织 profile。", + "每个组织 token 独立存储、刷新、删除,互不覆盖。", + "dws 顶层直接暴露 profile 管理能力,终端调度可通过 dws profile 命中。", + "命令执行前能解析当前 profile,并把 UserID/CorpID 注入运行时。", + "保留旧单槽 auth-token 镜像,兼容旧版二进制和宿主探测。" + ], + "nonGoals": [ + "本期不做 real/嵌入宿主多组织 hook 协议扩展。", + "本期不内置 --all-orgs 跨组织业务聚合;聚合由 agent 编排多次 --profile 调用完成。", + "本期不自动发现用户所属的所有组织,只展示用户主动登录过的 profile。", + "本期不把权限授权链路、额外业务命令扩展作为多组织登录交付条件。" + ], + "technicalDecisions": [ + { + "topic": "command_naming", + "decision": "采用技术方案命名:auth 管凭证,profile 管组织身份选择;用 dws profile list/use 和全局 --profile,替代产品草案中的 auth list/auth switch/--组织corp ID。", + "reason": "与飞书 CLI 的 auth/profile 分层一致,避免把凭证动作和上下文选择混在同一命令组。" + }, + { + "topic": "storage", + "decision": "Token 写入 auth-token: keychain 槽;profiles.json 只保存非敏感 profile 元数据和 current/primary/previous 指针。", + "reason": "保持 secret/config 分离,profile list 不需要解密所有 token。" + }, + { + "topic": "compatibility", + "decision": "当前可用 profile 镜像写入 legacy auth-token,并保留 token.json marker。", + "reason": "确保旧 binary、旧宿主或只检查 marker 的逻辑不立即失效。" + }, + { + "topic": "runtime_resolution", + "decision": "profile 解析优先级为 --profile > currentProfile > primaryProfile > legacy auth-token。", + "reason": "满足一次性组织覆盖、持久切换和旧单槽回退。" + } + ], + "features": [ + { + "id": "F1", + "priority": "P0", + "name": "多组织登录写入 profile", + "description": "用户重复执行 dws auth login 时,按登录结果中的 corpId 新增或刷新组织 profile;新组织成为 currentProfile,首次组织成为 primaryProfile。", + "acceptance": [ + "首次 auth login 创建 profiles.json,primaryProfile=currentProfile=登录 corpId。", + "第二个组织 auth login 新增 profile,不覆盖第一个组织 token。", + "同组织重复 auth login 只刷新该 profile 的 token 和元数据,不新增重复 profile。", + "登录成功后 token 写入 auth-token:,并同步 legacy auth-token 镜像。" + ] + }, + { + "id": "F2", + "priority": "P0", + "name": "profile 元数据列表", + "description": "dws 顶层提供 dws profile list/ls,展示已登录组织 profile、current/primary 标记、状态、用户和有效期等非敏感信息。", + "acceptance": [ + "dws profile --help 在顶层 Utility Commands 中可见。", + "dws profile list --format json 返回 success、primaryProfile、currentProfile、previousProfile 和 profiles 数组。", + "profile list 的 JSON profiles 项必须包含 corpName;表格输出必须显式展示组织名,避免只有 corpId。", + "profile list 只读取 profiles.json 渲染列表,不要求解密所有 token。", + "profile 名可由 corpName 生成;重复名称回退为带 corpId 后缀的稳定名称。" + ] + }, + { + "id": "F3", + "priority": "P0", + "name": "切换当前组织", + "description": "dws 顶层提供 dws profile use ,用于持久切换默认组织上下文或切回上一个组织。", + "acceptance": [ + "dws profile use 将 currentProfile 更新为目标 corpId。", + "切换时 previousProfile 记录切换前的 currentProfile。", + "dws profile use - 使用 previousProfile 切回,并在 current/previous 间 toggle。", + "切换成功后重置进程内 token/runtime 缓存,后续无 --profile 命令默认使用新的 currentProfile。", + "切换后 legacy auth-token 镜像同步为当前 profile token。", + "切换成功的人类可读输出必须包含当前组织名和 corpId,便于终端 agent 确认已切到正确组织。", + "不存在或歧义 profile 返回 validation error,不静默回退。" + ] + }, + { + "id": "F4", + "priority": "P0", + "name": "单次命令临时指定组织", + "description": "全局 --profile 作为一次性 profile override,适用于任意 dws 取数命令,不持久修改 currentProfile。", + "acceptance": [ + "dws --help 展示全局 --profile 标志。", + "带 --profile 的命令只影响本次运行,命令结束后 currentProfile 不变化。", + "runtime token cache 按 profile selector 分桶,A/B profile 连续命令不串 token。", + "插件 UserContext 注入前已完成 profile resolution,UserID/CorpID 对应被选 profile。" + ] + }, + { + "id": "F5", + "priority": "P0", + "name": "按 profile 查看认证状态", + "description": "dws auth status 支持查看当前或指定 profile 的认证状态,必要时只刷新被选中的 token slot。", + "acceptance": [ + "dws auth status 默认查看 currentProfile。", + "dws auth status --profile 查看指定 profile,不改变 currentProfile。", + "auth status 的 JSON 与人类可读输出必须包含对应组织名和 corpId。", + "access token 过期但 refresh token 有效时,只刷新被选 profile 的 auth-token:。", + "refresh 失败时标记该 profile 为 expired,并保留其他 profile 可用。" + ] + }, + { + "id": "F6", + "priority": "P0", + "name": "退出与重置", + "description": "dws auth logout 支持按 profile 清理,dws auth logout --all 仅清非主 profile,dws auth reset 清理所有 profile 与 legacy 状态。", + "acceptance": [ + "dws auth logout --profile 删除目标 auth-token: 和 profile 元数据,不影响其他 profile。", + "dws auth logout --all 删除所有非 primaryProfile,保留主 profile。", + "primaryProfile 不应通过 logout 被误删;需要全量清理时使用 auth reset。", + "auth reset 删除 profiles.json、所有 auth-token:、legacy auth-token、token.json marker 和 app config。" + ] + }, + { + "id": "F7", + "priority": "P1", + "name": "兼容旧单槽迁移", + "description": "新 CLI 在 profiles.json 缺失但 legacy auth-token 存在时,自动把旧 token 迁移为 primary/current profile。", + "acceptance": [ + "旧 auth-token 中包含 corpId 时,首次 LoadTokenData 初始化 profiles.json。", + "迁移后 auth-token: 存在,primaryProfile=currentProfile=corpId。", + "profiles.json 对旧版 CLI 为增量文件,旧版忽略不破坏旧逻辑。" + ] + }, + { + "id": "F8", + "priority": "P1", + "name": "agent 编排跨组织聚合原语", + "description": "CLI 只提供 profile list 和单次 --profile 取数原语,跨组织聚合由 agent 调度多次命令完成。", + "acceptance": [ + "不提供 --all-orgs 内置聚合 flag。", + "agent 可通过 dws profile list 获取已登录且授权的 profile 列表。", + "agent 对每个 profile 带 --profile 调一次业务命令,结果自行合并并标注来源组织。", + "部分 profile 失败时,agent 能保留成功结果并标注失败组织。" + ] + } + ], + "implementationSlices": [ + { + "id": "S1", + "owner": "BE/auth", + "writeSet": [ + "internal/auth/profiles.go", + "internal/auth/token.go", + "internal/auth/keychain_store.go", + "internal/auth/oauth_provider.go" + ], + "doneWhen": "profile 元数据、多槽 token、legacy mirror、profile resolution、单 profile 刷新全部有单元测试。" + }, + { + "id": "S2", + "owner": "CLI/app", + "writeSet": [ + "internal/app/profile_command.go", + "internal/app/auth_command.go", + "internal/app/flags.go", + "internal/app/root.go", + "internal/app/runner.go" + ], + "doneWhen": "dws profile 顶层命令、全局 --profile、auth status/logout/reset 与 runtime cache 均通过 CLI 级测试。" + }, + { + "id": "S3", + "owner": "QA", + "writeSet": [ + "internal/auth/*_test.go", + "internal/app/*_test.go", + "test/cli/*_test.go" + ], + "doneWhen": "覆盖 F1-F7 验收用例,至少运行 go test ./internal/auth ./internal/app ./test/cli。" + } + ], + "reviewChecklist": [ + "切换组织能力必须在 dws 顶层出现,不接受只在内部函数可用。", + "profile list/use 与 auth status 的人类可读输出必须显式包含组织名,不接受只展示 profile name 或 corpId。", + "产品草案中的 auth switch/auth list/--associated/--组织corp ID 若与技术方案冲突,以技术方案命名为准,并在 PRD 中记录裁决。", + "profiles.json 不得包含 access_token、refresh_token、persistent_code 或 client_secret。", + "全局 --profile 不得持久改 currentProfile。", + "primaryProfile 不得被 logout 误删;全量删除只允许 auth reset。", + "real/embedded hook 签名不得为本期多组织改造破坏。" + ] +} diff --git a/docs/ralph/dws-multi-profile-login/review/acceptance-review.md b/docs/ralph/dws-multi-profile-login/review/acceptance-review.md new file mode 100644 index 00000000..de9abb45 --- /dev/null +++ b/docs/ralph/dws-multi-profile-login/review/acceptance-review.md @@ -0,0 +1,61 @@ +# 多组织登录验收评审 + +生成时间:2026-06-26 + +## 评审结论 + +无阻塞问题。按技术方案口径,本轮多组织登录 P0 能力可以验收:多槽 token、profile 元数据、顶层 profile 命令、全局 `--profile`、legacy 兼容与主组织保护均已有代码和测试覆盖。 + +## 需求对齐 + +- PRD 已落地:`prd.json` 与 `docs/ralph/dws-multi-profile-login/prd.json` +- 顶层命令已落地:`dws profile list/use` +- 第二/第三组织登录路径已落地:重复 `dws auth login --force` +- 单次组织指定已落地:全局 `--profile` +- 组织名展示已落地:profile JSON 包含 `corpName`,表格包含 `ORG_NAME` +- 技术方案拒绝项已裁决:不实现 `auth list/auth switch/--associated/--组织corp ID` + +## 代码证据 + +- `internal/auth/profiles.go`:维护 `primaryProfile`、`currentProfile`、`previousProfile`,按 `corpId` upsert profile。 +- `internal/auth/token.go`:token 写入 `auth-token:`,并同步 legacy `auth-token`。 +- `internal/app/profile_command.go`:实现 `profile list`、`profile use `,输出组织名和 corpId。 +- `internal/app/root.go`:注册顶层 `profile` 命令,并在运行时预解析/注入全局 `--profile`。 +- `internal/app/auth_command.go`:auth status/logout/reset 对 profile 语义做了补齐。 + +## 测试证据 + +已通过: + +```bash +go test ./internal/auth ./internal/app -run 'Test(MultiProfile|RuntimeProfile|DeleteProfile|UpsertProfile|LoadProfiles|LegacyKeychain|WriteProfile|ProfileList|ProfileUse|AuthStatus|AuthLogout|AuthLogin|ResolveAuthLogin|EnrichAuthLogin|RootHelp|RootShortHelp|RootCommand)' +``` + +结果: + +- `ok github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/auth` +- `ok github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/app` + +本机 dws 验证: + +- `dws version`:`v1.0.41-SNAPSHOT`,commit `756c7d1` +- `dws profile list --format json`:当前本机存在两个 profile,一个 primary,一个 current,说明第二组织登录已进入多槽 profile 体系。 + +## 未阻塞但需记录的风险 + +- 全量 `go test ./internal/auth ./internal/app` 被 `internal/app` 中 upgrade 相关用例 `TestValidateNewBinary_RecoversFromUnsignedDarwin` 阻塞,错误为测试二进制执行被 macOS kill。该路径与多组织登录无直接耦合,应单独排查本机签名/隔离属性/测试环境。 +- real/embedded hook 后端仍不支持显式 `--profile`,这是技术方案明确的 P0 非目标。 +- P0 不自动发现所有所属组织;第三组织必须由用户再次完成 OAuth 授权后才会出现在 `profile list`。 +- 跨组织聚合由 agent 编排,不由 CLI 内置 `--all-orgs`。 + +## 验收判断 + +可以验收。对产品经理侧,当前可交付用户路径是: + +1. 首次 `dws auth login` 登录主组织。 +2. 继续 `dws auth login --force` 登录第二/第三组织。 +3. 用 `dws profile list` 看组织列表。 +4. 用 `dws profile use` 切默认组织。 +5. 用 `dws --profile ` 做单次跨组织调度。 + +这条路径覆盖了“多组织登录、切换、终端可调度、组织名可见”的核心需求。 diff --git a/docs/ralph/dws-multi-profile-login/source/MyQA2dXW7oOA63YacZ4vrQ1RWzlwrZgb-product-plan.md b/docs/ralph/dws-multi-profile-login/source/MyQA2dXW7oOA63YacZ4vrQ1RWzlwrZgb-product-plan.md new file mode 100644 index 00000000..84081919 --- /dev/null +++ b/docs/ralph/dws-multi-profile-login/source/MyQA2dXW7oOA63YacZ4vrQ1RWzlwrZgb-product-plan.md @@ -0,0 +1,292 @@ + + +## 一、背景与问题 + +在钉钉生态中,一个自然人往往同时属于多个组织。一个人可能同时是A公司的员工、B行业协会的成员、C项目组的外部顾问。在钉钉的自然使用中,用户已经能够在一个钉钉账号下看到不同组织的群消息、文档、日程、审批等数据,组织之间的数据边界对用户而言是透明的——用户切换组织身份就能访问对应组织的资源。 + +DWS定位为用户的个人Agent代理,其核心价值是帮助用户整理、聚合、分析其个人数据。当用户对DWS说"帮我整理这周所有的会议"时,用户的心智预期是获得横跨所有组织的完整会议列表,而不是仅限于某一个组织。当前DWS仅能获取单一组织内的数据,与用户在钉钉中的自然使用体验形成了割裂。DWS如何在保证安全可控的前提下,像用户在钉钉中自然使用一样,代理用户获取其在多个组织中的个人数据? + +## 二、设计原则与核心概念 + +本方案的核心设计可以概括为:**以登录为锚点,用户主动授权,DWS守住安全底线。** + +用户通过登录建立对某个组织的身份凭证,DWS以用户在该组织中的真实身份代理获取数据。多组织能力的开启完全由用户自主决定——用户登录哪些组织、授权哪些业务域,DWS就能访问哪些数据。整个过程不依赖组织管理员的额外配置,用户即可完成跨组织数据获取的全流程。 + +### 2.1 权限跟着用户走 + +在多组织模型下,DWS获取某个组织的数据时,使用的是用户在该组织中的身份凭证,获取的数据范围受该组织对该用户的权限配置约束。DWS不会放大也不会缩小用户的原有权限。用户在钉钉中能看到A公司的群消息、B协会的日程、C项目组的文档,DWS就可以在用户登录并授权后代理获取这些数据;用户在某个组织中看不到的数据,DWS同样无法获取。 + + + +### 2.2 核心概念 + +**当前组织**:用户首次登录/持续登录DWS时所在的组织,是DWS默认的数据获取上下文。 + +**已登录组织**:用户通过登录指令主动登录的其他组织。每个已登录组织都持有独立的身份凭证,DWS可以代理用户访问对应组织的数据。 + +**用户授权**:用户在登录某个组织后,决定DWS可以代理访问哪些业务域数据的授权行为。 + + + +## 三、多组织登录管理 + +多组织能力的核心入口是登录指令。用户通过指令主动登录其他组织,登录后完成DWS授权,即可开始跨组织数据获取。 + +### 3.1 主组织登录与授权认证 + +用户通过钉钉统一登录完成身份认证后,DWS获得用户的unionId和当前主组织的访问凭证。首次登录时展示一次性的基础授权确认页面,用户确认后DWS即可开始在主组织范围内代理用户获取数据。**此阶段不强制要求用户配置其他组织的授权。** + +| **用户身份登录** | **主组织dws授权** | +|----------------------|----------------------| +| 通过钉钉统一登录身份认证
![](redacted://alidocs-signed-image "") | 登录后第一次唤起授权
![](redacted://alidocs-signed-image "") | + +### 3.2 登录关联组织 + +用户在DWS对话中使用以下指令登录其他组织: + +**指令格式:** +``` +登录关联组织 +dws auth login --associated +``` + +**交互流程:** +1. 用户确认目标组织后,发起钉钉OAuth登录流程 +2. 登录成功后,**立即唤起该组织的DWS使用授权**,用户按业务域勾选授权范围 +3. 授权完成后,该组织进入"已登录"状态,DWS可代理用户获取对应数据 + +**登录后的DWS授权说明:** + +登录成功后,系统自动唤起授权页面,用户可选择授权DWS访问的业务域范围: + +| **用户身份登录** | **组织dws授权** | +|----------------------|-------------------| +| 通过钉钉统一登录身份认证
![](redacted://alidocs-signed-image "") | 登录后第一次唤起授权
![](redacted://alidocs-signed-image "") | + +已登录组织展示-P1 + +### 4.2 查询已登录组织 + +用户可随时通过指令查询当前已登录的组织情况: + +**指令格式:** +``` +查看已登录组织 +dws auth list +``` + +**TUI返回示例:** + +标明示意出用户当前登录的主组织 +``` +当前已登录 2 个组织: + + A科技有限公司(主组织) + 状态:已授权 | 业务域:日程、消息、文档、待办、通讯录 + + B科技有限公司 + 状态:已授权 | 业务域:日程、文档 + +``` + +**能力说明:** +- 一个Agent中可同时登录多个组织,各组织独立持有身份凭证 +- 每个已登录组织的授权状态、业务域范围、凭证有效期均独立管理 +- 主组织始终保持登录状态,不可退出 + +### 4.3 退出登录 + +用户可通过指令退出某个组织的登录,退出后DWS将停止代理获取该组织的数据: + +**指令格式:** +``` +退出登录 +dws auth logout--associated +dws auth logout --all # 退出所有非主组织的登录 +``` + +**交互示例:** +``` +用户:退出B行业协会的登录 +DWS:确认退出【B行业协会】的登录?退出后DWS将无法获取该组织的数据, + 已授权的凭证将被清除。 + [取消] [确认退出] + +用户:确认退出 +DWS:已退出【B行业协会】的登录,相关授权凭证已清除。 + 如需重新接入,可使用"登录B行业协会"指令重新登录。 +``` + +**说明:** +- 退出登录会清除该组织的access\_token和refresh\_token +- 退出操作不可撤回,如需恢复需重新执行登录流程 +- 主组织不支持退出登录 + +### 4.4 切换当前组织 + +用户可切换DWS当前默认操作的组织上下文: + +**指令格式:** +``` +切换到 [组织名称] +dws auth switch +``` + +切换后,用户的无组织前缀指令(如"查一下今天的日程")默认从当前上下文组织获取数据。 +``` + +❯ dws auth switch + +? 选择要切换的组织: (↑↓ 方向键选择, Enter 确认) + + ORGANIZATION ROLE STATUS ENDPOINT + ─────────────────────────────────────────────────────────────── + ● 蚂蚁集团 (default) Admin ✔ 已登录 api.dws.com ← 当前 + ○ ACME科技 (acme-prod) Admin ✔ 已登录 acme-prod.dws.com +❯ ○ 星辰互联 (star-inc) Developer ✔ 已登录 star-inc.dws.com ← 光标 + ○ 测试环境 (acme-dev) Dev ⚠ 已过期 acme-dev.dws.com +``` + + + +## 五、用户授权:个人跨组织数据代理 + +### 5.1 使用中数据展示规则 + +跨组织数据在DWS中聚合展示时,每条数据标注来源组织。用户可按组织筛选或查看聚合视图。来自不同组织的数据按业务域内的自然排序方式混合排列。 +``` +┌─────────────────────────────────────────────────────┐ +│ 📅 我的日程 — 本周 │ +│ │ +│ 组织筛选: [全部 ▾] [A科技] [B协会] │ +│ │ +│ ───── 周一 6月23日 ───────────────────────────── │ +│ │ +│ ● 10:00-11:00 产品需求评审 │ +│ A科技有限公司 · 会议室3F-01 · 8人 │ +│ │ +│ ● 14:00-15:00 行业标准讨论会 │ +│ B行业协会 · 线上会议 · 25人 │ +│ │ +│ ───── 周二 6月24日 ───────────────────────────── │ +│ │ +│ ● 09:00-09:30 站会 │ +│ A科技有限公司 · 线上会议 · 5人 │ +│ │ +│ ───────────────────────────────────────────────── │ +│ │ +│ 💡 你还属于【C建设项目组】,登录后可查看该组织日程 │ +│ [登录并授权 →] │ +│ │ +└─────────────────────────────────────────────────────┘ + +``` + +### 5.2 增量授权展示 + +在用户日常使用DWS的过程中,当DWS识别到用户的请求可能涉及其他组织数据需要授权时,展示当前组织信息 + +**模型调度** + +| **场景一:用户主动请求跨组织数据。** | **场景二:DWS在执行任务过程中发现跨组织数据需求。** | +|----------------------------------------------------|-------------------------------------------------------------------------| +| 👤 用户:帮我汇总这周所有的会议安排
┌─────────────────────────────────────────────────────┐
│ DWS 对话界面 │
├─────────────────────────────────────────────────────┤
│ │
│ 👤 用户:帮我汇总这周所有的会议安排 │
│ │
│ 🤖 DWS: │
│ 以下是你本周的会议安排: │
│ │
│ 📅 来自【A科技有限公司】(已授权) │
│ ┌────────────────────────────────────────┐ │
│ │ 周一 10:00 产品需求评审 会议室3F-01 │ │
│ │ 周二 14:00 技术方案讨论 线上会议 │ │
│ │ 周四 09:30 周报汇报 会议室5F-03 │ │
│ └────────────────────────────────────────┘ │
│ │
│ ┌────────────────────────────────────────────┐ │
│ │ 💡 你还属于以下组织,登录后可查看完整日程: │ │
│ │ │ │
│ │ 🏛️ B行业协会 &#91;登录并授权 →] │ │
│ │ 🏗️ C建设项目组 &#91;登录并授权 →] │ │
│ │ │ │
│ └────────────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────┘
| 执行任务中发现跨组织数据需求
┌─────────────────────────────────────────────────────┐
│ 🤖 DWS: │
│ 已为你整理明天会议的相关材料: │
│ │
│ 📄 来自【A科技集团】 │
│ · 《Q3产品规划v2.1》 │
│ · 《技术架构升级方案》 │
│ │
│ ┌────────────────────────────────────────────┐ │
│ │ 💡 你在【B子公司】中可能有相关文档, │ │
│ │ 登录后即可授权DWS获取。 │ │
│ │ &#91;登录并授权 →] │ │
│ └────────────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────┘
| +| **场景三:用户在DWS中使用指令直接指定组织。** | **场景四:用户请求未登录组织的数据。** | +| 👤 用户:帮我看看B子公司下周有什么日程安排
┌─────────────────────────────────────────────────────┐
│ 👤 用户:帮我看看B子公司下周有什么日程安排 │
│ │
│ 🤖 DWS: │
│ 你尚未登录【B子公司】,需要登录并授权后才能帮你查看。 │
│ │
│ ┌────────────────────────────────────────────┐ │
│ │ 登录【B子公司】并授权DWS获取: │ │
│ │ │ │
│ │ ☑ 📅 日程/日历(当前所需) │ │
│ │ │ │
│ │ 可同时授权(可选): │ │
│ │ ☐ 📄 文档/知识库 │ │
│ │ ☐ ✅ 待办/任务 │ │
│ │ ☐ 👤 通讯录(个人信息) │ │
│ │ │ │
│ │ DWS获取的数据不会超出你在该组织中的 │ │
│ │ 可见范围。你可随时在设置中撤销授权。 │ │
│ │ │ │
│ │ &#91;取消] &#91;登录并授权] │ │
│ └────────────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────┘
| 👤 用户:帮我看看D行业协会下周有什么活动
┌─────────────────────────────────────────────────────┐
│ 👤 用户:帮我看看D行业协会下周有什么活动 │
│ │
│ 🤖 DWS: │
│ 你尚未登录【D行业协会】。 │
│ 需要登录并授权后,DWS才能帮你获取该组织的数据。 │
│ │
│ ┌────────────────────────────────────────────┐ │
│ │ &#91;取消] &#91;登录D行业协会 →] │ │
│ └────────────────────────────────────────────┘ │
│ │
│ 在登录之前,你也可以直接在钉钉中切换到 │
│ D行业协会查看相关数据。 │
│ │
└─────────────────────────────────────────────────────┘
| + +**用户指定部分** + +--组织corp ID + + + +| **个人增量授权** | | +|----------------------|---| +| 授权页面包含组织信息
![个人授权@2x.png](redacted://alidocs-signed-image "") | | + + + +### 5.2 用户权限管理页面 + +用户个人组织授权管理,在原来个人授权管理页面下管理。 + + + +### 5.3 运行时数据获取流程 +``` +用户发起请求(如"汇总本周日程") + │ + ▼ +DWS解析用户意图 + │ 识别出:需要日程数据,范围=所有组织 + │ + ▼ +查询用户已登录组织列表 + 授权记录 + │ unionId → 所有 UserOrgAuthorization + │ 过滤:status=active 且 domain包含calendar + │ + ▼ +┌────────────────────────────────────────┐ +│ 对每个已授权组织: │ +│ │ +│ 1. 检查access_token是否有效 │ +│ ├─ 有效 → 直接调用 │ +│ └─ 过期 → 用refresh_token刷新 │ +│ ├─ 刷新成功 → 调用 │ +│ └─ 刷新失败 → 提示用户重新登录 │ +│ │ +│ 2. 以用户在该组织的身份调用日程API │ +│ 请求参数受用户权限范围约束 │ +│ │ +│ 3. 获取结果,标记数据来源组织 │ +└────────────────────────────────────────┘ + │ + ▼ +聚合所有组织的数据,按时间排序 + │ 附带:未登录组织的引导提示(登录即可接入) + ▼ +返回给用户 + +``` + + + + + +## 六、边界情况与异常处理 + +### 7.1 登录凭证过期需重新登录 + +当某个已登录组织过期时,DWS将该组织的状态标记为"登录已过期"。下次用户请求该组织数据时,DWS提示"你在【X组织】的登录已过期,需要重新登录",并提供快捷重新登录入口。 + +### 7.2 用户被移出某组织 + +用户从某组织中被移除后,其在该组织中的成员身份失效,access\_token随之失效。DWS将该组织的授权状态标记为"已失效",在权限管理页面中显示"你已不再是该组织成员"。已缓存的该组织数据按策略清除。该组织从用户的"其他组织"列表中移除。 + +### 7.3 数据获取部分失败 + +DWS并行向多个组织获取数据时,部分组织可能暂时不可用。DWS降级,正常返回已成功获取的数据,对失败的组织标注"数据暂时无法获取,请稍后重试"。 + +### 7.4 用户同时属于多个组织 + +用户可以在一个Agent中同时登录多个组织(如A、B、C三个组织),各组织的身份凭证和授权配置相互独立。DWS在获取数据时并行请求所有已授权组织,聚合结果后统一返回。 + +### 7.5 用户撤销某组织的授权 + +用户可在权限管理页面随时撤销对某个组织特定业务域的授权,或完全退出该组织的登录。撤销后DWS立即停止代理获取对应数据,凭证按业务域粒度清除(撤销部分业务域时保留其他业务域的凭证)。 + + + +## 附:与飞书CLI设计的对照 + +飞书CLI(github.com/larksuite/cli)在授权设计上提供了直接的参考范式。飞书CLI采用三层授权体系:应用凭证(app\_id/app\_secret)构成基础锚点,用户OAuth登录获取用户级token,细粒度scope控制按业务域开放权限(如 `--domain calendar,task` 或 `--scope "calendar:calendar:read"`)。 + +DWS的多组织登录设计与此高度对齐。DWS中的"业务域开关"等价于飞书CLI中的scope/domain概念。飞书CLI的 `--recommend` 参数提供推荐权限集的理念,对应到DWS中就是首次登录时默认授权主组织的常用业务域。飞书CLI的 `--as user` 身份切换机制也与DWS的设计理念一致:DWS始终以用户在目标组织中的真实身份获取数据。 + +DWS的 `dws auth login / list / logout` 指令体系,在飞书CLI的用户OAuth登录机制基础上,增加了多组织并发登录、按需触发登录、组织上下文切换等面向终端用户的交互能力,使多组织数据获取对用户而言像"切换账号"一样自然。 diff --git a/docs/ralph/dws-multi-profile-login/source/MyQA2dXW7oOA63YacZgBYPbmWzlwrZgb-supplement.md b/docs/ralph/dws-multi-profile-login/source/MyQA2dXW7oOA63YacZgBYPbmWzlwrZgb-supplement.md new file mode 100644 index 00000000..c129ddae --- /dev/null +++ b/docs/ralph/dws-multi-profile-login/source/MyQA2dXW7oOA63YacZgBYPbmWzlwrZgb-supplement.md @@ -0,0 +1,158 @@ + + +本文只补《dws 多组织 CLI 技术方案》中“多组织登录”落地时需要防漏的部分,不展开权限授权、额外命令扩展或跨组织业务能力。 + +本页目标很窄:让同一个自然人可以在本机 DWS 登录多个钉钉组织,并能稳定选择当前组织运行。 + +### 1\. 本页边界 + +只做: + +| 范围 | 说明 | +|------|------| +| 多组织登录 | 同一用户可重复 `dws auth login`,每次登录一个组织 profile。 | +| 多槽 token 存储 | 每个组织一份独立 token slot,避免互相覆盖。 | +| 当前组织上下文 | 用 `currentProfile/primaryProfile/previousProfile` 管理当前组织。 | +| profile 切换 | 支持 `profile list`、`profile use <name>`、`profile use -`。 | +| 单次命令临时指定组织 | 支持全局 `--profile <name>`,只影响本次命令。 | +| 旧版本兼容 | 保留 legacy `auth-token` 镜像,避免旧 binary 或宿主直接失效。 | + +不做: + +| 不做项 | 原因 | +|---------|------| +| 权限授权链路 | 与“登录多个组织”不是同一层问题,本页不展开。 | +| 额外命令扩展 | 不是完成多组织登录的必要条件。 | +| 跨组织业务处理 | 多组织登录只提供 profile 原语;业务层能力另行设计。 | +| 自动发现用户所有所属组织 | 只展示用户已经主动登录过的组织 profile。 | + +### 2\. 最小登录链路 + +多组织登录链路保持简单: +1. 用户执行 `dws auth login`。 +2. CLI 走现有 OAuth loopback 或 `--device` 设备流。 +3. 登录成功后拿到 `TokenData`,其中包含 `CorpID / CorpName / UserID / UserName / ClientID / RefreshToken / ExpiresAt / RefreshExpAt`。 +4. CLI 用 `CorpID` 判断这是新组织还是已有组织。 +5. 新组织:写入新的 token slot,并新增 profile 元数据。 +6. 已有组织:刷新该 profile 的 token 和元数据,不新增重复 profile。 +7. 首次登录的组织成为 `primaryProfile`。 +8. 最近一次登录成功的组织成为 `currentProfile`。 + +### 3\. 本地数据模型 + +目标数据仍分两层:token 进安全存储,profile 元数据进明文配置。 + +| 数据 | 目标位置 | 内容 | +|------|------------|------| +| 用户 token | Keychain `auth-token:<corpId>` | 当前组织的完整 `TokenData`。 | +| profile 元数据 | `profiles.json` | profile 名、corpId、corpName、userId、userName、状态和时间戳。 | +| 当前组织 | `profiles.json.currentProfile` | 当前默认使用的 profile。 | +| 主组织 | `profiles.json.primaryProfile` | 首次登录的 profile,用于兜底和保护。 | +| 上一个组织 | `profiles.json.previousProfile` | 支持 `profile use -`。 | +| legacy 兼容槽 | Keychain `auth-token` | 镜像当前可用 profile,给旧逻辑兜底。 | +| marker | `token.json` | 只标记有登录态,不存 token。 | + +建议 `profiles.json` 只保存非敏感字段: +```json +{ + "version": 1, + "primaryProfile": "ding-a", + "currentProfile": "ding-b", + "previousProfile": "ding-a", + "profiles": [ + { + "name": "org-a", + "corpId": "ding-a", + "corpName": "A 组织", + "userId": "user-a", + "userName": "张三", + "status": "active", + "lastLoginAt": "2026-06-25T12:00:00+08:00", + "lastUsedAt": "2026-06-25T12:00:00+08:00" + } + ] +} +``` + +### 4\. 当前 profile 解析规则 + +每次命令运行前只做一件事:解析本次应该使用哪个 profile。 + +优先级: +```text +--profile flag > currentProfile > primaryProfile > legacy auth-token +``` + +规则: +- `--profile ` 只影响本次命令,不写 `currentProfile`。 +- `profile use ` 才持久修改 `currentProfile`。 +- `profile use -` 使用 `previousProfile` 做切回。 +- `auth login` 登录新组织后,可以把新组织设为 `currentProfile`。 +- 如果 `currentProfile` 不可用,回退到 `primaryProfile`。 +- 如果 `profiles.json` 不存在但 legacy `auth-token` 存在,执行一次迁移初始化。 + +### 5\. 命令行为补充 + +主技术方案已经定义了命令名,本页只补行为边界。 + +| 命令 | 行为边界 | +|------|------------| +| `dws auth login` | 登录一个组织;新组织新增 profile,老组织刷新 profile。 | +| `dws auth status` | 查看当前 profile 状态。 | +| `dws auth status --profile <name>` | 查看指定 profile,不改变当前 profile。 | +| `dws auth logout` | 默认退出当前 profile。 | +| `dws auth logout --profile <name>` | 只退出指定 profile,不影响其他组织。 | +| `dws profile list` | 只读 `profiles.json` 渲染列表,不解密所有 token。 | +| `dws profile use <name>` | 切换当前 profile,更新 `previousProfile`。 | +| `dws profile use -` | 切回上一个 profile。 | +| 全局 `--profile <name>` | 单次覆盖 profile,不持久化。 | + +### 6\. 实现防漏点 + +这些是完成多组织登录主链路时必须检查的点。 + +| 点位 | 要求 | +|------|------| +| token 保存 | 不能再覆盖固定 `auth-token`;必须写到 `auth-token:<corpId>`。 | +| token 读取 | 先解析 profile,再按 corpId 读取对应 slot。 | +| token 刷新 | 只刷新当前 profile 的 token slot。 | +| 并发刷新 | 同一个 corpId 刷新需要加锁,避免 refresh token 覆盖。 | +| runtime token cache | 不能继续是全局单值;必须按 profile/corpId 隔离,或命令级绑定。 | +| plugin UserContext | 注入 `UserID/CorpID` 前必须先完成 profile resolution。 | +| legacy mirror | 每次 current profile 变化或登录成功后,更新 legacy `auth-token` 镜像。 | +| `token.json` | 继续只做 marker,不写入组织列表或 token。 | +| `auth reset` | 清理所有 profile 元数据、所有 `auth-token:<corpId>` slot、legacy slot 和 marker。 | + +### 7\. 最小验收用例 + +只验收多组织登录本身。 + +| 用例 | 期望结果 | +|------|------------| +| 首次 `auth login` | 创建 `primaryProfile=currentProfile`,写入一个 `auth-token:<corpId>`。 | +| 第二个组织 `auth login` | 新增第二个 profile,不覆盖第一个组织 token。 | +| 同组织重复 `auth login` | 刷新该组织 token,不新增重复 profile。 | +| `profile list` | 能看到所有已登录组织,且 current/primary 标记正确。 | +| `profile use B` | 当前组织切到 B,previous 记录切换前的 A。 | +| `profile use -` | 当前组织切回 A。 | +| `--profile B` 执行业务命令 | 使用 B 的 token,但 currentProfile 仍保持原值。 | +| A/B 两个 profile 连续执行命令 | token、`UserID/CorpID`、运行结果不串组织。 | +| access token 过期 | 只刷新当前 profile 的 token slot。 | +| refresh token 过期 | 当前 profile 标记为 expired,提示重新登录该组织。 | +| `auth logout --profile B` | 删除 B 的 token slot 和 profile 元数据,不影响 A。 | +| legacy 单槽迁移 | 旧 `auth-token` 被初始化为一个 primary/current profile。 | + +### 8\. 结论 + +本补充页只要求把多组织登录闭环做完整: +```text +多次登录组织 -> 多槽保存 token -> profiles.json 管当前组织 -> 运行时按 profile 取 token -> profile 切换和临时覆盖不串组织 +``` + +权限授权、额外命令扩展、跨组织业务处理都不放进本补充页。 diff --git a/docs/ralph/dws-multi-profile-login/source/mweZ92PV6O36dZbnsMLBrOLGJxEKBD6p-tech-solution.md b/docs/ralph/dws-multi-profile-login/source/mweZ92PV6O36dZbnsMLBrOLGJxEKBD6p-tech-solution.md new file mode 100644 index 00000000..9a349925 --- /dev/null +++ b/docs/ralph/dws-multi-profile-login/source/mweZ92PV6O36dZbnsMLBrOLGJxEKBD6p-tech-solution.md @@ -0,0 +1,147 @@ + + +## 一、设计前提:现状已具备的基础 + +dws 现有凭证体系已为多组织准备好大半,缺口集中在「单槽 → 多槽 \+ 当前上下文指针」。 +- token 本身已是组织绑定。`TokenData` 已含 `CorpID / CorpName / UserID / UserName / ClientID / RefreshToken / ExpiresAt / RefreshExpAt`(internal/auth/token.go)。一份 token = 一个组织 = 一个 profile。 +- 当前为单槽 keychain。service 为 `dws-cli`、account 固定 `auth-token`(internal/keychain/keychain.go)。一次只存一份,这是「只支持单组织」的真正卡点。 +- 已有键控多槽先例。client secret 用 `client-secret:` 多槽存(internal/auth/keychain\_store.go)。多 profile 直接复刻同一模式,不引入新机制。 +- 存储已分层。keychain 存密文(macOS 系统钥匙串存 DEK 加 AES-256-GCM 密文;Linux 文件 DEK;Windows DPAPI);`token.json` 仅为宿主探测用的标记文件;`identity.json` 只存 agentId,不含组织信息。 +- 运行时注入点单一。启动时 `LoadTokenData` 一次,取 `UserID/CorpID` 注入(internal/app/root.go),`$corpId/$unionId` 运行时默认值从 token 解析。这是切换 profile 唯一要改的地方。 +- real/dev 已分流。real 模式由宿主 hook 接管 Save/Load/DeleteToken 并隐藏 login;dev 模式走 keychain 加 OAuth。 + +## 二、与飞书 CLI 的对照(命名取向依据) + +完全沿用飞书的命名与拆分:`auth` 管凭证动作(token),`profile` 管选哪个身份。在 dws 语境里,一个 profile 就是一个已登录组织(corp)。逐项对照: +- 概念:飞书一个 profile = 一个 app/租户身份;dws 一个 profile = 一个已登录组织(corp)。 +- 多身份存储:飞书用 MultiAppConfig 的 Apps 数组(明文)加 keychain 密钥分离;dws 用 profiles.json 明文元数据加 keychain 多槽密文,同样分离。 +- 再次登录:飞书再跑一次 `auth login` 即新增身份;dws 再跑一次 `auth login` 即新增 profile,无需专门 flag。 +- 列表:飞书 `profile list`;dws `profile list`。 +- 持久切换:飞书 `profile use `(改 CurrentApp,原值存 PreviousApp);dws `profile use `(改 currentProfile,原值存 previousProfile)。 +- 切回上一个:飞书 `profile use -`;dws `profile use -`。 +- 一次性切换:飞书全局 `--profile `;dws 全局 `--profile `,一次性、不持久化。 +- 状态:飞书 `auth status`;dws `auth status`。 +- 业务域授权:飞书 `--domain calendar,task` 加 `--recommend`;dws `--domain calendar,im,doc` 加 `--recommend`。 +- 登录方式:飞书 device flow 加 `--no-wait`;dws 沿用现有 loopback 加 `--device`。 + +profile 名默认取 corpName(可重复时回退带 corpId 后缀);底层稳定键始终是 corpId,profile 名只作展示与选择用,等同飞书「profile 名作选择、appId 作稳定键」。 + +## 三、指令命名与参数(最终) + +与飞书一致:`auth` 组加 `profile` 组加全局 `--profile`。 + +### 3.1 dws auth(凭证动作) +- `dws auth login [--device] [--force] [--domain ] [--recommend]` + - 登录一个组织(profile)。首次登录 = 主 profile(primary);之后对新组织 login 即新增一个 profile 并设为当前;对同组织重复 login = 刷新。 + - 组织身份由 OAuth 授权账号决定,CLI 自动从返回取 corpId/corpName,无需手填。 + - `--domain` 指定本次授权的业务域(日程/消息/文档/待办/通讯录等),`--recommend` 仅请求默认推荐域。对齐飞书。 +- `dws auth logout [--profile ] [--all]` + - 退出登录。默认退当前 profile(二次确认);`--profile` 指定某个;`--all` 退出所有非主 profile。主 profile 不可退出。 + - 退出 = 删该 corp 的 keychain 槽,加远端 revoke,加从 profiles.json 移除。 +- `dws auth status [--profile ]` + - 查看当前(或指定)profile 的认证状态,含 refresh token 有效性、自动刷新。 +- 兼容保留:`auth export / import / reset` 维持现状,按当前 profile 操作。 + +### 3.2 dws profile(身份/组织管理,命名同飞书) +- `dws profile list`(别名 `ls`) + - 列出已登录 profile:主 profile 标记、当前标记、授权业务域、状态(已授权/已过期/已失效)、有效期。 + - 仅展示已登录 profile,不拉取「用户全部组织列表」。靠 profiles.json 渲染,不解密 keychain。 +- `dws profile use ` + - 切换当前 profile 并持久化:写 currentProfile,原值存 previousProfile。 + - 参数可为 profile 名或 corpId;无参时给 TUI 列表选择。 +- `dws profile use -` + - 切回上一个 profile(用 previousProfile 做 toggle),对标飞书 `profile use -`。 + +### 3.3 全局 flag:跨组织取数 +- 全局 `--profile ` + - 任意取数指令加此 flag,单次从指定 profile 取数,一次性、不改 currentProfile。等价 PRD 的「--组织corp ID」,命名对标飞书 `--profile`。 + - 值可为 profile 名或 corpId。 +- 不提供 `--all-orgs` 内置聚合。跨组织聚合由 agent 编排(见第五节)。 + +### 3.4 与 PRD 草案的差异 +- 组织管理用独立 `profile` 组:与飞书 `auth` 加 `profile` 的拆分与命名完全一致。 +- 去掉 `login --associated`:飞书风格下 login 本身就是新增,无需该 flag。 +- `logout --associated` 改为 `logout --profile / --all`。 +- 切换命令用 `profile use`(含 `-` 切回),跨组织一次性取数用全局 `--profile`,替代草案的 `--corp`。 +- 去掉未登录组织引导:本期只展示已登录 profile。 + +## 四、凭证管理技术方案(核心) + +### 4.1 存储模型:单槽 → 键控多槽(向前兼容) +- keychain 改键控:token account 从固定 `auth-token` 扩为 `auth-token:`,复刻现有 `client-secret:` 模式。每个 profile 一份独立密文,加密方式(DEK 加 AES-256-GCM)完全不变。corpId 作稳定键(profile 可改名,键不变)。 +- 新增明文注册表 profiles.json(放 configDir,不含任何 token,只存元数据加指针),对齐飞书 MultiAppConfig: +``` +{ + "primaryProfile": "corpA", // 主 profile 的 corpId,不可退出 + "currentProfile": "corpB", // 当前 = 上一次 profile use 选中的 + "previousProfile": "corpA", // 上一个,支持 profile use - 切回 + "profiles": [ + { + "corpId": "corpA", "name": "A科技", + "userId": "...", "userName": "...", + "status": "active", + "authorizedDomains": ["calendar", "im", "doc"], + "refreshExpAt": "...", "updatedAt": "..." + } + ] +} +``` +- config 与 secret 分离:`profile list` 读 profiles.json 即可渲染,不碰 keychain;token 全程只在 keychain。 + +### 4.2 向前/向后兼容(硬约束) +- 向后兼容:新 CLI 读旧的单槽 `auth-token` 仍可用(见 4.3 优先级末位回退)。首次多 profile 使用时,把旧单槽读出,用其 CorpID 落成 `auth-token:` 并标 primary,复用现有 legacy 迁移钩子(keychain\_store.go 的 EnsureMigration),用户无感。 +- 向前兼容:新 CLI 始终把「当前 profile(无则主)」的 TokenData 镜像写一份到旧单槽 `auth-token`,并照常写 token.json 标记。这样旧版二进制、real 宿主即使不认识 profiles.json,也能在主 profile 上照常工作。 +- profiles.json 为增量文件,旧版忽略,不破坏旧逻辑。 + +### 4.3 当前上下文解析(唯一运行时改动,无环境变量) + +每次请求按优先级选 token: +``` +--profile flag > currentProfile > primaryProfile > 旧单槽 auth-token(兼容回退) +``` +- `--profile` 一次性、不写回 currentProfile;只有 `profile use` 才更新 currentProfile/previousProfile。 +- 落点为 `LoadTokenData` 与 root.go 注入处:把「加载固定槽」改为「解析出 corpId 再加载对应槽」。改动面集中、极小。 + +### 4.4 real / dev 双模(本期边界) +- dev(独立 CLI,本期落地):CLI 自跑 OAuth(loopback/device),每个 profile 的 TokenData 写进 `auth-token:`,更新 profiles.json。多 profile 逻辑仅在非嵌入(!IsEmbedded)时启用。 +- real(嵌悟空,本期不动):edition 的 Save/Load/DeleteToken hook 签名保持不变;token 仍由宿主单组织颁发/刷新,行为与现状完全一致。多 profile 能力本期不在 real 暴露。后续如需 real 多组织,再单独评审 hook 协议扩展,且必须向前兼容。 + +### 4.5 刷新与安全底线 +- 刷新按槽独立:每个 TokenData 自带 refresh\_token 加 clientID,GetAccessToken 只刷被选中的那一槽,互不影响。 +- 权限跟人走、CLI 不放大:每个 profile 独立 OAuth 独立 consent,token 数据范围 = 用户在该组织真实可见范围。 +- 业务域粒度:授权域记在 profiles.json 的 authorizedDomains;撤销单个域保留其他域 token,退出整个 profile 才清该 corp 的 keychain 槽。 + +## 五、运行时数据获取与聚合(重点) + +CLI 只提供底层原语,跨组织聚合由 agent(Claude Code)编排,符合「脚本只取数、触发/判断/编排在 agent 原生跑」的产品铁律。 +- 底层原语:CLI 提供「单 profile 一次取数」,默认走 currentProfile,或带 `--profile` 指定。 +- 聚合流程(agent 编排): + 1. `dws profile list` 拿到已授权 profile 加各自授权域。 + 2. 对每个满足条件的 profile 带 `--profile ` 调一次取数。 + 3. 合并结果并标注来源组织,按业务域自然序排列。 +- 部分失败降级:某 profile 调用失败时,agent 正常返回已成功的,对失败的标注「暂不可用,可稍后重试」。 +- 不内置 `--all-orgs`:保持泛化性,编排逻辑留在 agent,遇到未预设情况能现场判断。 + +## 六、边界与异常(对齐 PRD 第六节) +- 凭证过期:该 profile 槽刷新失败,profiles.json 标 expired,下次取数提示重登并给快捷入口。 +- 被移出组织:refresh 返回失效,标 revoked,清该 corp 槽与缓存,从 list 移除。 +- 部分失败:见第五节降级。 +- 主 profile 保护:`auth logout --all` 与 `profile use` 均不动 primaryProfile;主 profile 不可退出。 + +## 七、落地改动点(文件级,仅 dev 路径) +- internal/keychain、internal/auth/keychain\_store.go:token account 扩为 `auth-token:`,新增按 corp 的 Save/Load/Delete/List;保留旧单槽镜像写。 +- internal/auth/token.go:LoadTokenData/SaveTokenData/DeleteTokenData 内部按解析出的 corpId 选槽;edition hook 签名不变(real 路径原样)。 +- 新增 internal/auth/profiles.go:profiles.json 读写,加 currentProfile/previousProfile 解析,加优先级链。 +- internal/app/auth\_command.go:`logout` 加 `--profile/--all`,`status` 加 `--profile`,`login` 加 `--domain/--recommend`。 +- 新增 internal/app/profile\_command.go:`dws profile list / use`(含 `use -` 切回)。 +- internal/app/root.go:注入处改为「解析当前 profile 后加载对应槽」;注册全局 `--profile`(仅 !IsEmbedded 生效)。 + +## 八、待确认 / 后续 +- real 模式多组织:本期不做,留作后续独立评审,扩展时 hook 协议须向前兼容。 +- 业务域到 scope 映射表:`--domain` 落地需要一份 dws 业务域到钉钉 OAuth scope 的映射(可参考飞书 domain 到 scope 注册表的做法)。 diff --git a/docs/ralph/dws-multi-profile-login/source/source-manifest.json b/docs/ralph/dws-multi-profile-login/source/source-manifest.json new file mode 100644 index 00000000..adba898d --- /dev/null +++ b/docs/ralph/dws-multi-profile-login/source/source-manifest.json @@ -0,0 +1,31 @@ +{ + "sources": [ + { + "title": "dws 多组织 CLI 技术方案", + "nodeId": "mweZ92PV6O36dZbnsMLBrOLGJxEKBD6p", + "url": "https://alidocs.dingtalk.com/i/nodes/mweZ92PV6O36dZbnsMLBrOLGJxEKBD6p?utm_scene=person_space", + "role": "primary_technical_design", + "logId": "213ee25c17824452404885344e08e2", + "localPath": "docs/ralph/dws-multi-profile-login/source/mweZ92PV6O36dZbnsMLBrOLGJxEKBD6p-tech-solution.md", + "imageUrlsRedacted": true + }, + { + "title": "方案补充", + "nodeId": "MyQA2dXW7oOA63YacZgBYPbmWzlwrZgb", + "url": "https://alidocs.dingtalk.com/i/nodes/MyQA2dXW7oOA63YacZgBYPbmWzlwrZgb?utm_scene=person_space", + "role": "technical_scope_supplement", + "logId": "0bab027317824452412095066e094d", + "localPath": "docs/ralph/dws-multi-profile-login/source/MyQA2dXW7oOA63YacZgBYPbmWzlwrZgb-supplement.md", + "imageUrlsRedacted": true + }, + { + "title": "dws支持多组织", + "nodeId": "MyQA2dXW7oOA63YacZ4vrQ1RWzlwrZgb", + "url": "https://alidocs.dingtalk.com/i/nodes/MyQA2dXW7oOA63YacZ4vrQ1RWzlwrZgb?corpId=ding8196cd9a2b2405da24f2f5cc6abecb85&utm_medium=im_card&iframeQuery=utm_medium%3Dim_card%26utm_source%3Dim&utm_scene=person_space&utm_source=im", + "role": "product_logic_reference", + "logId": "2104a64c17824452419116947e08c7", + "localPath": "docs/ralph/dws-multi-profile-login/source/MyQA2dXW7oOA63YacZ4vrQ1RWzlwrZgb-product-plan.md", + "imageUrlsRedacted": true + } + ] +} diff --git a/docs/ralph/dws-multi-profile-login/technical-solution.md b/docs/ralph/dws-multi-profile-login/technical-solution.md new file mode 100644 index 00000000..e558d6c4 --- /dev/null +++ b/docs/ralph/dws-multi-profile-login/technical-solution.md @@ -0,0 +1,127 @@ +# dws 多组织登录技术方案 + +生成时间:2026-06-26 + +## 目标 + +让同一个本机用户可以在 dws 中登录多个钉钉组织,并在后续命令执行时明确选择组织上下文。能力必须位于 dws 顶层,便于终端调度直接命中。 + +## 命令设计 + +### 登录 + +首次登录: + +```bash +dws auth login --format json +``` + +继续登录第二个或第三个组织: + +```bash +dws auth login --force --format json +``` + +设备码模式: + +```bash +dws auth login --device --force --format json +``` + +说明:不新增 `--associated`。OAuth 授权结果中的 `corpId` 是 profile 的唯一组织键。 + +### 查看 profile + +```bash +dws profile list --format json +dws profile ls +``` + +JSON 输出包含 `primaryProfile`、`currentProfile`、`previousProfile` 和 `profiles[]`;profile 项包含 `name`、`corpId`、`corpName`、`userId`、`userName`、`status`、过期时间与 current/primary 标记。表格输出必须展示组织名,避免只展示 corpId。 + +### 切换 profile + +```bash +dws profile use --format json +dws profile use - --format json +``` + +`profile use ` 持久切换默认 current;`profile use -` 在 current 和 previous 间 toggle。切换成功后同步 legacy `auth-token` 镜像,并清理进程内 token/runtime cache。 + +### 单次命令覆盖 + +```bash +dws --profile --format json +``` + +`--profile` 只影响本次运行,不修改 `currentProfile`。适合 agent 在一个任务中轮询多个组织。 + +## 数据模型 + +### profiles.json + +`profiles.json` 只保存非敏感元数据: + +- `primaryProfile`:首次成功登录的组织,普通 logout 不删除。 +- `currentProfile`:默认命令上下文。 +- `previousProfile`:上一个 current,用于 `profile use -`。 +- `profiles[]`:按 `corpId` 维护 profile 元数据。 + +不得在 `profiles.json` 中保存 access token、refresh token、persistent code 或 client secret。 + +### keychain token 槽 + +- 新槽:`auth-token:` +- legacy 镜像:`auth-token` + +每个组织 token 独立存储。当前 profile 的 token 会同步到 legacy 槽,兼容旧二进制、旧宿主或只检查 token marker 的逻辑。 + +## 运行时解析 + +profile 解析优先级: + +1. 全局 `--profile` +2. `currentProfile` +3. `primaryProfile` +4. legacy `auth-token` + +命令初始化时先预解析 `--profile`,再构造运行时 loader,确保插件命令获取 token 前已经有正确的组织上下文。 + +## 第二/第三组织登录语义 + +第二个组织和第三个组织都是“重复登录同一个自然人但选择不同组织”的场景。CLI 不需要知道这是第几个组织,只看 OAuth 返回的 `corpId`: + +- 新 `corpId`:新增 profile,写入 `auth-token:`,设为 current。 +- 已存在 `corpId`:刷新该 profile token 和元数据,不新增重复项。 +- 首个 profile:同时成为 primary 和 current。 +- 后续 profile:成为 current,原 current 进入 previous。 + +## 被拒绝或延期的产品逻辑 + +以下产品稿能力不进入 P0 技术实现: + +- `dws auth list`:替换为 `dws profile list`。 +- `dws auth switch`:替换为 `dws profile use`。 +- `dws auth login --associated`:替换为重复执行 `dws auth login --force`。 +- `--组织corp ID`:替换为全局 `--profile `。 +- 自动发现所有所属组织:P1;P0 只展示主动登录过的 profile。 +- 内置跨组织聚合 `--all-orgs`:不做;由 agent 编排多次 `--profile` 调用。 + +## 验收标准 + +- 首次登录创建 primary/current profile。 +- 第二/第三组织登录不会覆盖已有组织 token。 +- 同组织重复登录只刷新,不重复新增。 +- `dws profile list` 顶层可见,JSON 和表格都展示组织名。 +- `dws profile use` 可按 name/corpId 切换,可用 `-` 切回 previous。 +- `--profile` 可一次性指定组织,且不改变 current。 +- `auth status/logout/reset` 按 profile 语义执行,primary 不被普通 logout 误删。 +- legacy 单槽可迁移,current token 可镜像到 legacy 槽。 + +## 验证命令 + +```bash +go test ./internal/auth ./internal/app -run 'Test(MultiProfile|RuntimeProfile|DeleteProfile|UpsertProfile|LoadProfiles|LegacyKeychain|WriteProfile|ProfileList|ProfileUse|AuthStatus|AuthLogout|AuthLogin|ResolveAuthLogin|EnrichAuthLogin|RootHelp|RootShortHelp|RootCommand)' +dws version +dws profile list --format json +``` From 5ce10ce325576f49ce7f2c1dc1ce47722e02d89e Mon Sep 17 00:00:00 2001 From: "shangguanxuan.sgx" Date: Fri, 26 Jun 2026 12:03:29 +0800 Subject: [PATCH 05/22] =?UTF-8?q?feat(auth):=20=E6=94=AF=E6=8C=81=20auth?= =?UTF-8?q?=20switch=20TUI=20=E5=88=87=E6=8D=A2=20profile?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../ralph/dws-multi-profile-login/analysis.md | 18 +- .../comments/pr-comment.md | 7 +- docs/ralph/dws-multi-profile-login/prd.json | 9 +- .../review/acceptance-review.md | 9 +- .../technical-solution.md | 14 +- internal/app/auth_command.go | 13 ++ internal/app/profile_command.go | 159 +++++++++++++++--- internal/app/profile_command_test.go | 120 +++++++++++++ prd.json | 9 +- 9 files changed, 308 insertions(+), 50 deletions(-) diff --git a/docs/ralph/dws-multi-profile-login/analysis.md b/docs/ralph/dws-multi-profile-login/analysis.md index 42958130..4f8a3e7f 100644 --- a/docs/ralph/dws-multi-profile-login/analysis.md +++ b/docs/ralph/dws-multi-profile-login/analysis.md @@ -17,11 +17,12 @@ ## 决策结论 -本轮以技术方案为主线。产品稿中提到的 `auth list`、`auth switch`、`auth login --associated`、`--组织corp ID` 属于被技术方案替换的命名或交互,不作为当前实现验收口径。当前 P0 采用: +本轮以技术方案为主线。产品稿中提到的 `auth list`、`auth login --associated`、`--组织corp ID` 属于被技术方案替换的命名或交互,不作为当前实现验收口径。`auth switch` 保留为 `profile use` 的产品兼容入口,并在无参数时展示组织选择 TUI。当前 P0 采用: - `dws auth login --force`:继续登录第二个、第三个组织。 - `dws profile list`:查看已登录组织。 -- `dws profile use `:持久切换当前组织。 +- `dws profile use [name|corpId|-]`:持久切换当前组织;无参数时展示 TUI。 +- `dws auth switch [name|corpId|-]`:兼容切换入口;无参数时展示 TUI。 - `dws --profile `:单次命令临时指定组织。 这个拆分符合“auth 管凭证、profile 管组织上下文”的边界,也让终端调度 dws 时能在顶层直接命中 profile 管理能力。 @@ -69,12 +70,21 @@ dws profile list --format json ```bash dws profile use --format json +dws auth switch --format json +``` + +交互选择组织: + +```bash +dws auth switch +dws profile use ``` 切回上一个组织: ```bash dws profile use - --format json +dws auth switch - --format json ``` 单次命令指定组织,不改变默认 current: @@ -89,7 +99,7 @@ dws --profile --format json - 多槽 profile 元数据与 current/primary/previous 指针:`internal/auth/profiles.go` - token 按组织独立存储,legacy `auth-token` 镜像兼容旧逻辑:`internal/auth/token.go` -- 顶层 `dws profile list/use`:`internal/app/profile_command.go` +- 顶层 `dws profile list/use` 与 `dws auth switch` 兼容入口:`internal/app/profile_command.go`、`internal/app/auth_command.go` - 全局 `--profile` 预解析与运行时注入:`internal/app/root.go` - PRD 与验收口径:`prd.json` @@ -105,7 +115,7 @@ dws --profile --format json 聚焦多组织能力的单元测试已通过: ```bash -go test ./internal/auth ./internal/app -run 'Test(MultiProfile|RuntimeProfile|DeleteProfile|UpsertProfile|LoadProfiles|LegacyKeychain|WriteProfile|ProfileList|ProfileUse|AuthStatus|AuthLogout|AuthLogin|ResolveAuthLogin|EnrichAuthLogin|RootHelp|RootShortHelp|RootCommand)' +go test ./internal/auth ./internal/app -run 'Test(MultiProfile|RuntimeProfile|DeleteProfile|UpsertProfile|LoadProfiles|LegacyKeychain|WriteProfile|ProfileList|ProfileUse|ProfileSwitch|AuthSwitch|AuthStatus|AuthLogout|AuthLogin|ResolveAuthLogin|EnrichAuthLogin|RootHelp|RootShortHelp|RootCommand)' ``` 全量 `go test ./internal/auth ./internal/app` 中 `internal/auth` 通过,但 `internal/app` 被升级模块用例 `TestValidateNewBinary_RecoversFromUnsignedDarwin` 阻塞,错误是测试二进制执行时被 macOS kill。该失败发生在 upgrade 验签/回滚路径,不在多组织登录代码改动面内,需作为独立 CI/本机签名环境问题跟进。 diff --git a/docs/ralph/dws-multi-profile-login/comments/pr-comment.md b/docs/ralph/dws-multi-profile-login/comments/pr-comment.md index 0efa98bd..622e1f01 100644 --- a/docs/ralph/dws-multi-profile-login/comments/pr-comment.md +++ b/docs/ralph/dws-multi-profile-login/comments/pr-comment.md @@ -7,14 +7,15 @@ - 第二/第三个组织不新增特殊命令,继续执行 `dws auth login --force --format json`,在 OAuth 页选择目标组织。 - 新 `corpId` 会新增 profile;已存在 `corpId` 只刷新 token 和元数据。 - 查看组织:`dws profile list --format json`。 -- 持久切换:`dws profile use --format json`。 +- 持久切换:`dws profile use --format json` 或 `dws auth switch --format json`。 +- 交互切换:`dws auth switch` 或 `dws profile use` 无参数时展示组织选择 TUI。 - 单次命令指定组织:`dws --profile --format json`。 -- 产品稿里的 `auth list/auth switch/--associated/--组织corp ID` 不进入 P0,分别由 `profile list/use`、重复 `auth login --force` 和全局 `--profile` 替代。 +- 产品稿里的 `auth list/--associated/--组织corp ID` 不进入 P0,分别由 `profile list`、重复 `auth login --force` 和全局 `--profile` 替代;`auth switch` 作为 `profile use` 的兼容入口保留。 ### 验证 ```bash -go test ./internal/auth ./internal/app -run 'Test(MultiProfile|RuntimeProfile|DeleteProfile|UpsertProfile|LoadProfiles|LegacyKeychain|WriteProfile|ProfileList|ProfileUse|AuthStatus|AuthLogout|AuthLogin|ResolveAuthLogin|EnrichAuthLogin|RootHelp|RootShortHelp|RootCommand)' +go test ./internal/auth ./internal/app -run 'Test(MultiProfile|RuntimeProfile|DeleteProfile|UpsertProfile|LoadProfiles|LegacyKeychain|WriteProfile|ProfileList|ProfileUse|ProfileSwitch|AuthSwitch|AuthStatus|AuthLogout|AuthLogin|ResolveAuthLogin|EnrichAuthLogin|RootHelp|RootShortHelp|RootCommand)' ``` 结果:`internal/auth` 与多组织相关 `internal/app` 用例通过。 diff --git a/docs/ralph/dws-multi-profile-login/prd.json b/docs/ralph/dws-multi-profile-login/prd.json index 1f86ce6a..c2335450 100644 --- a/docs/ralph/dws-multi-profile-login/prd.json +++ b/docs/ralph/dws-multi-profile-login/prd.json @@ -51,7 +51,7 @@ "technicalDecisions": [ { "topic": "command_naming", - "decision": "采用技术方案命名:auth 管凭证,profile 管组织身份选择;用 dws profile list/use 和全局 --profile,替代产品草案中的 auth list/auth switch/--组织corp ID。", + "decision": "采用技术方案命名:auth 管凭证,profile 管组织身份选择;主命令使用 dws profile list/use 和全局 --profile,同时保留 dws auth switch 作为产品兼容入口并在无参数时展示组织选择 TUI。", "reason": "与飞书 CLI 的 auth/profile 分层一致,避免把凭证动作和上下文选择混在同一命令组。" }, { @@ -100,9 +100,12 @@ "id": "F3", "priority": "P0", "name": "切换当前组织", - "description": "dws 顶层提供 dws profile use ,用于持久切换默认组织上下文或切回上一个组织。", + "description": "dws 顶层提供 dws profile use [name|corpId|-],并提供 dws auth switch [name|corpId|-] 兼容入口,用于持久切换默认组织上下文或切回上一个组织。", "acceptance": [ "dws profile use 将 currentProfile 更新为目标 corpId。", + "dws auth switch 等价于 dws profile use ,用于兼容产品方案和用户心智。", + "dws auth switch 或 dws profile use 在交互终端且无参数时展示组织选择 TUI,用户可用方向键选择并回车确认。", + "非交互环境无参数执行时返回 validation error,并提示显式传入 profile 名或 corpId。", "切换时 previousProfile 记录切换前的 currentProfile。", "dws profile use - 使用 previousProfile 切回,并在 current/previous 间 toggle。", "切换成功后重置进程内 token/runtime 缓存,后续无 --profile 命令默认使用新的 currentProfile。", @@ -210,7 +213,7 @@ "reviewChecklist": [ "切换组织能力必须在 dws 顶层出现,不接受只在内部函数可用。", "profile list/use 与 auth status 的人类可读输出必须显式包含组织名,不接受只展示 profile name 或 corpId。", - "产品草案中的 auth switch/auth list/--associated/--组织corp ID 若与技术方案冲突,以技术方案命名为准,并在 PRD 中记录裁决。", + "产品草案中的 auth list/--associated/--组织corp ID 若与技术方案冲突,以技术方案命名为准;auth switch 保留为 profile use 的兼容入口,且无参数时必须展示 TUI。", "profiles.json 不得包含 access_token、refresh_token、persistent_code 或 client_secret。", "全局 --profile 不得持久改 currentProfile。", "primaryProfile 不得被 logout 误删;全量删除只允许 auth reset。", diff --git a/docs/ralph/dws-multi-profile-login/review/acceptance-review.md b/docs/ralph/dws-multi-profile-login/review/acceptance-review.md index de9abb45..e6619cff 100644 --- a/docs/ralph/dws-multi-profile-login/review/acceptance-review.md +++ b/docs/ralph/dws-multi-profile-login/review/acceptance-review.md @@ -13,13 +13,14 @@ - 第二/第三组织登录路径已落地:重复 `dws auth login --force` - 单次组织指定已落地:全局 `--profile` - 组织名展示已落地:profile JSON 包含 `corpName`,表格包含 `ORG_NAME` -- 技术方案拒绝项已裁决:不实现 `auth list/auth switch/--associated/--组织corp ID` +- 技术方案拒绝项已裁决:不实现 `auth list/--associated/--组织corp ID`;`auth switch` 保留为 `profile use` 的兼容入口,无参数展示 TUI ## 代码证据 - `internal/auth/profiles.go`:维护 `primaryProfile`、`currentProfile`、`previousProfile`,按 `corpId` upsert profile。 - `internal/auth/token.go`:token 写入 `auth-token:`,并同步 legacy `auth-token`。 -- `internal/app/profile_command.go`:实现 `profile list`、`profile use `,输出组织名和 corpId。 +- `internal/app/profile_command.go`:实现 `profile list`、`profile use [name|corpId|-]`,无参数展示 TUI,输出组织名和 corpId。 +- `internal/app/auth_command.go`:实现 `auth switch [name|corpId|-]` 兼容入口,复用 profile 切换逻辑。 - `internal/app/root.go`:注册顶层 `profile` 命令,并在运行时预解析/注入全局 `--profile`。 - `internal/app/auth_command.go`:auth status/logout/reset 对 profile 语义做了补齐。 @@ -28,7 +29,7 @@ 已通过: ```bash -go test ./internal/auth ./internal/app -run 'Test(MultiProfile|RuntimeProfile|DeleteProfile|UpsertProfile|LoadProfiles|LegacyKeychain|WriteProfile|ProfileList|ProfileUse|AuthStatus|AuthLogout|AuthLogin|ResolveAuthLogin|EnrichAuthLogin|RootHelp|RootShortHelp|RootCommand)' +go test ./internal/auth ./internal/app -run 'Test(MultiProfile|RuntimeProfile|DeleteProfile|UpsertProfile|LoadProfiles|LegacyKeychain|WriteProfile|ProfileList|ProfileUse|ProfileSwitch|AuthSwitch|AuthStatus|AuthLogout|AuthLogin|ResolveAuthLogin|EnrichAuthLogin|RootHelp|RootShortHelp|RootCommand)' ``` 结果: @@ -55,7 +56,7 @@ go test ./internal/auth ./internal/app -run 'Test(MultiProfile|RuntimeProfile|De 1. 首次 `dws auth login` 登录主组织。 2. 继续 `dws auth login --force` 登录第二/第三组织。 3. 用 `dws profile list` 看组织列表。 -4. 用 `dws profile use` 切默认组织。 +4. 用 `dws auth switch` 或 `dws profile use` 切默认组织;无参数时弹 TUI。 5. 用 `dws --profile ` 做单次跨组织调度。 这条路径覆盖了“多组织登录、切换、终端可调度、组织名可见”的核心需求。 diff --git a/docs/ralph/dws-multi-profile-login/technical-solution.md b/docs/ralph/dws-multi-profile-login/technical-solution.md index e558d6c4..c292f6bc 100644 --- a/docs/ralph/dws-multi-profile-login/technical-solution.md +++ b/docs/ralph/dws-multi-profile-login/technical-solution.md @@ -42,11 +42,13 @@ JSON 输出包含 `primaryProfile`、`currentProfile`、`previousProfile` 和 `p ### 切换 profile ```bash -dws profile use --format json +dws profile use [name-or-corpId] --format json +dws auth switch [name-or-corpId] --format json dws profile use - --format json +dws auth switch - --format json ``` -`profile use ` 持久切换默认 current;`profile use -` 在 current 和 previous 间 toggle。切换成功后同步 legacy `auth-token` 镜像,并清理进程内 token/runtime cache。 +`profile use ` 持久切换默认 current;`auth switch ` 是产品兼容入口,语义等价。`profile use -` 和 `auth switch -` 在 current 和 previous 间 toggle。无参数执行 `dws auth switch` 或 `dws profile use` 时,在交互终端展示组织选择 TUI;非交互环境要求显式传入 profile 名或 corpId。切换成功后同步 legacy `auth-token` 镜像,并清理进程内 token/runtime cache。 ### 单次命令覆盖 @@ -64,7 +66,7 @@ dws --profile --format json - `primaryProfile`:首次成功登录的组织,普通 logout 不删除。 - `currentProfile`:默认命令上下文。 -- `previousProfile`:上一个 current,用于 `profile use -`。 +- `previousProfile`:上一个 current,用于 `profile use -` 或 `auth switch -`。 - `profiles[]`:按 `corpId` 维护 profile 元数据。 不得在 `profiles.json` 中保存 access token、refresh token、persistent code 或 client secret。 @@ -101,7 +103,7 @@ profile 解析优先级: 以下产品稿能力不进入 P0 技术实现: - `dws auth list`:替换为 `dws profile list`。 -- `dws auth switch`:替换为 `dws profile use`。 +- `dws auth switch`:保留为 `dws profile use` 的兼容入口;无参数时必须展示 TUI。 - `dws auth login --associated`:替换为重复执行 `dws auth login --force`。 - `--组织corp ID`:替换为全局 `--profile `。 - 自动发现所有所属组织:P1;P0 只展示主动登录过的 profile。 @@ -113,7 +115,7 @@ profile 解析优先级: - 第二/第三组织登录不会覆盖已有组织 token。 - 同组织重复登录只刷新,不重复新增。 - `dws profile list` 顶层可见,JSON 和表格都展示组织名。 -- `dws profile use` 可按 name/corpId 切换,可用 `-` 切回 previous。 +- `dws profile use` 与 `dws auth switch` 可按 name/corpId 切换,可用 `-` 切回 previous,无参数时展示 TUI。 - `--profile` 可一次性指定组织,且不改变 current。 - `auth status/logout/reset` 按 profile 语义执行,primary 不被普通 logout 误删。 - legacy 单槽可迁移,current token 可镜像到 legacy 槽。 @@ -121,7 +123,7 @@ profile 解析优先级: ## 验证命令 ```bash -go test ./internal/auth ./internal/app -run 'Test(MultiProfile|RuntimeProfile|DeleteProfile|UpsertProfile|LoadProfiles|LegacyKeychain|WriteProfile|ProfileList|ProfileUse|AuthStatus|AuthLogout|AuthLogin|ResolveAuthLogin|EnrichAuthLogin|RootHelp|RootShortHelp|RootCommand)' +go test ./internal/auth ./internal/app -run 'Test(MultiProfile|RuntimeProfile|DeleteProfile|UpsertProfile|LoadProfiles|LegacyKeychain|WriteProfile|ProfileList|ProfileUse|ProfileSwitch|AuthSwitch|AuthStatus|AuthLogout|AuthLogin|ResolveAuthLogin|EnrichAuthLogin|RootHelp|RootShortHelp|RootCommand)' dws version dws profile list --format json ``` diff --git a/internal/app/auth_command.go b/internal/app/auth_command.go index 48e2da70..c606431c 100644 --- a/internal/app/auth_command.go +++ b/internal/app/auth_command.go @@ -82,6 +82,7 @@ func buildAuthCommand(patCaller edition.ToolCaller) *cobra.Command { cmd.AddCommand( newAuthLogoutCommand(), newAuthStatusCommand(), + newAuthSwitchCommand(), newAuthExportCommand(), newAuthImportCommand(), newAuthExchangeCommand(), @@ -90,6 +91,18 @@ func buildAuthCommand(patCaller edition.ToolCaller) *cobra.Command { return cmd } +func newAuthSwitchCommand() *cobra.Command { + return &cobra.Command{ + Use: "switch [name|corpId|-]", + Short: "切换当前组织 profile", + Args: cobra.MaximumNArgs(1), + DisableAutoGenTag: true, + RunE: func(cmd *cobra.Command, args []string) error { + return runProfileSwitchCommand(cmd, args) + }, + } +} + func newAuthLoginCommand(patCaller edition.ToolCaller) *cobra.Command { cmd := &cobra.Command{ Use: "login", diff --git a/internal/app/profile_command.go b/internal/app/profile_command.go index 37b906b3..5a32b124 100644 --- a/internal/app/profile_command.go +++ b/internal/app/profile_command.go @@ -21,6 +21,7 @@ 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/charmbracelet/huh" "github.com/spf13/cobra" ) @@ -67,40 +68,144 @@ func newProfileListCommand() *cobra.Command { func newProfileUseCommand() *cobra.Command { return &cobra.Command{ - Use: "use ", + Use: "use [name|corpId|-]", Short: "切换当前组织 profile", - Args: cobra.ExactArgs(1), + Args: cobra.MaximumNArgs(1), DisableAutoGenTag: true, RunE: func(cmd *cobra.Command, args []string) error { - configDir := defaultConfigDir() - var ( - profile *authpkg.Profile - err error - ) - if strings.TrimSpace(args[0]) == "-" { - profile, err = authpkg.UsePreviousProfile(configDir) - } else { - profile, err = authpkg.SetCurrentProfile(configDir, args[0]) - } - if err != nil { - return apperrors.NewValidation(err.Error()) - } - ResetRuntimeTokenCache() - clearCompatCache() - format, _ := cmd.Root().PersistentFlags().GetString("format") - if strings.EqualFold(strings.TrimSpace(format), "json") { - cfg, loadErr := authpkg.LoadProfiles(configDir) - if loadErr != nil { - return apperrors.NewInternal(fmt.Sprintf("failed to load profiles: %v", loadErr)) - } - return writeProfileUseJSON(cmd.OutOrStdout(), profile, cfg) - } - fmt.Fprintln(cmd.OutOrStdout(), profileUseMessage(profile)) - return nil + return runProfileSwitchCommand(cmd, args) }, } } +var ( + profileSwitchSelector = selectProfileSwitchProfile + profileSwitchInteractiveTerminal = isInteractiveTerminal +) + +func runProfileSwitchCommand(cmd *cobra.Command, args []string) error { + configDir := defaultConfigDir() + selector := "" + if len(args) > 0 { + selector = strings.TrimSpace(args[0]) + } + usedTUI := false + if selector == "" { + var err error + selector, err = profileSwitchSelector(cmd, configDir) + if err != nil { + return err + } + usedTUI = true + } + return switchProfileAndWrite(cmd, configDir, selector, usedTUI) +} + +func switchProfileAndWrite(cmd *cobra.Command, configDir, selector string, usedTUI bool) error { + var ( + profile *authpkg.Profile + err error + ) + if strings.TrimSpace(selector) == "-" { + profile, err = authpkg.UsePreviousProfile(configDir) + } else { + profile, err = authpkg.SetCurrentProfile(configDir, selector) + } + if err != nil { + return apperrors.NewValidation(err.Error()) + } + ResetRuntimeTokenCache() + clearCompatCache() + format, _ := cmd.Root().PersistentFlags().GetString("format") + if strings.EqualFold(strings.TrimSpace(format), "json") && !(usedTUI && authLoginAllowsInteractiveDefault(cmd, format)) { + cfg, loadErr := authpkg.LoadProfiles(configDir) + if loadErr != nil { + return apperrors.NewInternal(fmt.Sprintf("failed to load profiles: %v", loadErr)) + } + return writeProfileUseJSON(cmd.OutOrStdout(), profile, cfg) + } + fmt.Fprintln(cmd.OutOrStdout(), profileUseMessage(profile)) + return nil +} + +func selectProfileSwitchProfile(cmd *cobra.Command, configDir string) (string, error) { + if !profileSwitchInteractiveTerminal() { + return "", apperrors.NewValidation("profile selector required in non-interactive mode; use dws auth switch or dws profile use ") + } + 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 { + return "", apperrors.NewValidation("未找到已登录 profile,请先运行 dws auth login") + } + choice := strings.TrimSpace(cfg.CurrentProfile) + if choice == "" { + choice = strings.TrimSpace(cfg.PrimaryProfile) + } + if choice == "" { + choice = cfg.Profiles[0].CorpID + } + options := make([]huh.Option[string], 0, len(cfg.Profiles)) + for _, p := range cfg.Profiles { + options = append(options, huh.NewOption(profileSwitchOptionLabel(p, cfg), p.CorpID)) + } + height := len(options) + if height > 12 { + height = 12 + } + form := huh.NewForm( + huh.NewGroup( + huh.NewSelect[string](). + Title("选择要切换的组织"). + Description("↑↓ 选择,Enter 确认\n\nORGANIZATION STATUS USER CORP_ID"). + Options(options...). + Height(height). + Value(&choice), + ), + ).WithTheme(authLoginHuhTheme()) + if err := form.Run(); err != nil { + return "", apperrors.NewValidation(fmt.Sprintf("组织选择中止: %v", err)) + } + return strings.TrimSpace(choice), nil +} + +func profileSwitchOptionLabel(p authpkg.Profile, cfg *authpkg.ProfilesConfig) string { + status := strings.TrimSpace(p.Status) + if status == "" { + status = authpkg.ProfileStatusActive + } + statusLabel := "" + switch status { + case authpkg.ProfileStatusActive: + statusLabel = "已登录" + case authpkg.ProfileStatusExpired: + statusLabel = "已过期" + case authpkg.ProfileStatusRevoked: + statusLabel = "已撤销" + default: + statusLabel = status + } + user := strings.TrimSpace(p.UserName) + if user == "" { + user = strings.TrimSpace(p.UserID) + } + if user == "" { + user = "-" + } + marker := "" + if cfg != nil && p.CorpID == cfg.CurrentProfile { + marker = " ← 当前" + } else if cfg != nil && p.CorpID == cfg.PrimaryProfile { + marker = " default" + } + org := profileOrgName(p) + return fmt.Sprintf("%-28s %-8s %-18s %s%s", clipProfileCell(org, 28), statusLabel, clipProfileCell(user, 18), p.CorpID, marker) +} + type profileListResponse struct { Success bool `json:"success"` PrimaryProfile string `json:"primaryProfile,omitempty"` diff --git a/internal/app/profile_command_test.go b/internal/app/profile_command_test.go index 1ab7f2ee..07d46fd9 100644 --- a/internal/app/profile_command_test.go +++ b/internal/app/profile_command_test.go @@ -19,6 +19,7 @@ import ( "testing" authpkg "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/auth" + "github.com/spf13/cobra" ) func TestWriteProfileUseJSONKeepsPrimaryAndCurrentDistinct(t *testing.T) { @@ -141,6 +142,125 @@ func TestProfileUseRootCommandSwitchesOrganizationAndLegacyMirror(t *testing.T) } } +func TestAuthSwitchRootCommandSwitchesOrganization(t *testing.T) { + configDir := setupAuthLogoutProfiles(t, + authLogoutTestToken("corp_primary"), + authLogoutTestToken("corp_secondary"), + ) + + cmd := NewRootCommand() + var out bytes.Buffer + cmd.SetOut(&out) + cmd.SetErr(&out) + cmd.SetArgs([]string{"--format", "table", "auth", "switch", "corp_primary"}) + if err := cmd.Execute(); err != nil { + t.Fatalf("auth switch corp_primary error = %v\noutput:\n%s", err, out.String()) + } + if !bytes.Contains(out.Bytes(), []byte("组织: corp_primary org")) { + t.Fatalf("auth switch output should include organization name:\n%s", out.String()) + } + cfg, err := authpkg.LoadProfiles(configDir) + if err != nil { + t.Fatalf("LoadProfiles() error = %v", err) + } + if cfg.CurrentProfile != "corp_primary" || cfg.PreviousProfile != "corp_secondary" { + t.Fatalf("profile pointers = current %q previous %q, want corp_primary/corp_secondary", cfg.CurrentProfile, cfg.PreviousProfile) + } +} + +func TestAuthSwitchNoArgsUsesTUISelector(t *testing.T) { + configDir := setupAuthLogoutProfiles(t, + authLogoutTestToken("corp_primary"), + authLogoutTestToken("corp_secondary"), + ) + oldSelector := profileSwitchSelector + t.Cleanup(func() { + profileSwitchSelector = oldSelector + }) + called := false + profileSwitchSelector = func(cmd *cobra.Command, gotConfigDir string) (string, error) { + called = true + if gotConfigDir != configDir { + t.Fatalf("configDir = %q, want %q", gotConfigDir, configDir) + } + return "corp_primary", nil + } + + cmd := NewRootCommand() + var out bytes.Buffer + cmd.SetOut(&out) + cmd.SetErr(&out) + cmd.SetArgs([]string{"auth", "switch"}) + if err := cmd.Execute(); err != nil { + t.Fatalf("auth switch error = %v\noutput:\n%s", err, out.String()) + } + if !called { + t.Fatal("auth switch without args did not invoke TUI selector") + } + if !bytes.Contains(out.Bytes(), []byte("组织: corp_primary org")) { + t.Fatalf("auth switch TUI path should use human output by default:\n%s", out.String()) + } + cfg, err := authpkg.LoadProfiles(configDir) + if err != nil { + t.Fatalf("LoadProfiles() error = %v", err) + } + if cfg.CurrentProfile != "corp_primary" { + t.Fatalf("currentProfile = %q, want corp_primary", cfg.CurrentProfile) + } +} + +func TestProfileUseNoArgsUsesTUISelector(t *testing.T) { + configDir := setupAuthLogoutProfiles(t, + authLogoutTestToken("corp_primary"), + authLogoutTestToken("corp_secondary"), + ) + oldSelector := profileSwitchSelector + t.Cleanup(func() { + profileSwitchSelector = oldSelector + }) + profileSwitchSelector = func(cmd *cobra.Command, gotConfigDir string) (string, error) { + if gotConfigDir != configDir { + t.Fatalf("configDir = %q, want %q", gotConfigDir, configDir) + } + return "corp_primary", nil + } + + cmd := NewRootCommand() + var out bytes.Buffer + cmd.SetOut(&out) + cmd.SetErr(&out) + cmd.SetArgs([]string{"profile", "use"}) + if err := cmd.Execute(); err != nil { + t.Fatalf("profile use error = %v\noutput:\n%s", err, out.String()) + } + if !bytes.Contains(out.Bytes(), []byte("组织: corp_primary org")) { + t.Fatalf("profile use TUI path should use human output by default:\n%s", out.String()) + } + cfg, err := authpkg.LoadProfiles(configDir) + if err != nil { + t.Fatalf("LoadProfiles() error = %v", err) + } + if cfg.CurrentProfile != "corp_primary" { + t.Fatalf("currentProfile = %q, want corp_primary", cfg.CurrentProfile) + } +} + +func TestProfileSwitchSelectorRequiresInteractiveTerminal(t *testing.T) { + oldInteractive := profileSwitchInteractiveTerminal + t.Cleanup(func() { + profileSwitchInteractiveTerminal = oldInteractive + }) + profileSwitchInteractiveTerminal = func() bool { return false } + + _, err := selectProfileSwitchProfile(nil, t.TempDir()) + if err == nil { + t.Fatal("selectProfileSwitchProfile() succeeded, want validation error") + } + if !bytes.Contains([]byte(err.Error()), []byte("profile selector required")) { + t.Fatalf("error = %v, want profile selector hint", err) + } +} + func TestWriteProfileListTableIncludesCorpName(t *testing.T) { cfg := &authpkg.ProfilesConfig{ PrimaryProfile: "corp_a", diff --git a/prd.json b/prd.json index 1f86ce6a..c2335450 100644 --- a/prd.json +++ b/prd.json @@ -51,7 +51,7 @@ "technicalDecisions": [ { "topic": "command_naming", - "decision": "采用技术方案命名:auth 管凭证,profile 管组织身份选择;用 dws profile list/use 和全局 --profile,替代产品草案中的 auth list/auth switch/--组织corp ID。", + "decision": "采用技术方案命名:auth 管凭证,profile 管组织身份选择;主命令使用 dws profile list/use 和全局 --profile,同时保留 dws auth switch 作为产品兼容入口并在无参数时展示组织选择 TUI。", "reason": "与飞书 CLI 的 auth/profile 分层一致,避免把凭证动作和上下文选择混在同一命令组。" }, { @@ -100,9 +100,12 @@ "id": "F3", "priority": "P0", "name": "切换当前组织", - "description": "dws 顶层提供 dws profile use ,用于持久切换默认组织上下文或切回上一个组织。", + "description": "dws 顶层提供 dws profile use [name|corpId|-],并提供 dws auth switch [name|corpId|-] 兼容入口,用于持久切换默认组织上下文或切回上一个组织。", "acceptance": [ "dws profile use 将 currentProfile 更新为目标 corpId。", + "dws auth switch 等价于 dws profile use ,用于兼容产品方案和用户心智。", + "dws auth switch 或 dws profile use 在交互终端且无参数时展示组织选择 TUI,用户可用方向键选择并回车确认。", + "非交互环境无参数执行时返回 validation error,并提示显式传入 profile 名或 corpId。", "切换时 previousProfile 记录切换前的 currentProfile。", "dws profile use - 使用 previousProfile 切回,并在 current/previous 间 toggle。", "切换成功后重置进程内 token/runtime 缓存,后续无 --profile 命令默认使用新的 currentProfile。", @@ -210,7 +213,7 @@ "reviewChecklist": [ "切换组织能力必须在 dws 顶层出现,不接受只在内部函数可用。", "profile list/use 与 auth status 的人类可读输出必须显式包含组织名,不接受只展示 profile name 或 corpId。", - "产品草案中的 auth switch/auth list/--associated/--组织corp ID 若与技术方案冲突,以技术方案命名为准,并在 PRD 中记录裁决。", + "产品草案中的 auth list/--associated/--组织corp ID 若与技术方案冲突,以技术方案命名为准;auth switch 保留为 profile use 的兼容入口,且无参数时必须展示 TUI。", "profiles.json 不得包含 access_token、refresh_token、persistent_code 或 client_secret。", "全局 --profile 不得持久改 currentProfile。", "primaryProfile 不得被 logout 误删;全量删除只允许 auth reset。", From cb50304f4db55b62cc92298f4b4485ccebd6cf6c Mon Sep 17 00:00:00 2001 From: "shangguanxuan.sgx" Date: Fri, 26 Jun 2026 14:55:11 +0800 Subject: [PATCH 06/22] =?UTF-8?q?feat(auth):=20logout=20=E9=BB=98=E8=AE=A4?= =?UTF-8?q?=E6=B8=85=E7=90=86=E6=89=80=E6=9C=89=E7=BB=84=E7=BB=87?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../ralph/dws-multi-profile-login/analysis.md | 13 +- .../comments/pr-comment.md | 1 + docs/ralph/dws-multi-profile-login/prd.json | 11 +- .../review/acceptance-review.md | 3 +- .../technical-solution.md | 4 +- internal/app/auth_command.go | 57 +++---- internal/app/auth_command_test.go | 153 ++++++++---------- prd.json | 11 +- 8 files changed, 115 insertions(+), 138 deletions(-) diff --git a/docs/ralph/dws-multi-profile-login/analysis.md b/docs/ralph/dws-multi-profile-login/analysis.md index 4f8a3e7f..d26ae730 100644 --- a/docs/ralph/dws-multi-profile-login/analysis.md +++ b/docs/ralph/dws-multi-profile-login/analysis.md @@ -107,9 +107,20 @@ dws --profile --format json - P0 不自动发现用户属于的所有组织,只列出用户主动登录过的 profile。 - P0 不扩展 real/embedded hook 协议;hook 后端显式 profile 选择仍会返回“不支持”。 -- 主 profile 不通过普通 logout 误删;需要全量清理时走 `dws auth reset`。 +- `dws auth logout` 默认清理所有已登录组织;`--profile ` 才是单组织登出;`auth reset` 用于额外清理 app config 等本机认证配置。 - profile 元数据不保存 access token、refresh token、persistent code 或 client secret。 +## 飞书 CLI 登出语义对齐 + +飞书 CLI 的 `auth logout` 文档语义是“Sign out and remove stored credentials”,源码实现会遍历当前 app config 下的 `Users`,逐个 revoke/remove token,然后把 `app.Users` 置空并保存配置。也就是说,飞书的 logout 清的是当前 app/profile 认证上下文下的所有已登录用户 token,而不是只清一个“当前用户”。 + +dws 的多组织模型不是飞书的多用户列表,而是同一自然人下多个组织 profile。为了让用户输入 `dws auth logout` 时得到“我已经彻底退出 dws 用户授权”的结果,dws 应采用: + +- `dws auth logout`:默认清理所有组织 profile 的用户登录态。 +- `dws auth logout --profile `:只清理指定组织。 +- 不再暴露 `--all`,避免用户需要额外记忆“全登出”的特殊 flag。 +- `dws auth reset`:比 logout 更重,额外清理 app config、mcp_url 等本机认证配置。 + ## 验收状态 聚焦多组织能力的单元测试已通过: diff --git a/docs/ralph/dws-multi-profile-login/comments/pr-comment.md b/docs/ralph/dws-multi-profile-login/comments/pr-comment.md index 622e1f01..ab10e348 100644 --- a/docs/ralph/dws-multi-profile-login/comments/pr-comment.md +++ b/docs/ralph/dws-multi-profile-login/comments/pr-comment.md @@ -10,6 +10,7 @@ - 持久切换:`dws profile use --format json` 或 `dws auth switch --format json`。 - 交互切换:`dws auth switch` 或 `dws profile use` 无参数时展示组织选择 TUI。 - 单次命令指定组织:`dws --profile --format json`。 +- 登出:`dws auth logout` 默认清理所有组织登录态;`dws auth logout --profile ` 只清指定组织;`--all` 已移除。 - 产品稿里的 `auth list/--associated/--组织corp ID` 不进入 P0,分别由 `profile list`、重复 `auth login --force` 和全局 `--profile` 替代;`auth switch` 作为 `profile use` 的兼容入口保留。 ### 验证 diff --git a/docs/ralph/dws-multi-profile-login/prd.json b/docs/ralph/dws-multi-profile-login/prd.json index c2335450..3572a331 100644 --- a/docs/ralph/dws-multi-profile-login/prd.json +++ b/docs/ralph/dws-multi-profile-login/prd.json @@ -143,11 +143,12 @@ "id": "F6", "priority": "P0", "name": "退出与重置", - "description": "dws auth logout 支持按 profile 清理,dws auth logout --all 仅清非主 profile,dws auth reset 清理所有 profile 与 legacy 状态。", + "description": "dws auth logout 默认清理所有组织 profile 的用户登录态;dws auth logout --profile 只清理指定组织;dws auth reset 在 logout 基础上进一步清理本机应用认证配置。", "acceptance": [ - "dws auth logout --profile 删除目标 auth-token: 和 profile 元数据,不影响其他 profile。", - "dws auth logout --all 删除所有非 primaryProfile,保留主 profile。", - "primaryProfile 不应通过 logout 被误删;需要全量清理时使用 auth reset。", + "dws auth logout 不带参数时删除所有 auth-token:、profiles.json、legacy auth-token 和 token.json marker。", + "dws auth logout 不再提供 --all flag。", + "dws auth logout --profile 删除目标 auth-token: 和 profile 元数据,不影响其他 profile;目标可以是 primaryProfile。", + "dws auth logout 清理用户登录态但保留 app config / mcp_url 等本机应用配置。", "auth reset 删除 profiles.json、所有 auth-token:、legacy auth-token、token.json marker 和 app config。" ] }, @@ -216,7 +217,7 @@ "产品草案中的 auth list/--associated/--组织corp ID 若与技术方案冲突,以技术方案命名为准;auth switch 保留为 profile use 的兼容入口,且无参数时必须展示 TUI。", "profiles.json 不得包含 access_token、refresh_token、persistent_code 或 client_secret。", "全局 --profile 不得持久改 currentProfile。", - "primaryProfile 不得被 logout 误删;全量删除只允许 auth reset。", + "auth logout 默认必须清除所有已登录组织;--profile 只能清除指定组织;不得继续暴露 --all。", "real/embedded hook 签名不得为本期多组织改造破坏。" ] } diff --git a/docs/ralph/dws-multi-profile-login/review/acceptance-review.md b/docs/ralph/dws-multi-profile-login/review/acceptance-review.md index e6619cff..a4a3c9d2 100644 --- a/docs/ralph/dws-multi-profile-login/review/acceptance-review.md +++ b/docs/ralph/dws-multi-profile-login/review/acceptance-review.md @@ -22,7 +22,7 @@ - `internal/app/profile_command.go`:实现 `profile list`、`profile use [name|corpId|-]`,无参数展示 TUI,输出组织名和 corpId。 - `internal/app/auth_command.go`:实现 `auth switch [name|corpId|-]` 兼容入口,复用 profile 切换逻辑。 - `internal/app/root.go`:注册顶层 `profile` 命令,并在运行时预解析/注入全局 `--profile`。 -- `internal/app/auth_command.go`:auth status/logout/reset 对 profile 语义做了补齐。 +- `internal/app/auth_command.go`:auth status/logout/reset 对 profile 语义做了补齐;`auth logout` 默认清理所有组织,`--profile` 只清指定组织。 ## 测试证据 @@ -47,6 +47,7 @@ go test ./internal/auth ./internal/app -run 'Test(MultiProfile|RuntimeProfile|De - 全量 `go test ./internal/auth ./internal/app` 被 `internal/app` 中 upgrade 相关用例 `TestValidateNewBinary_RecoversFromUnsignedDarwin` 阻塞,错误为测试二进制执行被 macOS kill。该路径与多组织登录无直接耦合,应单独排查本机签名/隔离属性/测试环境。 - real/embedded hook 后端仍不支持显式 `--profile`,这是技术方案明确的 P0 非目标。 - P0 不自动发现所有所属组织;第三组织必须由用户再次完成 OAuth 授权后才会出现在 `profile list`。 +- `auth logout` 与飞书 CLI 的登出心智对齐为“清当前认证上下文下的用户登录态”;在 dws 多组织模型里,不带 `--profile` 表示清所有已登录组织。 - 跨组织聚合由 agent 编排,不由 CLI 内置 `--all-orgs`。 ## 验收判断 diff --git a/docs/ralph/dws-multi-profile-login/technical-solution.md b/docs/ralph/dws-multi-profile-login/technical-solution.md index c292f6bc..1b4bb277 100644 --- a/docs/ralph/dws-multi-profile-login/technical-solution.md +++ b/docs/ralph/dws-multi-profile-login/technical-solution.md @@ -64,7 +64,7 @@ dws --profile --format json `profiles.json` 只保存非敏感元数据: -- `primaryProfile`:首次成功登录的组织,普通 logout 不删除。 +- `primaryProfile`:首次成功登录的组织,用于默认 fallback 和标记;`auth logout` 默认全登出时会一并清除。 - `currentProfile`:默认命令上下文。 - `previousProfile`:上一个 current,用于 `profile use -` 或 `auth switch -`。 - `profiles[]`:按 `corpId` 维护 profile 元数据。 @@ -117,7 +117,7 @@ profile 解析优先级: - `dws profile list` 顶层可见,JSON 和表格都展示组织名。 - `dws profile use` 与 `dws auth switch` 可按 name/corpId 切换,可用 `-` 切回 previous,无参数时展示 TUI。 - `--profile` 可一次性指定组织,且不改变 current。 -- `auth status/logout/reset` 按 profile 语义执行,primary 不被普通 logout 误删。 +- `auth logout` 默认清理所有组织登录态;`auth logout --profile ` 只清指定组织;`auth reset` 额外清 app config 等本机认证配置。 - legacy 单槽可迁移,current token 可镜像到 legacy 槽。 ## 验证命令 diff --git a/internal/app/auth_command.go b/internal/app/auth_command.go index c606431c..f471c6db 100644 --- a/internal/app/auth_command.go +++ b/internal/app/auth_command.go @@ -395,7 +395,7 @@ func selectLoginRecommendScopeMode() (pat.LoginRecommendScopeMode, error) { func newAuthLogoutCommand() *cobra.Command { cmd := &cobra.Command{ Use: "logout", - Short: "清除认证信息", + Short: "清除认证信息(默认退出所有组织)", DisableAutoGenTag: true, RunE: func(cmd *cobra.Command, args []string) error { configDir := defaultConfigDir() @@ -403,31 +403,20 @@ func newAuthLogoutCommand() *cobra.Command { if err != nil { return apperrors.NewInternal("failed to read --profile") } - all, err := cmd.Flags().GetBool("all") - if err != nil { - return apperrors.NewInternal("failed to read --all") - } - if all && strings.TrimSpace(profileSelector) != "" { - return apperrors.NewValidation("--profile and --all cannot be used together") - } revokeCtx, cancel := context.WithTimeout(cmd.Context(), 15*time.Second) defer cancel() - if all { - if err := logoutNonPrimaryProfiles(cmd, revokeCtx, configDir); err != nil { + 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 } - } else if err := logoutOneProfile(cmd, revokeCtx, configDir, profileSelector); err != nil { - return err } - cleanupAuthConfigIfNoProfiles(configDir) ResetRuntimeTokenCache() clearCompatCache() w := cmd.OutOrStdout() - if all { - fmt.Fprintln(w, "[OK] 已清除所有非主 profile 认证信息") - fmt.Fprintln(w, "主 profile 已保留;如需清除全部认证信息,请运行 dws auth reset") - return nil - } fmt.Fprintln(w, "[OK] 已清除认证信息") if !edition.Get().IsEmbedded { fmt.Fprintln(w, "请运行 dws auth login --recommend 重新登录") @@ -436,7 +425,6 @@ func newAuthLogoutCommand() *cobra.Command { }, } cmd.Flags().String("profile", "", "指定要退出的 profile 名或 corpId") - cmd.Flags().Bool("all", false, "退出所有非主 profile") return cmd } @@ -526,19 +514,9 @@ func newAuthStatusCommand() *cobra.Command { } func logoutOneProfile(_ *cobra.Command, ctx context.Context, configDir, selector string) error { - selected, err := authpkg.ResolveProfile(configDir, selector) - if err != nil { + if _, err := authpkg.ResolveProfile(configDir, selector); err != nil { return apperrors.NewValidation(err.Error()) } - if selected != nil { - cfg, loadErr := authpkg.LoadProfiles(configDir) - if loadErr != nil { - return apperrors.NewInternal(fmt.Sprintf("failed to load profiles: %v", loadErr)) - } - if selected.CorpID == cfg.PrimaryProfile { - return apperrors.NewValidation("primary profile cannot be logged out; use auth reset to clear all profiles") - } - } restoreProfile := pushRuntimeProfile(selector) defer restoreProfile() _ = authpkg.RevokeTokenRemote(ctx) @@ -548,7 +526,7 @@ func logoutOneProfile(_ *cobra.Command, ctx context.Context, configDir, selector return nil } -func logoutNonPrimaryProfiles(_ *cobra.Command, ctx context.Context, configDir string) error { +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)) } @@ -556,17 +534,18 @@ func logoutNonPrimaryProfiles(_ *cobra.Command, ctx context.Context, configDir s if err != nil { return apperrors.NewInternal(fmt.Sprintf("failed to load profiles: %v", err)) } - for _, profile := range cfg.Profiles { - if profile.CorpID == cfg.PrimaryProfile { - continue - } - restoreProfile := pushRuntimeProfile(profile.CorpID) + if cfg == nil || len(cfg.Profiles) == 0 { _ = authpkg.RevokeTokenRemote(ctx) - restoreProfile() - if err := authpkg.DeleteTokenDataForProfile(configDir, profile.CorpID); err != nil { - return apperrors.NewInternal(fmt.Sprintf("failed to clear profile %s: %v", profile.Name, err)) + } 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 } diff --git a/internal/app/auth_command_test.go b/internal/app/auth_command_test.go index 3f6ef8f3..f66c7e06 100644 --- a/internal/app/auth_command_test.go +++ b/internal/app/auth_command_test.go @@ -233,76 +233,66 @@ func TestAuthStatusProfileOverrideDoesNotSwitchCurrentProfile(t *testing.T) { } } -func TestAuthLogoutPrimarySingleProfileReturnsValidationAndKeepsToken(t *testing.T) { - for _, tc := range []struct { - name string - args []string - }{ - {name: "default current primary", args: []string{"auth", "logout"}}, - {name: "explicit primary profile", args: []string{"auth", "logout", "--profile", "corp_primary"}}, - } { - t.Run(tc.name, func(t *testing.T) { - configDir := setupAuthLogoutProfiles(t, authLogoutTestToken("corp_primary")) - - revokeCalls := 0 - originalTransport := http.DefaultTransport - t.Cleanup(func() { - http.DefaultTransport = originalTransport - }) - http.DefaultTransport = roundTripFunc(func(req *http.Request) (*http.Response, error) { - revokeCalls++ - return nil, errors.New("unexpected remote revoke for protected primary profile") - }) - - cmd := NewRootCommand() - var out bytes.Buffer - cmd.SetOut(&out) - cmd.SetErr(&out) - cmd.SetArgs(tc.args) - - err := cmd.Execute() - if err == nil { - t.Fatalf("Execute(%v) succeeded, want validation error", tc.args) - } - var appErr *apperrors.Error - if !errors.As(err, &appErr) || appErr.Category != apperrors.CategoryValidation { - t.Fatalf("expected validation error, got %T: %v", err, err) - } - if !strings.Contains(err.Error(), "auth reset") { - t.Fatalf("error = %v, want auth reset hint", err) - } - if revokeCalls != 0 { - t.Fatalf("remote revoke calls = %d, want 0 for protected primary profile", revokeCalls) - } +func TestAuthLogoutDefaultDeletesAllProfilesAndPreservesAppConfig(t *testing.T) { + configDir := setupAuthLogoutProfiles(t, + authLogoutTestToken("corp_primary"), + authLogoutTestToken("corp_secondary"), + ) + if err := authpkg.SaveAppConfig(configDir, &authpkg.AppConfig{ + ClientID: "client-app", + ClientSecret: authpkg.PlainSecret("secret-app"), + }); err != nil { + t.Fatalf("SaveAppConfig() error = %v", err) + } - cfg, err := authpkg.LoadProfiles(configDir) - if err != nil { - t.Fatalf("LoadProfiles() error = %v", err) - } - if cfg.PrimaryProfile != "corp_primary" || cfg.CurrentProfile != "corp_primary" { - t.Fatalf("profiles pointers = primary %q current %q, want corp_primary/corp_primary", cfg.PrimaryProfile, cfg.CurrentProfile) - } - if len(cfg.Profiles) != 1 || cfg.Profiles[0].CorpID != "corp_primary" { - t.Fatalf("profiles = %#v, want only corp_primary retained", cfg.Profiles) - } - if !authpkg.TokenDataExistsKeychainForCorpID("corp_primary") { - t.Fatal("primary profile token should be retained") - } - loaded, err := authpkg.LoadTokenDataForProfile(configDir, "corp_primary") - if err != nil { - t.Fatalf("LoadTokenDataForProfile(primary) error = %v", err) - } - if loaded.AccessToken != "access-corp_primary" { - t.Fatalf("primary access token = %q, want retained token", loaded.AccessToken) - } - if !authpkg.TokenDataExistsKeychain() { - t.Fatal("legacy auth-token mirror should remain after rejected primary logout") - } - }) + originalTransport := http.DefaultTransport + t.Cleanup(func() { + http.DefaultTransport = originalTransport + }) + http.DefaultTransport = roundTripFunc(func(req *http.Request) (*http.Response, error) { + return nil, errors.New("remote revoke disabled in unit test") + }) + + cmd := NewRootCommand() + var out bytes.Buffer + cmd.SetOut(&out) + cmd.SetErr(&out) + cmd.SetArgs([]string{"auth", "logout"}) + if err := cmd.Execute(); err != nil { + t.Fatalf("auth logout error = %v\noutput:\n%s", err, out.String()) + } + for _, want := range []string{"[OK] 已清除认证信息", "重新登录"} { + if !strings.Contains(out.String(), want) { + t.Fatalf("auth logout output missing %q:\n%s", want, out.String()) + } + } + + cfg, err := authpkg.LoadProfiles(configDir) + if err != nil { + t.Fatalf("LoadProfiles() error = %v", err) + } + if cfg.PrimaryProfile != "" || cfg.CurrentProfile != "" || cfg.PreviousProfile != "" || len(cfg.Profiles) != 0 { + t.Fatalf("profiles after logout = %#v, want empty", cfg) + } + if authpkg.TokenDataExistsKeychainForCorpID("corp_primary") { + t.Fatal("primary profile token should be deleted") + } + if authpkg.TokenDataExistsKeychainForCorpID("corp_secondary") { + t.Fatal("secondary profile token should be deleted") + } + if authpkg.TokenDataExistsKeychain() { + t.Fatal("legacy auth-token mirror should be deleted") + } + appConfig, err := authpkg.LoadAppConfig(configDir) + if err != nil { + t.Fatalf("LoadAppConfig() error = %v", err) + } + if appConfig == nil || appConfig.ClientID != "client-app" { + t.Fatalf("app config after logout = %#v, want preserved client-app", appConfig) } } -func TestAuthLogoutAllKeepsPrimaryAndDeletesNonPrimary(t *testing.T) { +func TestAuthLogoutProfileDeletesOnlySelectedProfile(t *testing.T) { configDir := setupAuthLogoutProfiles(t, authLogoutTestToken("corp_primary"), authLogoutTestToken("corp_secondary"), @@ -320,39 +310,32 @@ func TestAuthLogoutAllKeepsPrimaryAndDeletesNonPrimary(t *testing.T) { var out bytes.Buffer cmd.SetOut(&out) cmd.SetErr(&out) - cmd.SetArgs([]string{"auth", "logout", "--all"}) + cmd.SetArgs([]string{"auth", "logout", "--profile", "corp_primary"}) if err := cmd.Execute(); err != nil { - t.Fatalf("auth logout --all error = %v\noutput:\n%s", err, out.String()) - } - if !strings.Contains(out.String(), "主 profile 已保留") { - t.Fatalf("auth logout --all output should mention retained primary profile:\n%s", out.String()) + t.Fatalf("auth logout --profile corp_primary error = %v\noutput:\n%s", err, out.String()) } - if strings.Contains(out.String(), "重新登录") { - t.Fatalf("auth logout --all output should not ask for re-login while primary remains:\n%s", out.String()) - } - cfg, err := authpkg.LoadProfiles(configDir) if err != nil { t.Fatalf("LoadProfiles() error = %v", err) } - if cfg.PrimaryProfile != "corp_primary" || cfg.CurrentProfile != "corp_primary" { - t.Fatalf("profiles pointers = primary %q current %q, want corp_primary/corp_primary", cfg.PrimaryProfile, cfg.CurrentProfile) + if cfg.PrimaryProfile != "corp_secondary" || cfg.CurrentProfile != "corp_secondary" { + t.Fatalf("profiles pointers = primary %q current %q, want corp_secondary/corp_secondary", cfg.PrimaryProfile, cfg.CurrentProfile) } - if len(cfg.Profiles) != 1 || cfg.Profiles[0].CorpID != "corp_primary" { - t.Fatalf("profiles = %#v, want only corp_primary retained", cfg.Profiles) + if len(cfg.Profiles) != 1 || cfg.Profiles[0].CorpID != "corp_secondary" { + t.Fatalf("profiles = %#v, want only corp_secondary retained", cfg.Profiles) } - if !authpkg.TokenDataExistsKeychainForCorpID("corp_primary") { - t.Fatal("primary profile token should be retained") + if authpkg.TokenDataExistsKeychainForCorpID("corp_primary") { + t.Fatal("selected primary profile token should be deleted") } - if authpkg.TokenDataExistsKeychainForCorpID("corp_secondary") { - t.Fatal("non-primary profile token should be deleted") + if !authpkg.TokenDataExistsKeychainForCorpID("corp_secondary") { + t.Fatal("unselected secondary profile token should be retained") } loaded, err := authpkg.LoadTokenData(configDir) if err != nil { t.Fatalf("LoadTokenData() error = %v", err) } - if loaded.CorpID != "corp_primary" || loaded.AccessToken != "access-corp_primary" { - t.Fatalf("default token = (%q, %q), want retained primary token", loaded.CorpID, loaded.AccessToken) + if loaded.CorpID != "corp_secondary" || loaded.AccessToken != "access-corp_secondary" { + t.Fatalf("default token = (%q, %q), want retained secondary token", loaded.CorpID, loaded.AccessToken) } } diff --git a/prd.json b/prd.json index c2335450..3572a331 100644 --- a/prd.json +++ b/prd.json @@ -143,11 +143,12 @@ "id": "F6", "priority": "P0", "name": "退出与重置", - "description": "dws auth logout 支持按 profile 清理,dws auth logout --all 仅清非主 profile,dws auth reset 清理所有 profile 与 legacy 状态。", + "description": "dws auth logout 默认清理所有组织 profile 的用户登录态;dws auth logout --profile 只清理指定组织;dws auth reset 在 logout 基础上进一步清理本机应用认证配置。", "acceptance": [ - "dws auth logout --profile 删除目标 auth-token: 和 profile 元数据,不影响其他 profile。", - "dws auth logout --all 删除所有非 primaryProfile,保留主 profile。", - "primaryProfile 不应通过 logout 被误删;需要全量清理时使用 auth reset。", + "dws auth logout 不带参数时删除所有 auth-token:、profiles.json、legacy auth-token 和 token.json marker。", + "dws auth logout 不再提供 --all flag。", + "dws auth logout --profile 删除目标 auth-token: 和 profile 元数据,不影响其他 profile;目标可以是 primaryProfile。", + "dws auth logout 清理用户登录态但保留 app config / mcp_url 等本机应用配置。", "auth reset 删除 profiles.json、所有 auth-token:、legacy auth-token、token.json marker 和 app config。" ] }, @@ -216,7 +217,7 @@ "产品草案中的 auth list/--associated/--组织corp ID 若与技术方案冲突,以技术方案命名为准;auth switch 保留为 profile use 的兼容入口,且无参数时必须展示 TUI。", "profiles.json 不得包含 access_token、refresh_token、persistent_code 或 client_secret。", "全局 --profile 不得持久改 currentProfile。", - "primaryProfile 不得被 logout 误删;全量删除只允许 auth reset。", + "auth logout 默认必须清除所有已登录组织;--profile 只能清除指定组织;不得继续暴露 --all。", "real/embedded hook 签名不得为本期多组织改造破坏。" ] } From 786a19b28dd8f546b8e0803f1818a7ef4b521c1a Mon Sep 17 00:00:00 2001 From: "shangguanxuan.sgx" Date: Fri, 26 Jun 2026 15:08:47 +0800 Subject: [PATCH 07/22] =?UTF-8?q?feat(auth):=20login=20=E9=BB=98=E8=AE=A4?= =?UTF-8?q?=E6=96=B0=E5=A2=9E=E7=BB=84=E7=BB=87=E6=8E=88=E6=9D=83?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/ralph/dws-multi-profile-login/analysis.md | 10 +++++----- .../comments/pr-comment.md | 4 ++-- docs/ralph/dws-multi-profile-login/prd.json | 1 + .../review/acceptance-review.md | 4 ++-- .../technical-solution.md | 9 +++++---- internal/app/auth_command.go | 16 ++++++++++------ internal/app/auth_command_test.go | 9 +++++++++ prd.json | 1 + 8 files changed, 35 insertions(+), 19 deletions(-) diff --git a/docs/ralph/dws-multi-profile-login/analysis.md b/docs/ralph/dws-multi-profile-login/analysis.md index d26ae730..73ab3d73 100644 --- a/docs/ralph/dws-multi-profile-login/analysis.md +++ b/docs/ralph/dws-multi-profile-login/analysis.md @@ -19,7 +19,7 @@ 本轮以技术方案为主线。产品稿中提到的 `auth list`、`auth login --associated`、`--组织corp ID` 属于被技术方案替换的命名或交互,不作为当前实现验收口径。`auth switch` 保留为 `profile use` 的产品兼容入口,并在无参数时展示组织选择 TUI。当前 P0 采用: -- `dws auth login --force`:继续登录第二个、第三个组织。 +- `dws auth login`:继续登录第二个、第三个组织;每次执行都进入授权流程以新增/刷新附属组织。 - `dws profile list`:查看已登录组织。 - `dws profile use [name|corpId|-]`:持久切换当前组织;无参数时展示 TUI。 - `dws auth switch [name|corpId|-]`:兼容切换入口;无参数时展示 TUI。 @@ -34,15 +34,15 @@ 已有第一个组织后,继续执行: ```bash -dws auth login --force --format json +dws auth login --format json ``` -浏览器授权页里选择第二个目标组织并授权。登录成功后,CLI 根据返回 token 中的 `corpId` 写入独立 keychain 槽 `auth-token:`,并在 `profiles.json` 中新增 profile。新登录的组织会成为 `currentProfile`,原来的 current 会进入 `previousProfile`。 +浏览器授权页里选择第二个目标组织并授权。登录成功后,CLI 根据返回 token 中的 `corpId` 写入独立 keychain 槽 `auth-token:`,并在 `profiles.json` 中新增 profile。新登录的组织会成为 `currentProfile`,原来的 current 会进入 `previousProfile`。这里不能因为当前 token 有效而直接返回,必须让用户有机会新增附属组织。 如果是 SSH/headless 环境,使用设备码模式: ```bash -dws auth login --device --force --format json +dws auth login --device --format json ``` 登录后检查: @@ -58,7 +58,7 @@ dws profile list --format json 第三个组织不需要新命令,重复第二组织流程: ```bash -dws auth login --force --format json +dws auth login --format json dws profile list --format json ``` diff --git a/docs/ralph/dws-multi-profile-login/comments/pr-comment.md b/docs/ralph/dws-multi-profile-login/comments/pr-comment.md index ab10e348..64f8fe43 100644 --- a/docs/ralph/dws-multi-profile-login/comments/pr-comment.md +++ b/docs/ralph/dws-multi-profile-login/comments/pr-comment.md @@ -4,14 +4,14 @@ ### 关键结论 -- 第二/第三个组织不新增特殊命令,继续执行 `dws auth login --force --format json`,在 OAuth 页选择目标组织。 +- 第二/第三个组织不新增特殊命令,继续执行 `dws auth login --format json`,在 OAuth 页选择目标组织。 - 新 `corpId` 会新增 profile;已存在 `corpId` 只刷新 token 和元数据。 - 查看组织:`dws profile list --format json`。 - 持久切换:`dws profile use --format json` 或 `dws auth switch --format json`。 - 交互切换:`dws auth switch` 或 `dws profile use` 无参数时展示组织选择 TUI。 - 单次命令指定组织:`dws --profile --format json`。 - 登出:`dws auth logout` 默认清理所有组织登录态;`dws auth logout --profile ` 只清指定组织;`--all` 已移除。 -- 产品稿里的 `auth list/--associated/--组织corp ID` 不进入 P0,分别由 `profile list`、重复 `auth login --force` 和全局 `--profile` 替代;`auth switch` 作为 `profile use` 的兼容入口保留。 +- 产品稿里的 `auth list/--associated/--组织corp ID` 不进入 P0,分别由 `profile list`、重复 `auth login` 和全局 `--profile` 替代;`auth switch` 作为 `profile use` 的兼容入口保留。 ### 验证 diff --git a/docs/ralph/dws-multi-profile-login/prd.json b/docs/ralph/dws-multi-profile-login/prd.json index 3572a331..d18610dc 100644 --- a/docs/ralph/dws-multi-profile-login/prd.json +++ b/docs/ralph/dws-multi-profile-login/prd.json @@ -78,6 +78,7 @@ "description": "用户重复执行 dws auth login 时,按登录结果中的 corpId 新增或刷新组织 profile;新组织成为 currentProfile,首次组织成为 primaryProfile。", "acceptance": [ "首次 auth login 创建 profiles.json,primaryProfile=currentProfile=登录 corpId。", + "每次执行 dws auth login 的 OAuth loopback 路径都进入授权流程,不因当前 token 有效而直接返回。", "第二个组织 auth login 新增 profile,不覆盖第一个组织 token。", "同组织重复 auth login 只刷新该 profile 的 token 和元数据,不新增重复 profile。", "登录成功后 token 写入 auth-token:,并同步 legacy auth-token 镜像。" diff --git a/docs/ralph/dws-multi-profile-login/review/acceptance-review.md b/docs/ralph/dws-multi-profile-login/review/acceptance-review.md index a4a3c9d2..4854bb4b 100644 --- a/docs/ralph/dws-multi-profile-login/review/acceptance-review.md +++ b/docs/ralph/dws-multi-profile-login/review/acceptance-review.md @@ -10,7 +10,7 @@ - PRD 已落地:`prd.json` 与 `docs/ralph/dws-multi-profile-login/prd.json` - 顶层命令已落地:`dws profile list/use` -- 第二/第三组织登录路径已落地:重复 `dws auth login --force` +- 第二/第三组织登录路径已落地:重复 `dws auth login`,默认进入授权流程以新增/刷新组织 profile - 单次组织指定已落地:全局 `--profile` - 组织名展示已落地:profile JSON 包含 `corpName`,表格包含 `ORG_NAME` - 技术方案拒绝项已裁决:不实现 `auth list/--associated/--组织corp ID`;`auth switch` 保留为 `profile use` 的兼容入口,无参数展示 TUI @@ -55,7 +55,7 @@ go test ./internal/auth ./internal/app -run 'Test(MultiProfile|RuntimeProfile|De 可以验收。对产品经理侧,当前可交付用户路径是: 1. 首次 `dws auth login` 登录主组织。 -2. 继续 `dws auth login --force` 登录第二/第三组织。 +2. 继续 `dws auth login` 登录第二/第三组织。 3. 用 `dws profile list` 看组织列表。 4. 用 `dws auth switch` 或 `dws profile use` 切默认组织;无参数时弹 TUI。 5. 用 `dws --profile ` 做单次跨组织调度。 diff --git a/docs/ralph/dws-multi-profile-login/technical-solution.md b/docs/ralph/dws-multi-profile-login/technical-solution.md index 1b4bb277..12bed436 100644 --- a/docs/ralph/dws-multi-profile-login/technical-solution.md +++ b/docs/ralph/dws-multi-profile-login/technical-solution.md @@ -19,16 +19,16 @@ dws auth login --format json 继续登录第二个或第三个组织: ```bash -dws auth login --force --format json +dws auth login --format json ``` 设备码模式: ```bash -dws auth login --device --force --format json +dws auth login --device --format json ``` -说明:不新增 `--associated`。OAuth 授权结果中的 `corpId` 是 profile 的唯一组织键。 +说明:不新增 `--associated`。OAuth 授权结果中的 `corpId` 是 profile 的唯一组织键。`dws auth login` 的 OAuth loopback 路径默认进入授权流程,不能因为 current token 有效而直接返回;`--force` 仅兼容保留,不再是新增附属组织的必要参数。 ### 查看 profile @@ -93,6 +93,7 @@ profile 解析优先级: 第二个组织和第三个组织都是“重复登录同一个自然人但选择不同组织”的场景。CLI 不需要知道这是第几个组织,只看 OAuth 返回的 `corpId`: +- 每次 `dws auth login` 都进入授权流程,给用户选择/授权一个组织的机会。 - 新 `corpId`:新增 profile,写入 `auth-token:`,设为 current。 - 已存在 `corpId`:刷新该 profile token 和元数据,不新增重复项。 - 首个 profile:同时成为 primary 和 current。 @@ -104,7 +105,7 @@ profile 解析优先级: - `dws auth list`:替换为 `dws profile list`。 - `dws auth switch`:保留为 `dws profile use` 的兼容入口;无参数时必须展示 TUI。 -- `dws auth login --associated`:替换为重复执行 `dws auth login --force`。 +- `dws auth login --associated`:替换为重复执行 `dws auth login`。 - `--组织corp ID`:替换为全局 `--profile `。 - 自动发现所有所属组织:P1;P0 只展示主动登录过的 profile。 - 内置跨组织聚合 `--all-orgs`:不做;由 agent 编排多次 `--profile` 调用。 diff --git a/internal/app/auth_command.go b/internal/app/auth_command.go index f471c6db..e0a41929 100644 --- a/internal/app/auth_command.go +++ b/internal/app/auth_command.go @@ -123,10 +123,10 @@ func newAuthLoginCommand(patCaller edition.ToolCaller) *cobra.Command { 否则 OAuth 回调会跳到本机不可达的 127.0.0.1 链接,授权完成后无法回写 token。 示例: - dws auth login # 本机登录后选择推荐/全部权限与授权业务域 + dws auth login # 本机登录并新增/刷新一个组织 profile dws auth login --recommend # 无交互批量授权服务端推荐权限 dws auth login --device # SSH 远程 / 无头环境登录 (设备流) - dws auth login --force # 强制重新登录 (忽略缓存 token) + dws auth login --force # 兼容保留;login 默认已忽略缓存并进入授权流程 dws auth login --token xxx # 使用指定 token`, DisableAutoGenTag: true, RunE: func(cmd *cobra.Command, args []string) error { @@ -170,7 +170,7 @@ func newAuthLoginCommand(patCaller edition.ToolCaller) *cobra.Command { provider.NoBrowser, _ = cmd.Flags().GetBool("no-browser") provider.TargetCorpID = cfg.TargetCorpID configureOAuthProviderCompatibility(provider, configDir) - tokenData, err = provider.Login(loginCtx, cfg.Force) + tokenData, err = provider.Login(loginCtx, authLoginForcesAuthorization(cfg)) if err != nil { return apperrors.NewAuth(fmt.Sprintf("dingtalk login failed: %v", err)) } @@ -237,7 +237,7 @@ func newAuthLoginCommand(patCaller edition.ToolCaller) *cobra.Command { if err := runPostLoginAuthorization(); err != nil { return err } - return writeAuthLoginJSON(w, tokenData, cfg.Force) + return writeAuthLoginJSON(w, tokenData, authLoginForcesAuthorization(cfg)) } // Default table output @@ -245,7 +245,7 @@ func newAuthLoginCommand(patCaller edition.ToolCaller) *cobra.Command { return err } fmt.Fprintln(w) - if !cfg.Device && tokenData != nil && tokenData.IsAccessTokenValid() && !cfg.Force { + if !cfg.Device && tokenData != nil && tokenData.IsAccessTokenValid() && !authLoginForcesAuthorization(cfg) { fmt.Fprintln(w, authLoginStatusLine("Token 有效,无需重新登录")) } else { fmt.Fprintln(w, authLoginStatusLine("登录成功!")) @@ -270,7 +270,7 @@ func newAuthLoginCommand(patCaller edition.ToolCaller) *cobra.Command { } 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("force", false, "兼容保留;login 默认已忽略缓存并进入授权流程") cmd.Flags().Bool("recommend", false, "登录成功后无交互批量授权服务端推荐权限") // Hidden compatibility flags cmd.Flags().String("redirect-url", "", "Loopback redirect URL") @@ -1070,6 +1070,10 @@ func resolveAuthLoginConfig(cmd *cobra.Command) (authLoginConfig, error) { }, nil } +func authLoginForcesAuthorization(_ authLoginConfig) bool { + return true +} + func resolveAuthLoginTargetCorpID(configDir, selector string) (string, error) { selector = strings.TrimSpace(selector) if selector == "" { diff --git a/internal/app/auth_command_test.go b/internal/app/auth_command_test.go index f66c7e06..74db1272 100644 --- a/internal/app/auth_command_test.go +++ b/internal/app/auth_command_test.go @@ -452,6 +452,15 @@ func TestResolveAuthLoginConfigReadsInheritedYes(t *testing.T) { } } +func TestAuthLoginForcesAuthorizationByDefault(t *testing.T) { + if !authLoginForcesAuthorization(authLoginConfig{}) { + t.Fatal("auth login should force authorization by default so each login can add an organization profile") + } + if !authLoginForcesAuthorization(authLoginConfig{Force: false}) { + t.Fatal("Force=false should still force authorization") + } +} + func TestAuthLoginRecommendSkipsPostLoginTUI(t *testing.T) { t.Setenv(keychain.DisableKeychainEnv, "1") t.Setenv(keychain.StorageDirEnv, t.TempDir()) diff --git a/prd.json b/prd.json index 3572a331..d18610dc 100644 --- a/prd.json +++ b/prd.json @@ -78,6 +78,7 @@ "description": "用户重复执行 dws auth login 时,按登录结果中的 corpId 新增或刷新组织 profile;新组织成为 currentProfile,首次组织成为 primaryProfile。", "acceptance": [ "首次 auth login 创建 profiles.json,primaryProfile=currentProfile=登录 corpId。", + "每次执行 dws auth login 的 OAuth loopback 路径都进入授权流程,不因当前 token 有效而直接返回。", "第二个组织 auth login 新增 profile,不覆盖第一个组织 token。", "同组织重复 auth login 只刷新该 profile 的 token 和元数据,不新增重复 profile。", "登录成功后 token 写入 auth-token:,并同步 legacy auth-token 镜像。" From f7ccfe2fdc0ffd6877d78f83083260808ae46d0d Mon Sep 17 00:00:00 2001 From: "shangguanxuan.sgx" Date: Fri, 26 Jun 2026 15:37:57 +0800 Subject: [PATCH 08/22] =?UTF-8?q?feat(profile):=20=E4=BD=BF=E7=94=A8=20pro?= =?UTF-8?q?file=20switch=20=E5=88=87=E6=8D=A2=E7=BB=84=E7=BB=87?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../ralph/dws-multi-profile-login/analysis.md | 26 +++++++----- .../comments/pr-comment.md | 13 +++--- docs/ralph/dws-multi-profile-login/prd.json | 17 ++++---- .../review/acceptance-review.md | 14 +++---- .../technical-solution.md | 16 ++++---- internal/app/auth_command.go | 13 ------ internal/app/profile_command.go | 16 +++++++- internal/app/profile_command_test.go | 41 +++++++++++++++---- prd.json | 17 ++++---- 9 files changed, 98 insertions(+), 75 deletions(-) diff --git a/docs/ralph/dws-multi-profile-login/analysis.md b/docs/ralph/dws-multi-profile-login/analysis.md index 73ab3d73..19cb0cd2 100644 --- a/docs/ralph/dws-multi-profile-login/analysis.md +++ b/docs/ralph/dws-multi-profile-login/analysis.md @@ -17,12 +17,12 @@ ## 决策结论 -本轮以技术方案为主线。产品稿中提到的 `auth list`、`auth login --associated`、`--组织corp ID` 属于被技术方案替换的命名或交互,不作为当前实现验收口径。`auth switch` 保留为 `profile use` 的产品兼容入口,并在无参数时展示组织选择 TUI。当前 P0 采用: +本轮以技术方案为主线。产品稿中提到的 `auth list`、`auth login --associated`、`--组织corp ID`、`auth switch` 属于被技术方案替换的命名或交互,不作为当前实现验收口径。正式切换入口收敛为 `dws profile switch`,并在无参数时展示组织选择 TUI。当前 P0 采用: - `dws auth login`:继续登录第二个、第三个组织;每次执行都进入授权流程以新增/刷新附属组织。 - `dws profile list`:查看已登录组织。 -- `dws profile use [name|corpId|-]`:持久切换当前组织;无参数时展示 TUI。 -- `dws auth switch [name|corpId|-]`:兼容切换入口;无参数时展示 TUI。 +- `dws profile switch [name|corpId|-]`:持久切换当前组织;无参数时展示 TUI。 +- `dws profile use [name|corpId|-]`:兼容旧技术方案中的 profile use 习惯,正式文档和用户引导使用 `profile switch`。 - `dws --profile `:单次命令临时指定组织。 这个拆分符合“auth 管凭证、profile 管组织上下文”的边界,也让终端调度 dws 时能在顶层直接命中 profile 管理能力。 @@ -69,22 +69,23 @@ dws profile list --format json 持久切换默认组织: ```bash -dws profile use --format json -dws auth switch --format json +dws profile switch --format json ``` +其中 `` 可以是主组织对应的 profile 名或 corpId;切回主组织不需要特殊命令。 + 交互选择组织: ```bash -dws auth switch -dws profile use +dws profile switch ``` +TUI 列表会展示主组织、当前组织和所有已登录附属组织;选中主组织即可把主组织重新设为 currentProfile。 + 切回上一个组织: ```bash -dws profile use - --format json -dws auth switch - --format json +dws profile switch - --format json ``` 单次命令指定组织,不改变默认 current: @@ -99,7 +100,8 @@ dws --profile --format json - 多槽 profile 元数据与 current/primary/previous 指针:`internal/auth/profiles.go` - token 按组织独立存储,legacy `auth-token` 镜像兼容旧逻辑:`internal/auth/token.go` -- 顶层 `dws profile list/use` 与 `dws auth switch` 兼容入口:`internal/app/profile_command.go`、`internal/app/auth_command.go` +- 顶层 `dws profile list/switch`:`internal/app/profile_command.go` +- `auth` 命令组保留认证动作,不暴露 `auth switch`:`internal/app/auth_command.go` - 全局 `--profile` 预解析与运行时注入:`internal/app/root.go` - PRD 与验收口径:`prd.json` @@ -114,6 +116,8 @@ dws --profile --format json 飞书 CLI 的 `auth logout` 文档语义是“Sign out and remove stored credentials”,源码实现会遍历当前 app config 下的 `Users`,逐个 revoke/remove token,然后把 `app.Users` 置空并保存配置。也就是说,飞书的 logout 清的是当前 app/profile 认证上下文下的所有已登录用户 token,而不是只清一个“当前用户”。 +飞书 CLI 的持久 profile 切换放在 `profile use `,并支持 `profile use -` 切回上一个 profile;`auth` 命令组包含 login/logout/status/list/check 等认证相关命令,不提供 `auth switch`。DWS 采用同样的边界:auth 只管凭证,组织上下文切换放在 profile 下;命令名按产品口径使用 `dws profile switch`。 + dws 的多组织模型不是飞书的多用户列表,而是同一自然人下多个组织 profile。为了让用户输入 `dws auth logout` 时得到“我已经彻底退出 dws 用户授权”的结果,dws 应采用: - `dws auth logout`:默认清理所有组织 profile 的用户登录态。 @@ -126,7 +130,7 @@ dws 的多组织模型不是飞书的多用户列表,而是同一自然人下 聚焦多组织能力的单元测试已通过: ```bash -go test ./internal/auth ./internal/app -run 'Test(MultiProfile|RuntimeProfile|DeleteProfile|UpsertProfile|LoadProfiles|LegacyKeychain|WriteProfile|ProfileList|ProfileUse|ProfileSwitch|AuthSwitch|AuthStatus|AuthLogout|AuthLogin|ResolveAuthLogin|EnrichAuthLogin|RootHelp|RootShortHelp|RootCommand)' +go test ./internal/auth ./internal/app -run 'Test(MultiProfile|RuntimeProfile|DeleteProfile|UpsertProfile|LoadProfiles|LegacyKeychain|WriteProfile|ProfileList|ProfileUse|ProfileSwitch|AuthCommandDoesNotExposeSwitch|AuthStatus|AuthLogout|AuthLogin|ResolveAuthLogin|EnrichAuthLogin|RootHelp|RootShortHelp|RootCommand)' ``` 全量 `go test ./internal/auth ./internal/app` 中 `internal/auth` 通过,但 `internal/app` 被升级模块用例 `TestValidateNewBinary_RecoversFromUnsignedDarwin` 阻塞,错误是测试二进制执行时被 macOS kill。该失败发生在 upgrade 验签/回滚路径,不在多组织登录代码改动面内,需作为独立 CI/本机签名环境问题跟进。 diff --git a/docs/ralph/dws-multi-profile-login/comments/pr-comment.md b/docs/ralph/dws-multi-profile-login/comments/pr-comment.md index 64f8fe43..23264524 100644 --- a/docs/ralph/dws-multi-profile-login/comments/pr-comment.md +++ b/docs/ralph/dws-multi-profile-login/comments/pr-comment.md @@ -7,24 +7,21 @@ - 第二/第三个组织不新增特殊命令,继续执行 `dws auth login --format json`,在 OAuth 页选择目标组织。 - 新 `corpId` 会新增 profile;已存在 `corpId` 只刷新 token 和元数据。 - 查看组织:`dws profile list --format json`。 -- 持久切换:`dws profile use --format json` 或 `dws auth switch --format json`。 -- 交互切换:`dws auth switch` 或 `dws profile use` 无参数时展示组织选择 TUI。 +- 持久切换:`dws profile switch --format json`;目标可以是主组织,选中主组织即可切回。 +- 交互切换:`dws profile switch` 无参数时展示组织选择 TUI,列表包含主组织、当前组织和已登录附属组织。 - 单次命令指定组织:`dws --profile --format json`。 - 登出:`dws auth logout` 默认清理所有组织登录态;`dws auth logout --profile ` 只清指定组织;`--all` 已移除。 -- 产品稿里的 `auth list/--associated/--组织corp ID` 不进入 P0,分别由 `profile list`、重复 `auth login` 和全局 `--profile` 替代;`auth switch` 作为 `profile use` 的兼容入口保留。 +- 产品稿里的 `auth list/--associated/--组织corp ID/auth switch` 不进入 P0,分别由 `profile list`、重复 `auth login`、全局 `--profile` 和 `profile switch` 替代;`auth` 命令组不暴露 switch。 ### 验证 ```bash -go test ./internal/auth ./internal/app -run 'Test(MultiProfile|RuntimeProfile|DeleteProfile|UpsertProfile|LoadProfiles|LegacyKeychain|WriteProfile|ProfileList|ProfileUse|ProfileSwitch|AuthSwitch|AuthStatus|AuthLogout|AuthLogin|ResolveAuthLogin|EnrichAuthLogin|RootHelp|RootShortHelp|RootCommand)' +go test ./internal/auth ./internal/app -run 'Test(MultiProfile|RuntimeProfile|DeleteProfile|UpsertProfile|LoadProfiles|LegacyKeychain|WriteProfile|ProfileList|ProfileUse|ProfileSwitch|AuthCommandDoesNotExposeSwitch|AuthStatus|AuthLogout|AuthLogin|ResolveAuthLogin|EnrichAuthLogin|RootHelp|RootShortHelp|RootCommand)' ``` 结果:`internal/auth` 与多组织相关 `internal/app` 用例通过。 -另已验证本地打包安装: - -- `dws version`:`v1.0.41-SNAPSHOT`,commit `756c7d1` -- `dws profile list --format json`:本机已有两个 profile,一个 primary,一个 current +另已验证本地打包安装,本机 `dws` 已指向本 PR 最新构建。 ### 残余说明 diff --git a/docs/ralph/dws-multi-profile-login/prd.json b/docs/ralph/dws-multi-profile-login/prd.json index d18610dc..ec384ef5 100644 --- a/docs/ralph/dws-multi-profile-login/prd.json +++ b/docs/ralph/dws-multi-profile-login/prd.json @@ -51,7 +51,7 @@ "technicalDecisions": [ { "topic": "command_naming", - "decision": "采用技术方案命名:auth 管凭证,profile 管组织身份选择;主命令使用 dws profile list/use 和全局 --profile,同时保留 dws auth switch 作为产品兼容入口并在无参数时展示组织选择 TUI。", + "decision": "采用技术方案命名:auth 管凭证,profile 管组织身份选择;主命令使用 dws profile list/switch 和全局 --profile;不在 auth 命令组下提供 dws auth switch。", "reason": "与飞书 CLI 的 auth/profile 分层一致,避免把凭证动作和上下文选择混在同一命令组。" }, { @@ -101,14 +101,15 @@ "id": "F3", "priority": "P0", "name": "切换当前组织", - "description": "dws 顶层提供 dws profile use [name|corpId|-],并提供 dws auth switch [name|corpId|-] 兼容入口,用于持久切换默认组织上下文或切回上一个组织。", + "description": "dws 顶层提供 dws profile switch [name|corpId|-],用于持久切换默认组织上下文或切回上一个组织;无参数时展示组织选择 TUI。", "acceptance": [ - "dws profile use 将 currentProfile 更新为目标 corpId。", - "dws auth switch 等价于 dws profile use ,用于兼容产品方案和用户心智。", - "dws auth switch 或 dws profile use 在交互终端且无参数时展示组织选择 TUI,用户可用方向键选择并回车确认。", + "dws profile switch 将 currentProfile 更新为目标 corpId,目标可以是 primaryProfile 对应的主组织。", + "dws profile switch 在交互终端且无参数时展示组织选择 TUI,列表包含主组织、当前组织和所有已登录附属组织,用户可用方向键选择并回车确认。", + "dws profile use 作为兼容命令继续复用 profile switch 语义,但正式文档和用户引导使用 dws profile switch。", + "dws auth switch 不在 auth 命令下暴露,避免把凭证动作和组织上下文切换混在一起。", "非交互环境无参数执行时返回 validation error,并提示显式传入 profile 名或 corpId。", "切换时 previousProfile 记录切换前的 currentProfile。", - "dws profile use - 使用 previousProfile 切回,并在 current/previous 间 toggle。", + "dws profile switch - 使用 previousProfile 切回,并在 current/previous 间 toggle。", "切换成功后重置进程内 token/runtime 缓存,后续无 --profile 命令默认使用新的 currentProfile。", "切换后 legacy auth-token 镜像同步为当前 profile token。", "切换成功的人类可读输出必须包含当前组织名和 corpId,便于终端 agent 确认已切到正确组织。", @@ -214,8 +215,8 @@ ], "reviewChecklist": [ "切换组织能力必须在 dws 顶层出现,不接受只在内部函数可用。", - "profile list/use 与 auth status 的人类可读输出必须显式包含组织名,不接受只展示 profile name 或 corpId。", - "产品草案中的 auth list/--associated/--组织corp ID 若与技术方案冲突,以技术方案命名为准;auth switch 保留为 profile use 的兼容入口,且无参数时必须展示 TUI。", + "profile list/switch 与 auth status 的人类可读输出必须显式包含组织名,不接受只展示 profile name 或 corpId。", + "产品草案中的 auth list/--associated/--组织corp ID/auth switch 若与技术方案冲突,以技术方案命名为准;切换组织不得放在 auth 命令组下。", "profiles.json 不得包含 access_token、refresh_token、persistent_code 或 client_secret。", "全局 --profile 不得持久改 currentProfile。", "auth logout 默认必须清除所有已登录组织;--profile 只能清除指定组织;不得继续暴露 --all。", diff --git a/docs/ralph/dws-multi-profile-login/review/acceptance-review.md b/docs/ralph/dws-multi-profile-login/review/acceptance-review.md index 4854bb4b..a2a507b5 100644 --- a/docs/ralph/dws-multi-profile-login/review/acceptance-review.md +++ b/docs/ralph/dws-multi-profile-login/review/acceptance-review.md @@ -9,18 +9,18 @@ ## 需求对齐 - PRD 已落地:`prd.json` 与 `docs/ralph/dws-multi-profile-login/prd.json` -- 顶层命令已落地:`dws profile list/use` +- 顶层命令已落地:`dws profile list/switch` - 第二/第三组织登录路径已落地:重复 `dws auth login`,默认进入授权流程以新增/刷新组织 profile - 单次组织指定已落地:全局 `--profile` - 组织名展示已落地:profile JSON 包含 `corpName`,表格包含 `ORG_NAME` -- 技术方案拒绝项已裁决:不实现 `auth list/--associated/--组织corp ID`;`auth switch` 保留为 `profile use` 的兼容入口,无参数展示 TUI +- 技术方案拒绝项已裁决:不实现 `auth list/--associated/--组织corp ID/auth switch`;切换组织统一使用 `dws profile switch`,无参数展示 TUI ## 代码证据 - `internal/auth/profiles.go`:维护 `primaryProfile`、`currentProfile`、`previousProfile`,按 `corpId` upsert profile。 - `internal/auth/token.go`:token 写入 `auth-token:`,并同步 legacy `auth-token`。 -- `internal/app/profile_command.go`:实现 `profile list`、`profile use [name|corpId|-]`,无参数展示 TUI,输出组织名和 corpId。 -- `internal/app/auth_command.go`:实现 `auth switch [name|corpId|-]` 兼容入口,复用 profile 切换逻辑。 +- `internal/app/profile_command.go`:实现 `profile list`、`profile switch [name|corpId|-]`,无参数展示 TUI,输出组织名和 corpId;`profile use` 保留为兼容别名。 +- `internal/app/auth_command.go`:保留登录、登出、状态、导入导出等认证动作,不暴露 `auth switch`。 - `internal/app/root.go`:注册顶层 `profile` 命令,并在运行时预解析/注入全局 `--profile`。 - `internal/app/auth_command.go`:auth status/logout/reset 对 profile 语义做了补齐;`auth logout` 默认清理所有组织,`--profile` 只清指定组织。 @@ -29,7 +29,7 @@ 已通过: ```bash -go test ./internal/auth ./internal/app -run 'Test(MultiProfile|RuntimeProfile|DeleteProfile|UpsertProfile|LoadProfiles|LegacyKeychain|WriteProfile|ProfileList|ProfileUse|ProfileSwitch|AuthSwitch|AuthStatus|AuthLogout|AuthLogin|ResolveAuthLogin|EnrichAuthLogin|RootHelp|RootShortHelp|RootCommand)' +go test ./internal/auth ./internal/app -run 'Test(MultiProfile|RuntimeProfile|DeleteProfile|UpsertProfile|LoadProfiles|LegacyKeychain|WriteProfile|ProfileList|ProfileUse|ProfileSwitch|AuthCommandDoesNotExposeSwitch|AuthStatus|AuthLogout|AuthLogin|ResolveAuthLogin|EnrichAuthLogin|RootHelp|RootShortHelp|RootCommand)' ``` 结果: @@ -39,7 +39,7 @@ go test ./internal/auth ./internal/app -run 'Test(MultiProfile|RuntimeProfile|De 本机 dws 验证: -- `dws version`:`v1.0.41-SNAPSHOT`,commit `756c7d1` +- `dws version`:本机 `dws` 已按 PR 最新构建安装,版本为 `v1.0.41-SNAPSHOT` - `dws profile list --format json`:当前本机存在两个 profile,一个 primary,一个 current,说明第二组织登录已进入多槽 profile 体系。 ## 未阻塞但需记录的风险 @@ -57,7 +57,7 @@ go test ./internal/auth ./internal/app -run 'Test(MultiProfile|RuntimeProfile|De 1. 首次 `dws auth login` 登录主组织。 2. 继续 `dws auth login` 登录第二/第三组织。 3. 用 `dws profile list` 看组织列表。 -4. 用 `dws auth switch` 或 `dws profile use` 切默认组织;无参数时弹 TUI。 +4. 用 `dws profile switch` 切默认组织;无参数时弹 TUI,也可选择主组织切回。 5. 用 `dws --profile ` 做单次跨组织调度。 这条路径覆盖了“多组织登录、切换、终端可调度、组织名可见”的核心需求。 diff --git a/docs/ralph/dws-multi-profile-login/technical-solution.md b/docs/ralph/dws-multi-profile-login/technical-solution.md index 12bed436..fa682a47 100644 --- a/docs/ralph/dws-multi-profile-login/technical-solution.md +++ b/docs/ralph/dws-multi-profile-login/technical-solution.md @@ -42,13 +42,11 @@ JSON 输出包含 `primaryProfile`、`currentProfile`、`previousProfile` 和 `p ### 切换 profile ```bash -dws profile use [name-or-corpId] --format json -dws auth switch [name-or-corpId] --format json -dws profile use - --format json -dws auth switch - --format json +dws profile switch [name-or-corpId] --format json +dws profile switch - --format json ``` -`profile use ` 持久切换默认 current;`auth switch ` 是产品兼容入口,语义等价。`profile use -` 和 `auth switch -` 在 current 和 previous 间 toggle。无参数执行 `dws auth switch` 或 `dws profile use` 时,在交互终端展示组织选择 TUI;非交互环境要求显式传入 profile 名或 corpId。切换成功后同步 legacy `auth-token` 镜像,并清理进程内 token/runtime cache。 +`profile switch ` 持久切换默认 current;目标可以是 primaryProfile 对应的主组织。`profile switch -` 在 current 和 previous 间 toggle。无参数执行 `dws profile switch` 时,在交互终端展示组织选择 TUI,列表包含主组织、当前组织和所有已登录附属组织;非交互环境要求显式传入 profile 名或 corpId。切换成功后同步 legacy `auth-token` 镜像,并清理进程内 token/runtime cache。`profile use` 作为兼容别名保留,正式用户引导使用 `profile switch`。 ### 单次命令覆盖 @@ -66,7 +64,7 @@ dws --profile --format json - `primaryProfile`:首次成功登录的组织,用于默认 fallback 和标记;`auth logout` 默认全登出时会一并清除。 - `currentProfile`:默认命令上下文。 -- `previousProfile`:上一个 current,用于 `profile use -` 或 `auth switch -`。 +- `previousProfile`:上一个 current,用于 `profile switch -`。 - `profiles[]`:按 `corpId` 维护 profile 元数据。 不得在 `profiles.json` 中保存 access token、refresh token、persistent code 或 client secret。 @@ -104,7 +102,7 @@ profile 解析优先级: 以下产品稿能力不进入 P0 技术实现: - `dws auth list`:替换为 `dws profile list`。 -- `dws auth switch`:保留为 `dws profile use` 的兼容入口;无参数时必须展示 TUI。 +- `dws auth switch`:不提供;组织上下文切换统一使用 `dws profile switch`。 - `dws auth login --associated`:替换为重复执行 `dws auth login`。 - `--组织corp ID`:替换为全局 `--profile `。 - 自动发现所有所属组织:P1;P0 只展示主动登录过的 profile。 @@ -116,7 +114,7 @@ profile 解析优先级: - 第二/第三组织登录不会覆盖已有组织 token。 - 同组织重复登录只刷新,不重复新增。 - `dws profile list` 顶层可见,JSON 和表格都展示组织名。 -- `dws profile use` 与 `dws auth switch` 可按 name/corpId 切换,可用 `-` 切回 previous,无参数时展示 TUI。 +- `dws profile switch` 可按 name/corpId 切换,可用 `-` 切回 previous,无参数时展示 TUI,且可选回主组织。 - `--profile` 可一次性指定组织,且不改变 current。 - `auth logout` 默认清理所有组织登录态;`auth logout --profile ` 只清指定组织;`auth reset` 额外清 app config 等本机认证配置。 - legacy 单槽可迁移,current token 可镜像到 legacy 槽。 @@ -124,7 +122,7 @@ profile 解析优先级: ## 验证命令 ```bash -go test ./internal/auth ./internal/app -run 'Test(MultiProfile|RuntimeProfile|DeleteProfile|UpsertProfile|LoadProfiles|LegacyKeychain|WriteProfile|ProfileList|ProfileUse|ProfileSwitch|AuthSwitch|AuthStatus|AuthLogout|AuthLogin|ResolveAuthLogin|EnrichAuthLogin|RootHelp|RootShortHelp|RootCommand)' +go test ./internal/auth ./internal/app -run 'Test(MultiProfile|RuntimeProfile|DeleteProfile|UpsertProfile|LoadProfiles|LegacyKeychain|WriteProfile|ProfileList|ProfileUse|ProfileSwitch|AuthCommandDoesNotExposeSwitch|AuthStatus|AuthLogout|AuthLogin|ResolveAuthLogin|EnrichAuthLogin|RootHelp|RootShortHelp|RootCommand)' dws version dws profile list --format json ``` diff --git a/internal/app/auth_command.go b/internal/app/auth_command.go index e0a41929..c0a75e4f 100644 --- a/internal/app/auth_command.go +++ b/internal/app/auth_command.go @@ -82,7 +82,6 @@ func buildAuthCommand(patCaller edition.ToolCaller) *cobra.Command { cmd.AddCommand( newAuthLogoutCommand(), newAuthStatusCommand(), - newAuthSwitchCommand(), newAuthExportCommand(), newAuthImportCommand(), newAuthExchangeCommand(), @@ -91,18 +90,6 @@ func buildAuthCommand(patCaller edition.ToolCaller) *cobra.Command { return cmd } -func newAuthSwitchCommand() *cobra.Command { - return &cobra.Command{ - Use: "switch [name|corpId|-]", - Short: "切换当前组织 profile", - Args: cobra.MaximumNArgs(1), - DisableAutoGenTag: true, - RunE: func(cmd *cobra.Command, args []string) error { - return runProfileSwitchCommand(cmd, args) - }, - } -} - func newAuthLoginCommand(patCaller edition.ToolCaller) *cobra.Command { cmd := &cobra.Command{ Use: "login", diff --git a/internal/app/profile_command.go b/internal/app/profile_command.go index 5a32b124..d2dd32a9 100644 --- a/internal/app/profile_command.go +++ b/internal/app/profile_command.go @@ -36,7 +36,7 @@ func newProfileCommand() *cobra.Command { return cmd.Help() }, } - cmd.AddCommand(newProfileListCommand(), newProfileUseCommand()) + cmd.AddCommand(newProfileListCommand(), newProfileSwitchCommand(), newProfileUseCommand()) return cmd } @@ -69,6 +69,18 @@ func newProfileListCommand() *cobra.Command { func newProfileUseCommand() *cobra.Command { return &cobra.Command{ Use: "use [name|corpId|-]", + Short: "切换当前组织 profile(兼容 profile switch)", + Args: cobra.MaximumNArgs(1), + DisableAutoGenTag: true, + RunE: func(cmd *cobra.Command, args []string) error { + return runProfileSwitchCommand(cmd, args) + }, + } +} + +func newProfileSwitchCommand() *cobra.Command { + return &cobra.Command{ + Use: "switch [name|corpId|-]", Short: "切换当前组织 profile", Args: cobra.MaximumNArgs(1), DisableAutoGenTag: true, @@ -130,7 +142,7 @@ func switchProfileAndWrite(cmd *cobra.Command, configDir, selector string, usedT func selectProfileSwitchProfile(cmd *cobra.Command, configDir string) (string, error) { if !profileSwitchInteractiveTerminal() { - return "", apperrors.NewValidation("profile selector required in non-interactive mode; use dws auth switch or dws profile use ") + return "", apperrors.NewValidation("profile selector required in non-interactive mode; use dws profile switch ") } if err := authpkg.EnsureProfilesMigration(configDir); err != nil { return "", apperrors.NewInternal(fmt.Sprintf("failed to migrate profiles: %v", err)) diff --git a/internal/app/profile_command_test.go b/internal/app/profile_command_test.go index 07d46fd9..78c04768 100644 --- a/internal/app/profile_command_test.go +++ b/internal/app/profile_command_test.go @@ -16,6 +16,7 @@ package app import ( "bytes" "encoding/json" + "strings" "testing" authpkg "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/auth" @@ -142,7 +143,7 @@ func TestProfileUseRootCommandSwitchesOrganizationAndLegacyMirror(t *testing.T) } } -func TestAuthSwitchRootCommandSwitchesOrganization(t *testing.T) { +func TestProfileSwitchRootCommandSwitchesPrimaryOrganizationAndLegacyMirror(t *testing.T) { configDir := setupAuthLogoutProfiles(t, authLogoutTestToken("corp_primary"), authLogoutTestToken("corp_secondary"), @@ -152,12 +153,12 @@ func TestAuthSwitchRootCommandSwitchesOrganization(t *testing.T) { var out bytes.Buffer cmd.SetOut(&out) cmd.SetErr(&out) - cmd.SetArgs([]string{"--format", "table", "auth", "switch", "corp_primary"}) + cmd.SetArgs([]string{"--format", "table", "profile", "switch", "corp_primary"}) if err := cmd.Execute(); err != nil { - t.Fatalf("auth switch corp_primary error = %v\noutput:\n%s", err, out.String()) + t.Fatalf("profile switch corp_primary error = %v\noutput:\n%s", err, out.String()) } if !bytes.Contains(out.Bytes(), []byte("组织: corp_primary org")) { - t.Fatalf("auth switch output should include organization name:\n%s", out.String()) + t.Fatalf("profile switch output should include organization name:\n%s", out.String()) } cfg, err := authpkg.LoadProfiles(configDir) if err != nil { @@ -166,9 +167,16 @@ func TestAuthSwitchRootCommandSwitchesOrganization(t *testing.T) { if cfg.CurrentProfile != "corp_primary" || cfg.PreviousProfile != "corp_secondary" { t.Fatalf("profile pointers = current %q previous %q, want corp_primary/corp_secondary", cfg.CurrentProfile, cfg.PreviousProfile) } + legacyToken, err := authpkg.LoadTokenData(configDir) + if err != nil { + t.Fatalf("LoadTokenData() error = %v", err) + } + if legacyToken.CorpID != "corp_primary" { + t.Fatalf("legacy token corp = %q, want corp_primary", legacyToken.CorpID) + } } -func TestAuthSwitchNoArgsUsesTUISelector(t *testing.T) { +func TestProfileSwitchNoArgsUsesTUISelector(t *testing.T) { configDir := setupAuthLogoutProfiles(t, authLogoutTestToken("corp_primary"), authLogoutTestToken("corp_secondary"), @@ -190,15 +198,15 @@ func TestAuthSwitchNoArgsUsesTUISelector(t *testing.T) { var out bytes.Buffer cmd.SetOut(&out) cmd.SetErr(&out) - cmd.SetArgs([]string{"auth", "switch"}) + cmd.SetArgs([]string{"profile", "switch"}) if err := cmd.Execute(); err != nil { - t.Fatalf("auth switch error = %v\noutput:\n%s", err, out.String()) + t.Fatalf("profile switch error = %v\noutput:\n%s", err, out.String()) } if !called { - t.Fatal("auth switch without args did not invoke TUI selector") + t.Fatal("profile switch without args did not invoke TUI selector") } if !bytes.Contains(out.Bytes(), []byte("组织: corp_primary org")) { - t.Fatalf("auth switch TUI path should use human output by default:\n%s", out.String()) + t.Fatalf("profile switch TUI path should use human output by default:\n%s", out.String()) } cfg, err := authpkg.LoadProfiles(configDir) if err != nil { @@ -209,6 +217,21 @@ func TestAuthSwitchNoArgsUsesTUISelector(t *testing.T) { } } +func TestAuthCommandDoesNotExposeSwitch(t *testing.T) { + cmd := NewRootCommand() + var out bytes.Buffer + cmd.SetOut(&out) + cmd.SetErr(&out) + cmd.SetArgs([]string{"auth", "switch"}) + err := cmd.Execute() + if err == nil { + t.Fatalf("auth switch succeeded, want unknown command error\noutput:\n%s", out.String()) + } + if !strings.Contains(err.Error(), `unknown command "switch" for "dws auth"`) { + t.Fatalf("error = %v, want auth switch unknown command", err) + } +} + func TestProfileUseNoArgsUsesTUISelector(t *testing.T) { configDir := setupAuthLogoutProfiles(t, authLogoutTestToken("corp_primary"), diff --git a/prd.json b/prd.json index d18610dc..ec384ef5 100644 --- a/prd.json +++ b/prd.json @@ -51,7 +51,7 @@ "technicalDecisions": [ { "topic": "command_naming", - "decision": "采用技术方案命名:auth 管凭证,profile 管组织身份选择;主命令使用 dws profile list/use 和全局 --profile,同时保留 dws auth switch 作为产品兼容入口并在无参数时展示组织选择 TUI。", + "decision": "采用技术方案命名:auth 管凭证,profile 管组织身份选择;主命令使用 dws profile list/switch 和全局 --profile;不在 auth 命令组下提供 dws auth switch。", "reason": "与飞书 CLI 的 auth/profile 分层一致,避免把凭证动作和上下文选择混在同一命令组。" }, { @@ -101,14 +101,15 @@ "id": "F3", "priority": "P0", "name": "切换当前组织", - "description": "dws 顶层提供 dws profile use [name|corpId|-],并提供 dws auth switch [name|corpId|-] 兼容入口,用于持久切换默认组织上下文或切回上一个组织。", + "description": "dws 顶层提供 dws profile switch [name|corpId|-],用于持久切换默认组织上下文或切回上一个组织;无参数时展示组织选择 TUI。", "acceptance": [ - "dws profile use 将 currentProfile 更新为目标 corpId。", - "dws auth switch 等价于 dws profile use ,用于兼容产品方案和用户心智。", - "dws auth switch 或 dws profile use 在交互终端且无参数时展示组织选择 TUI,用户可用方向键选择并回车确认。", + "dws profile switch 将 currentProfile 更新为目标 corpId,目标可以是 primaryProfile 对应的主组织。", + "dws profile switch 在交互终端且无参数时展示组织选择 TUI,列表包含主组织、当前组织和所有已登录附属组织,用户可用方向键选择并回车确认。", + "dws profile use 作为兼容命令继续复用 profile switch 语义,但正式文档和用户引导使用 dws profile switch。", + "dws auth switch 不在 auth 命令下暴露,避免把凭证动作和组织上下文切换混在一起。", "非交互环境无参数执行时返回 validation error,并提示显式传入 profile 名或 corpId。", "切换时 previousProfile 记录切换前的 currentProfile。", - "dws profile use - 使用 previousProfile 切回,并在 current/previous 间 toggle。", + "dws profile switch - 使用 previousProfile 切回,并在 current/previous 间 toggle。", "切换成功后重置进程内 token/runtime 缓存,后续无 --profile 命令默认使用新的 currentProfile。", "切换后 legacy auth-token 镜像同步为当前 profile token。", "切换成功的人类可读输出必须包含当前组织名和 corpId,便于终端 agent 确认已切到正确组织。", @@ -214,8 +215,8 @@ ], "reviewChecklist": [ "切换组织能力必须在 dws 顶层出现,不接受只在内部函数可用。", - "profile list/use 与 auth status 的人类可读输出必须显式包含组织名,不接受只展示 profile name 或 corpId。", - "产品草案中的 auth list/--associated/--组织corp ID 若与技术方案冲突,以技术方案命名为准;auth switch 保留为 profile use 的兼容入口,且无参数时必须展示 TUI。", + "profile list/switch 与 auth status 的人类可读输出必须显式包含组织名,不接受只展示 profile name 或 corpId。", + "产品草案中的 auth list/--associated/--组织corp ID/auth switch 若与技术方案冲突,以技术方案命名为准;切换组织不得放在 auth 命令组下。", "profiles.json 不得包含 access_token、refresh_token、persistent_code 或 client_secret。", "全局 --profile 不得持久改 currentProfile。", "auth logout 默认必须清除所有已登录组织;--profile 只能清除指定组织;不得继续暴露 --all。", From 78ae2877bd4a34c0a95c1f23cfb57311b63ee5a6 Mon Sep 17 00:00:00 2001 From: "shangguanxuan.sgx" Date: Fri, 26 Jun 2026 15:43:16 +0800 Subject: [PATCH 09/22] =?UTF-8?q?docs(ralph):=20=E6=9B=B4=E6=96=B0=20profi?= =?UTF-8?q?le=20switch=20=E9=AA=8C=E6=94=B6=E6=9D=90=E6=96=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/ralph/dws-multi-profile-login/analysis.md | 2 +- docs/ralph/dws-multi-profile-login/comments/pr-comment.md | 7 ++----- .../dws-multi-profile-login/review/acceptance-review.md | 2 +- 3 files changed, 4 insertions(+), 7 deletions(-) diff --git a/docs/ralph/dws-multi-profile-login/analysis.md b/docs/ralph/dws-multi-profile-login/analysis.md index 19cb0cd2..524c4f93 100644 --- a/docs/ralph/dws-multi-profile-login/analysis.md +++ b/docs/ralph/dws-multi-profile-login/analysis.md @@ -133,4 +133,4 @@ dws 的多组织模型不是飞书的多用户列表,而是同一自然人下 go test ./internal/auth ./internal/app -run 'Test(MultiProfile|RuntimeProfile|DeleteProfile|UpsertProfile|LoadProfiles|LegacyKeychain|WriteProfile|ProfileList|ProfileUse|ProfileSwitch|AuthCommandDoesNotExposeSwitch|AuthStatus|AuthLogout|AuthLogin|ResolveAuthLogin|EnrichAuthLogin|RootHelp|RootShortHelp|RootCommand)' ``` -全量 `go test ./internal/auth ./internal/app` 中 `internal/auth` 通过,但 `internal/app` 被升级模块用例 `TestValidateNewBinary_RecoversFromUnsignedDarwin` 阻塞,错误是测试二进制执行时被 macOS kill。该失败发生在 upgrade 验签/回滚路径,不在多组织登录代码改动面内,需作为独立 CI/本机签名环境问题跟进。 +全量 `go test ./internal/auth ./internal/app` 也已通过。 diff --git a/docs/ralph/dws-multi-profile-login/comments/pr-comment.md b/docs/ralph/dws-multi-profile-login/comments/pr-comment.md index 23264524..7442ddc2 100644 --- a/docs/ralph/dws-multi-profile-login/comments/pr-comment.md +++ b/docs/ralph/dws-multi-profile-login/comments/pr-comment.md @@ -17,12 +17,9 @@ ```bash go test ./internal/auth ./internal/app -run 'Test(MultiProfile|RuntimeProfile|DeleteProfile|UpsertProfile|LoadProfiles|LegacyKeychain|WriteProfile|ProfileList|ProfileUse|ProfileSwitch|AuthCommandDoesNotExposeSwitch|AuthStatus|AuthLogout|AuthLogin|ResolveAuthLogin|EnrichAuthLogin|RootHelp|RootShortHelp|RootCommand)' +go test ./internal/auth ./internal/app ``` -结果:`internal/auth` 与多组织相关 `internal/app` 用例通过。 +结果:`internal/auth` 与 `internal/app` 均通过。 另已验证本地打包安装,本机 `dws` 已指向本 PR 最新构建。 - -### 残余说明 - -全量 `go test ./internal/auth ./internal/app` 中 `internal/app` 被 upgrade 模块用例 `TestValidateNewBinary_RecoversFromUnsignedDarwin` 阻塞,错误是测试二进制执行被 macOS kill。该失败不在本次多组织登录改动面内,建议作为独立本机签名/隔离环境问题跟进。 diff --git a/docs/ralph/dws-multi-profile-login/review/acceptance-review.md b/docs/ralph/dws-multi-profile-login/review/acceptance-review.md index a2a507b5..b6038355 100644 --- a/docs/ralph/dws-multi-profile-login/review/acceptance-review.md +++ b/docs/ralph/dws-multi-profile-login/review/acceptance-review.md @@ -30,6 +30,7 @@ ```bash go test ./internal/auth ./internal/app -run 'Test(MultiProfile|RuntimeProfile|DeleteProfile|UpsertProfile|LoadProfiles|LegacyKeychain|WriteProfile|ProfileList|ProfileUse|ProfileSwitch|AuthCommandDoesNotExposeSwitch|AuthStatus|AuthLogout|AuthLogin|ResolveAuthLogin|EnrichAuthLogin|RootHelp|RootShortHelp|RootCommand)' +go test ./internal/auth ./internal/app ``` 结果: @@ -44,7 +45,6 @@ go test ./internal/auth ./internal/app -run 'Test(MultiProfile|RuntimeProfile|De ## 未阻塞但需记录的风险 -- 全量 `go test ./internal/auth ./internal/app` 被 `internal/app` 中 upgrade 相关用例 `TestValidateNewBinary_RecoversFromUnsignedDarwin` 阻塞,错误为测试二进制执行被 macOS kill。该路径与多组织登录无直接耦合,应单独排查本机签名/隔离属性/测试环境。 - real/embedded hook 后端仍不支持显式 `--profile`,这是技术方案明确的 P0 非目标。 - P0 不自动发现所有所属组织;第三组织必须由用户再次完成 OAuth 授权后才会出现在 `profile list`。 - `auth logout` 与飞书 CLI 的登出心智对齐为“清当前认证上下文下的用户登录态”;在 dws 多组织模型里,不带 `--profile` 表示清所有已登录组织。 From c5ae1c23c6ba9d2aa96865190770cd527ea6a420 Mon Sep 17 00:00:00 2001 From: "shangguanxuan.sgx" Date: Fri, 26 Jun 2026 15:56:47 +0800 Subject: [PATCH 10/22] =?UTF-8?q?fix(profile):=20=E5=B1=95=E7=A4=BA?= =?UTF-8?q?=E5=85=A8=E9=83=A8=E5=8F=AF=E5=88=87=E6=8D=A2=E7=BB=84=E7=BB=87?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/app/profile_command.go | 42 ++++++++++++++++++++++------ internal/app/profile_command_test.go | 40 ++++++++++++++++++++++++++ 2 files changed, 74 insertions(+), 8 deletions(-) diff --git a/internal/app/profile_command.go b/internal/app/profile_command.go index d2dd32a9..8926ce64 100644 --- a/internal/app/profile_command.go +++ b/internal/app/profile_command.go @@ -161,10 +161,7 @@ func selectProfileSwitchProfile(cmd *cobra.Command, configDir string) (string, e if choice == "" { choice = cfg.Profiles[0].CorpID } - options := make([]huh.Option[string], 0, len(cfg.Profiles)) - for _, p := range cfg.Profiles { - options = append(options, huh.NewOption(profileSwitchOptionLabel(p, cfg), p.CorpID)) - } + options := profileSwitchOptions(cfg) height := len(options) if height > 12 { height = 12 @@ -173,7 +170,7 @@ func selectProfileSwitchProfile(cmd *cobra.Command, configDir string) (string, e huh.NewGroup( huh.NewSelect[string](). Title("选择要切换的组织"). - Description("↑↓ 选择,Enter 确认\n\nORGANIZATION STATUS USER CORP_ID"). + Description("全部已登录 profile,↑↓ 选择,Enter 确认,/ 搜索"). Options(options...). Height(height). Value(&choice), @@ -185,6 +182,17 @@ func selectProfileSwitchProfile(cmd *cobra.Command, configDir string) (string, e return strings.TrimSpace(choice), nil } +func profileSwitchOptions(cfg *authpkg.ProfilesConfig) []huh.Option[string] { + if cfg == nil { + return nil + } + options := make([]huh.Option[string], 0, len(cfg.Profiles)) + for _, p := range cfg.Profiles { + options = append(options, huh.NewOption(profileSwitchOptionLabel(p, cfg), p.CorpID)) + } + return options +} + func profileSwitchOptionLabel(p authpkg.Profile, cfg *authpkg.ProfilesConfig) string { status := strings.TrimSpace(p.Status) if status == "" { @@ -210,12 +218,21 @@ func profileSwitchOptionLabel(p authpkg.Profile, cfg *authpkg.ProfilesConfig) st } marker := "" if cfg != nil && p.CorpID == cfg.CurrentProfile { - marker = " ← 当前" + marker = "current" } else if cfg != nil && p.CorpID == cfg.PrimaryProfile { - marker = " default" + marker = "default" } org := profileOrgName(p) - return fmt.Sprintf("%-28s %-8s %-18s %s%s", clipProfileCell(org, 28), statusLabel, clipProfileCell(user, 18), p.CorpID, marker) + parts := []string{ + clipProfileCell(org, 22), + statusLabel, + clipProfileCell(user, 12), + shortProfileCorpID(p.CorpID), + } + if marker != "" { + parts = append(parts, marker) + } + return strings.Join(parts, " | ") } type profileListResponse struct { @@ -383,3 +400,12 @@ func clipProfileCell(value string, limit int) string { } return string(runes[:limit-3]) + "..." } + +func shortProfileCorpID(corpID string) string { + corpID = strings.TrimSpace(corpID) + runes := []rune(corpID) + if len(runes) <= 18 { + return corpID + } + return string(runes[:10]) + "..." + string(runes[len(runes)-4:]) +} diff --git a/internal/app/profile_command_test.go b/internal/app/profile_command_test.go index 78c04768..fb1cbcc7 100644 --- a/internal/app/profile_command_test.go +++ b/internal/app/profile_command_test.go @@ -217,6 +217,46 @@ func TestProfileSwitchNoArgsUsesTUISelector(t *testing.T) { } } +func TestProfileSwitchOptionsIncludeAllLoggedProfiles(t *testing.T) { + cfg := &authpkg.ProfilesConfig{ + PrimaryProfile: "corp_primary", + CurrentProfile: "corp_secondary", + Profiles: []authpkg.Profile{ + { + CorpID: "corp_primary", + CorpName: "主组织", + UserName: "alice", + Status: authpkg.ProfileStatusActive, + }, + { + CorpID: "corp_secondary", + CorpName: "第二组织", + UserName: "bob", + Status: authpkg.ProfileStatusActive, + }, + }, + } + options := profileSwitchOptions(cfg) + if len(options) != len(cfg.Profiles) { + t.Fatalf("options len = %d, want %d", len(options), len(cfg.Profiles)) + } + wantValues := []string{"corp_primary", "corp_secondary"} + for i, want := range wantValues { + if options[i].Value != want { + t.Fatalf("option[%d].Value = %q, want %q", i, options[i].Value, want) + } + if strings.Contains(options[i].Key, "\n") { + t.Fatalf("option[%d].Key contains newline: %q", i, options[i].Key) + } + } + if !strings.Contains(options[0].Key, "default") { + t.Fatalf("primary option missing default marker: %q", options[0].Key) + } + if !strings.Contains(options[1].Key, "current") { + t.Fatalf("current option missing current marker: %q", options[1].Key) + } +} + func TestAuthCommandDoesNotExposeSwitch(t *testing.T) { cmd := NewRootCommand() var out bytes.Buffer From 502f07277fb0cb9959eb4d674924a9fb998c61cc Mon Sep 17 00:00:00 2001 From: "shangguanxuan.sgx" Date: Fri, 26 Jun 2026 19:06:14 +0800 Subject: [PATCH 11/22] feat(profile): support multi-org switch tui --- go.mod | 6 +- internal/app/auth_command.go | 22 +- internal/app/help_source_test.go | 70 ++++ internal/app/profile_command.go | 522 ++++++++++++++++++++++----- internal/app/profile_command_test.go | 240 +++++++++++- internal/auth/portable_store.go | 5 +- internal/auth/portable_store_test.go | 73 ++++ 7 files changed, 821 insertions(+), 117 deletions(-) diff --git a/go.mod b/go.mod index 4c591985..3527f9bc 100644 --- a/go.mod +++ b/go.mod @@ -4,10 +4,13 @@ go 1.25.8 require ( github.com/RealAlexandreAI/json-repair v0.0.15 + github.com/charmbracelet/bubbletea v1.3.6 github.com/charmbracelet/huh v1.0.0 + github.com/charmbracelet/lipgloss v1.1.0 github.com/fatih/color v1.18.0 github.com/google/uuid v1.6.0 github.com/itchyny/gojq v0.12.18 + github.com/muesli/termenv v0.16.0 github.com/open-dingtalk/dingtalk-stream-sdk-go v0.9.1 github.com/spf13/cobra v1.10.2 github.com/zalando/go-keyring v0.2.8 @@ -21,9 +24,7 @@ require ( github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect github.com/catppuccin/go v0.3.0 // indirect github.com/charmbracelet/bubbles v0.21.1-0.20250623103423-23b8fd6302d7 // indirect - github.com/charmbracelet/bubbletea v1.3.6 // indirect github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect - github.com/charmbracelet/lipgloss v1.1.0 // indirect github.com/charmbracelet/x/ansi v0.9.3 // indirect github.com/charmbracelet/x/cellbuf v0.0.13 // indirect github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0 // indirect @@ -44,7 +45,6 @@ require ( github.com/mitchellh/hashstructure/v2 v2.0.2 // indirect github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect github.com/muesli/cancelreader v0.2.2 // indirect - github.com/muesli/termenv v0.16.0 // indirect github.com/rivo/uniseg v0.4.7 // indirect github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect golang.org/x/sync v0.20.0 // indirect diff --git a/internal/app/auth_command.go b/internal/app/auth_command.go index c0a75e4f..29580af1 100644 --- a/internal/app/auth_command.go +++ b/internal/app/auth_command.go @@ -111,6 +111,7 @@ func newAuthLoginCommand(patCaller edition.ToolCaller) *cobra.Command { 示例: dws auth login # 本机登录并新增/刷新一个组织 profile + dws auth login --profile # 指定本次授权目标组织,不持久切换当前组织 dws auth login --recommend # 无交互批量授权服务端推荐权限 dws auth login --device # SSH 远程 / 无头环境登录 (设备流) dws auth login --force # 兼容保留;login 默认已忽略缓存并进入授权流程 @@ -381,8 +382,14 @@ func selectLoginRecommendScopeMode() (pat.LoginRecommendScopeMode, error) { func newAuthLogoutCommand() *cobra.Command { cmd := &cobra.Command{ - Use: "logout", - Short: "清除认证信息(默认退出所有组织)", + Use: "logout", + Short: "清除认证信息(默认退出所有组织)", + Long: `清除本机钉钉登录态。 + +默认退出所有已登录组织 profile;指定 --profile 时只退出该组织,不影响其他组织。`, + Example: ` dws auth logout + dws auth logout --profile + dws auth logout --profile "钉钉"`, DisableAutoGenTag: true, RunE: func(cmd *cobra.Command, args []string) error { configDir := defaultConfigDir() @@ -417,8 +424,15 @@ func newAuthLogoutCommand() *cobra.Command { func newAuthStatusCommand() *cobra.Command { cmd := &cobra.Command{ - Use: "status", - Short: "查看认证状态", + Use: "status", + Short: "查看认证状态", + Long: `查看当前或指定组织 profile 的认证状态。 + +指定 --profile 时只读取并刷新被选中的 token slot,不会修改 currentProfile。`, + Example: ` dws auth status + dws auth status --profile + dws auth status --profile "钉钉" + dws auth status --profile --format json`, DisableAutoGenTag: true, RunE: func(cmd *cobra.Command, args []string) error { configDir := defaultConfigDir() diff --git a/internal/app/help_source_test.go b/internal/app/help_source_test.go index 48e102ff..72ff0fb5 100644 --- a/internal/app/help_source_test.go +++ b/internal/app/help_source_test.go @@ -220,6 +220,60 @@ func TestRootHelpCustomizationDoesNotAffectSubcommandHelp(t *testing.T) { } } +func TestProfileHelpDocumentsMultiProfileUsage(t *testing.T) { + got := executeHelpForTest(t, "profile", "switch", "--help") + for _, want := range []string{ + "切换默认组织 profile", + "需要只影响单次业务命令时,请使用全局 --profile", + "dws profile switch --corpId ", + "dws --profile contact user get-self", + "--corpId string", + "--name string", + } { + if !strings.Contains(got, want) { + t.Fatalf("profile switch help missing %q:\n%s", want, got) + } + } + + got = executeHelpForTest(t, "profile", "list", "--help") + for _, want := range []string{ + "列出本机已登录的所有组织 profile", + "dws profile list --format json", + } { + if !strings.Contains(got, want) { + t.Fatalf("profile list help missing %q:\n%s", want, got) + } + } +} + +func TestAuthHelpDocumentsProfileUsage(t *testing.T) { + got := executeHelpForTest(t, "auth", "login", "--help") + if !strings.Contains(got, "dws auth login --profile ") { + t.Fatalf("auth login help missing --profile example:\n%s", got) + } + + got = executeHelpForTest(t, "auth", "status", "--help") + for _, want := range []string{ + "查看当前或指定组织 profile 的认证状态", + "只读取并刷新被选中的 token slot", + "dws auth status --profile ", + } { + if !strings.Contains(got, want) { + t.Fatalf("auth status help missing %q:\n%s", want, got) + } + } + + got = executeHelpForTest(t, "auth", "logout", "--help") + for _, want := range []string{ + "默认退出所有已登录组织 profile", + "dws auth logout --profile ", + } { + if !strings.Contains(got, want) { + t.Fatalf("auth logout help missing %q:\n%s", want, got) + } + } +} + func TestRootCommandRegistersUpgradeCommand(t *testing.T) { root := NewRootCommand() if cmd := lookupCommand(root, "upgrade"); cmd == nil { @@ -227,6 +281,22 @@ func TestRootCommandRegistersUpgradeCommand(t *testing.T) { } } +func executeHelpForTest(t *testing.T, args ...string) string { + t.Helper() + t.Setenv(cli.CatalogFixtureEnv, "") + t.Setenv(cli.CacheDirEnv, t.TempDir()) + + root := NewRootCommand() + var out bytes.Buffer + root.SetOut(&out) + root.SetErr(&out) + root.SetArgs(args) + if err := root.Execute(); err != nil { + t.Fatalf("Execute(%v) error = %v\noutput:\n%s", args, err, out.String()) + } + return out.String() +} + func discoveryServerEntry(command, description string, groups, toolOverrides map[string]any) map[string]any { cliMeta := map[string]any{ "id": command, diff --git a/internal/app/profile_command.go b/internal/app/profile_command.go index 8926ce64..ab7166bb 100644 --- a/internal/app/profile_command.go +++ b/internal/app/profile_command.go @@ -15,20 +15,34 @@ package app import ( "encoding/json" + "errors" "fmt" "io" + "sort" "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/charmbracelet/huh" + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" + "github.com/muesli/termenv" "github.com/spf13/cobra" ) func newProfileCommand() *cobra.Command { cmd := &cobra.Command{ - Use: "profile", - Short: "组织 profile 管理", + Use: "profile", + Short: "组织 profile 管理", + Long: `管理本机已登录的钉钉组织 profile。 + +每个 profile 对应一个已授权组织。业务命令可通过全局 --profile 临时指定组织, +profile switch/use 才会持久修改默认组织上下文。`, + Example: ` dws profile list + dws profile switch + dws profile switch + dws profile switch - + dws --profile contact user get-self`, Args: cobra.NoArgs, TraverseChildren: true, DisableAutoGenTag: true, @@ -42,9 +56,12 @@ func newProfileCommand() *cobra.Command { func newProfileListCommand() *cobra.Command { return &cobra.Command{ - Use: "list", - Aliases: []string{"ls"}, - Short: "列出已登录组织 profile", + Use: "list", + Aliases: []string{"ls"}, + Short: "列出已登录组织 profile", + Long: "列出本机已登录的所有组织 profile,包含当前组织、主组织、组织名、corpId、状态和用户信息。", + Example: ` dws profile list + dws profile list --format json`, Args: cobra.NoArgs, DisableAutoGenTag: true, RunE: func(cmd *cobra.Command, args []string) error { @@ -67,27 +84,56 @@ func newProfileListCommand() *cobra.Command { } func newProfileUseCommand() *cobra.Command { - return &cobra.Command{ - Use: "use [name|corpId|-]", - Short: "切换当前组织 profile(兼容 profile switch)", + cmd := &cobra.Command{ + Use: "use [name|corpId|-]", + Short: "切换当前组织 profile(兼容 profile switch)", + Long: "兼容命令,语义等同于 dws profile switch。可用组织名、profile 名、corpId 或 - 切回上一个组织。", + Example: ` dws profile use + dws profile use --name "钉钉" + dws profile use -`, Args: cobra.MaximumNArgs(1), DisableAutoGenTag: true, RunE: func(cmd *cobra.Command, args []string) error { return runProfileSwitchCommand(cmd, args) }, } + addProfileSwitchSelectorFlags(cmd) + return cmd } func newProfileSwitchCommand() *cobra.Command { - return &cobra.Command{ - Use: "switch [name|corpId|-]", - Short: "切换当前组织 profile", + cmd := &cobra.Command{ + Use: "switch [name|corpId|-]", + Short: "切换当前组织 profile", + Long: `切换默认组织 profile,并记录 previousProfile 以支持 dws profile switch - 快速切回。 + +不带参数时,交互终端会展示组织选择器;非交互环境请显式传入组织名、profile 名或 corpId。 +需要只影响单次业务命令时,请使用全局 --profile。`, + Example: ` dws profile switch + dws profile switch + dws profile switch --corpId + dws profile switch --name "钉钉" + dws profile switch - + dws --profile contact user get-self`, Args: cobra.MaximumNArgs(1), DisableAutoGenTag: true, RunE: func(cmd *cobra.Command, args []string) error { return runProfileSwitchCommand(cmd, args) }, } + addProfileSwitchSelectorFlags(cmd) + return cmd +} + +func addProfileSwitchSelectorFlags(cmd *cobra.Command) { + cmd.Flags().String("corpId", "", "按 corpId 直接切换组织 profile") + cmd.Flags().String("corp-id", "", "按 corpId 直接切换组织 profile") + cmd.Flags().String("corpid", "", "按 corpId 直接切换组织 profile") + cmd.Flags().String("corp", "", "按 corpId 直接切换组织 profile") + cmd.Flags().String("name", "", "按组织名或 profile 名直接切换组织 profile") + _ = cmd.Flags().MarkHidden("corp-id") + _ = cmd.Flags().MarkHidden("corpid") + _ = cmd.Flags().MarkHidden("corp") } var ( @@ -95,15 +141,30 @@ var ( profileSwitchInteractiveTerminal = isInteractiveTerminal ) +const ( + profileSwitchVisibleOptions = 5 + profileSwitchCellPadding = 1 + profileSwitchOrgWidth = 34 + profileSwitchStatusWidth = 10 +) + +var profileSwitchRenderer = newProfileSwitchRenderer() + +func newProfileSwitchRenderer() *lipgloss.Renderer { + renderer := lipgloss.NewRenderer(io.Discard) + renderer.SetColorProfile(termenv.TrueColor) + renderer.SetHasDarkBackground(true) + return renderer +} + func runProfileSwitchCommand(cmd *cobra.Command, args []string) error { configDir := defaultConfigDir() - selector := "" - if len(args) > 0 { - selector = strings.TrimSpace(args[0]) + selector, err := profileSwitchSelectorFromCommand(cmd, args) + if err != nil { + return err } usedTUI := false if selector == "" { - var err error selector, err = profileSwitchSelector(cmd, configDir) if err != nil { return err @@ -113,6 +174,44 @@ func runProfileSwitchCommand(cmd *cobra.Command, args []string) error { return switchProfileAndWrite(cmd, configDir, selector, usedTUI) } +func profileSwitchSelectorFromCommand(cmd *cobra.Command, args []string) (string, error) { + selectors := make([]string, 0, 2) + if len(args) > 0 { + selectors = append(selectors, strings.TrimSpace(args[0])) + } + for _, name := range []string{"corpId", "corp-id", "corpid", "corp", "name"} { + value, changed := changedStringFlag(cmd, name) + if !changed { + continue + } + if value == "" { + return "", apperrors.NewValidation(fmt.Sprintf("--%s 不能为空", name)) + } + selectors = append(selectors, value) + } + if len(selectors) == 0 { + return "", nil + } + selector := selectors[0] + for _, candidate := range selectors[1:] { + if candidate != selector { + return "", apperrors.NewValidation("只能指定一个组织选择器,请使用位置参数或 --corpId/--name 其中一种") + } + } + return selector, nil +} + +func changedStringFlag(cmd *cobra.Command, name string) (string, bool) { + if cmd == nil || cmd.Flags() == nil { + return "", false + } + flag := cmd.Flags().Lookup(name) + if flag == nil || !flag.Changed { + return "", false + } + return strings.TrimSpace(flag.Value.String()), true +} + func switchProfileAndWrite(cmd *cobra.Command, configDir, selector string, usedTUI bool) error { var ( profile *authpkg.Profile @@ -161,78 +260,299 @@ func selectProfileSwitchProfile(cmd *cobra.Command, configDir string) (string, e if choice == "" { choice = cfg.Profiles[0].CorpID } - options := profileSwitchOptions(cfg) - height := len(options) - if height > 12 { - height = 12 + return runProfileSwitchTUI(cmd, cfg, choice) +} + +func runProfileSwitchTUI(cmd *cobra.Command, cfg *authpkg.ProfilesConfig, selectedCorpID string) (string, error) { + model := newProfileSwitchTUIModel(cfg, selectedCorpID) + program := tea.NewProgram( + model, + tea.WithAltScreen(), + tea.WithInput(cmd.InOrStdin()), + tea.WithOutput(cmd.ErrOrStderr()), + tea.WithContext(cmd.Context()), + ) + finalModel, err := program.Run() + if err != nil { + if errors.Is(err, tea.ErrInterrupted) { + return "", apperrors.NewValidation("组织选择中止: user aborted") + } + return "", apperrors.NewInternal(fmt.Sprintf("failed to run profile selector: %v", err)) } - form := huh.NewForm( - huh.NewGroup( - huh.NewSelect[string](). - Title("选择要切换的组织"). - Description("全部已登录 profile,↑↓ 选择,Enter 确认,/ 搜索"). - Options(options...). - Height(height). - Value(&choice), - ), - ).WithTheme(authLoginHuhTheme()) - if err := form.Run(); err != nil { - return "", apperrors.NewValidation(fmt.Sprintf("组织选择中止: %v", err)) + final, ok := finalModel.(profileSwitchTUIModel) + if !ok || final.aborted || !final.submitted { + return "", apperrors.NewValidation("组织选择中止: user aborted") } - return strings.TrimSpace(choice), nil + return final.selectedCorpID(), nil } -func profileSwitchOptions(cfg *authpkg.ProfilesConfig) []huh.Option[string] { - if cfg == nil { - return nil +type profileSwitchTUIModel struct { + cfg *authpkg.ProfilesConfig + profiles []authpkg.Profile + selected int + offset int + submitted bool + aborted bool +} + +func newProfileSwitchTUIModel(cfg *authpkg.ProfilesConfig, selectedCorpID string) profileSwitchTUIModel { + model := profileSwitchTUIModel{cfg: cfg} + if cfg != nil { + model.profiles = profileSwitchSortedProfiles(cfg.Profiles) } - options := make([]huh.Option[string], 0, len(cfg.Profiles)) - for _, p := range cfg.Profiles { - options = append(options, huh.NewOption(profileSwitchOptionLabel(p, cfg), p.CorpID)) + model.selected = profileSwitchProfileIndex(model.profiles, selectedCorpID) + if model.selected < 0 { + model.selected = 0 + } + model.ensureSelectedVisible() + return model +} + +func profileSwitchSortedProfiles(profiles []authpkg.Profile) []authpkg.Profile { + sorted := append([]authpkg.Profile(nil), profiles...) + sort.SliceStable(sorted, func(i, j int) bool { + left, leftOK := profileSwitchSortTime(sorted[i]) + right, rightOK := profileSwitchSortTime(sorted[j]) + if leftOK && rightOK && !left.Equal(right) { + return left.After(right) + } + if leftOK != rightOK { + return leftOK + } + return false + }) + return sorted +} + +func profileSwitchSortTime(p authpkg.Profile) (time.Time, bool) { + for _, raw := range []string{p.LastLoginAt, p.UpdatedAt, p.LastUsedAt} { + if t, ok := parseProfileSwitchTime(raw); ok { + return t, true + } + } + return time.Time{}, false +} + +func parseProfileSwitchTime(raw string) (time.Time, bool) { + raw = strings.TrimSpace(raw) + if raw == "" { + return time.Time{}, false + } + t, err := time.Parse(time.RFC3339, raw) + if err != nil { + return time.Time{}, false + } + return t, true +} + +func (m profileSwitchTUIModel) Init() tea.Cmd { + return nil +} + +func (m profileSwitchTUIModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case tea.KeyMsg: + switch msg.String() { + case "ctrl+c", "esc", "q": + m.aborted = true + return m, tea.Quit + case "up", "k": + if m.selected > 0 { + m.selected-- + m.ensureSelectedVisible() + } + case "down", "j": + if m.selected < len(m.profiles)-1 { + m.selected++ + m.ensureSelectedVisible() + } + case "enter": + m.submitted = true + return m, tea.Quit + } + } + return m, nil +} + +func (m profileSwitchTUIModel) View() string { + var b strings.Builder + title := profileSwitchTitleStyle().Render("选择要切换的组织") + hint := profileSwitchMutedStyle().Render("全部已登录 profile,↑↓ 选择,Enter 确认") + b.WriteString(title) + b.WriteString("\n") + b.WriteString(hint) + b.WriteString("\n\n") + b.WriteString(m.tableView()) + b.WriteString("\n") + b.WriteString(profileSwitchMutedStyle().Render("↑/k up • ↓/j down • enter submit • esc cancel")) + return b.String() +} + +func (m profileSwitchTUIModel) tableView() string { + rows := []string{ + profileSwitchBorder("┌", "┬", "┐"), + profileSwitchStyledTableLine("组织名", "本地状态", profileSwitchHeaderStyle()), + profileSwitchBorder("├", "┼", "┤"), + } + for i := 0; i < profileSwitchVisibleOptions; i++ { + idx := m.offset + i + if idx >= 0 && idx < len(m.profiles) { + rows = append(rows, m.profileRow(idx)) + continue + } + rows = append(rows, profileSwitchStyledTableLine("", "", profileSwitchNormalRowStyle())) } - return options + rows = append(rows, profileSwitchBorder("└", "┴", "┘")) + return strings.Join(rows, "\n") +} + +func (m profileSwitchTUIModel) profileRow(idx int) string { + profile := m.profiles[idx] + org, status := profileSwitchProfileCells(profile, m.cfg) + style := profileSwitchNormalRowStyle() + if idx == m.selected { + org = "› " + org + style = profileSwitchSelectedRowStyle() + } else { + org = " " + org + } + return profileSwitchStyledTableLine(org, status, style) +} + +func (m *profileSwitchTUIModel) ensureSelectedVisible() { + if len(m.profiles) == 0 { + m.selected = 0 + m.offset = 0 + return + } + if m.selected < 0 { + m.selected = 0 + } + if m.selected >= len(m.profiles) { + m.selected = len(m.profiles) - 1 + } + if m.selected < m.offset { + m.offset = m.selected + } + if m.selected >= m.offset+profileSwitchVisibleOptions { + m.offset = m.selected - profileSwitchVisibleOptions + 1 + } + maxOffset := len(m.profiles) - profileSwitchVisibleOptions + if maxOffset < 0 { + maxOffset = 0 + } + if m.offset > maxOffset { + m.offset = maxOffset + } + if m.offset < 0 { + m.offset = 0 + } +} + +func (m profileSwitchTUIModel) selectedCorpID() string { + if m.selected < 0 || m.selected >= len(m.profiles) { + return "" + } + return strings.TrimSpace(m.profiles[m.selected].CorpID) +} + +func profileSwitchProfileIndex(profiles []authpkg.Profile, corpID string) int { + corpID = strings.TrimSpace(corpID) + for i, p := range profiles { + if strings.TrimSpace(p.CorpID) == corpID { + return i + } + } + return -1 } func profileSwitchOptionLabel(p authpkg.Profile, cfg *authpkg.ProfilesConfig) string { - status := strings.TrimSpace(p.Status) + org, status := profileSwitchProfileCells(p, cfg) if status == "" { - status = authpkg.ProfileStatusActive - } - statusLabel := "" - switch status { - case authpkg.ProfileStatusActive: - statusLabel = "已登录" - case authpkg.ProfileStatusExpired: - statusLabel = "已过期" - case authpkg.ProfileStatusRevoked: - statusLabel = "已撤销" - default: - statusLabel = status - } - user := strings.TrimSpace(p.UserName) - if user == "" { - user = strings.TrimSpace(p.UserID) - } - if user == "" { - user = "-" - } - marker := "" + return org + } + return strings.Join([]string{org, status}, " | ") +} + +func profileSwitchProfileCells(p authpkg.Profile, cfg *authpkg.ProfilesConfig) (string, string) { + return profileOrgName(p), profileSwitchProfileStatus(p, cfg) +} + +func profileSwitchProfileStatus(p authpkg.Profile, cfg *authpkg.ProfilesConfig) string { if cfg != nil && p.CorpID == cfg.CurrentProfile { - marker = "current" - } else if cfg != nil && p.CorpID == cfg.PrimaryProfile { - marker = "default" + return "当前组织" + } + return "" +} + +func profileSwitchBorder(left, sep, right string) string { + segments := []string{ + strings.Repeat("─", profileSwitchCellWidth(profileSwitchOrgWidth)), + strings.Repeat("─", profileSwitchCellWidth(profileSwitchStatusWidth)), + } + return profileSwitchBorderStyle().Render(left + strings.Join(segments, sep) + right) +} + +func profileSwitchTableLine(org, status string) string { + cells := []string{ + profileSwitchTableCell(org, profileSwitchOrgWidth), + profileSwitchTableCell(status, profileSwitchStatusWidth), } - org := profileOrgName(p) - parts := []string{ - clipProfileCell(org, 22), - statusLabel, - clipProfileCell(user, 12), - shortProfileCorpID(p.CorpID), + return "│" + strings.Join(cells, "│") + "│" +} + +func profileSwitchStyledTableLine(org, status string, style lipgloss.Style) string { + cells := []string{ + style.Render(profileSwitchTableCell(org, profileSwitchOrgWidth)), + style.Render(profileSwitchTableCell(status, profileSwitchStatusWidth)), } - if marker != "" { - parts = append(parts, marker) + return profileSwitchTableSeparator() + strings.Join(cells, profileSwitchTableSeparator()) + profileSwitchTableSeparator() +} + +func profileSwitchTableSeparator() string { + return profileSwitchBorderStyle().Render("│") +} + +func profileSwitchTableCell(value string, width int) string { + clipped := clipProfileDisplayCell(strings.TrimSpace(value), width) + padding := strings.Repeat(" ", profileSwitchCellPadding) + return padding + padProfileDisplayCell(clipped, width) + padding +} + +func padProfileDisplayCell(value string, width int) string { + padding := width - lipgloss.Width(value) + if padding < 0 { + padding = 0 } - return strings.Join(parts, " | ") + return value + strings.Repeat(" ", padding) +} + +func profileSwitchCellWidth(contentWidth int) int { + return contentWidth + profileSwitchCellPadding*2 +} + +func profileSwitchSelectedRowStyle() lipgloss.Style { + return lipgloss.NewStyle().Renderer(profileSwitchRenderer).Foreground(lipgloss.Color("#69B1FF")).Bold(true) +} + +func profileSwitchNormalRowStyle() lipgloss.Style { + return lipgloss.NewStyle().Renderer(profileSwitchRenderer).Foreground(lipgloss.Color("#FFFFFF")) +} + +func profileSwitchHeaderStyle() lipgloss.Style { + return profileSwitchMutedStyle().Bold(true) +} + +func profileSwitchBorderStyle() lipgloss.Style { + return lipgloss.NewStyle().Renderer(profileSwitchRenderer).Foreground(lipgloss.Color("#2F3B52")) +} + +func profileSwitchTitleStyle() lipgloss.Style { + return lipgloss.NewStyle().Renderer(profileSwitchRenderer).Foreground(lipgloss.Color("#69B1FF")).Bold(true) +} + +func profileSwitchMutedStyle() lipgloss.Style { + return lipgloss.NewStyle().Renderer(profileSwitchRenderer).Foreground(lipgloss.Color("#8A96A8")) } type profileListResponse struct { @@ -249,9 +569,8 @@ type profileUseResponse struct { } type profileView struct { - Name string `json:"name"` CorpID string `json:"corpId"` - CorpName string `json:"corpName,omitempty"` + CorpName string `json:"corpName"` UserID string `json:"userId,omitempty"` UserName string `json:"userName,omitempty"` ClientID string `json:"clientId,omitempty"` @@ -299,7 +618,7 @@ func writeProfileListTable(w io.Writer, cfg *authpkg.ProfilesConfig) { fmt.Fprintln(w, "未找到已登录 profile") return } - fmt.Fprintf(w, "%-3s %-3s %-24s %-28s %-34s %-10s %s\n", "CUR", "PRI", "PROFILE", "ORG_NAME", "CORP_ID", "STATUS", "USER") + fmt.Fprintf(w, "%-3s %-3s %-28s %-34s %-10s %s\n", "CUR", "PRI", "ORG_NAME", "CORP_ID", "STATUS", "USER") for _, p := range cfg.Profiles { current := "" if p.CorpID == cfg.CurrentProfile { @@ -319,10 +638,9 @@ func writeProfileListTable(w io.Writer, cfg *authpkg.ProfilesConfig) { } fmt.Fprintf( w, - "%-3s %-3s %-24s %-28s %-34s %-10s %s\n", + "%-3s %-3s %-28s %-34s %-10s %s\n", current, primary, - clipProfileCell(p.Name, 24), clipProfileCell(profileOrgName(p), 28), clipProfileCell(p.CorpID, 34), status, @@ -335,16 +653,12 @@ func profileUseMessage(profile *authpkg.Profile) string { if profile == nil { return "[OK] 当前 profile 已切换" } - name := strings.TrimSpace(profile.Name) corpID := strings.TrimSpace(profile.CorpID) - if name == "" { - name = corpID - } orgName := strings.TrimSpace(profile.CorpName) if orgName == "" { - orgName = name + orgName = profileOrgName(*profile) } - return fmt.Sprintf("[OK] 当前 profile: %s | 组织: %s (%s)", name, orgName, corpID) + return fmt.Sprintf("[OK] 当前组织: %s (%s)", orgName, corpID) } func profileOrgName(p authpkg.Profile) string { @@ -370,9 +684,8 @@ func profileViews(cfg *authpkg.ProfilesConfig) []profileView { func profileViewFromProfile(p authpkg.Profile, primaryProfile, currentProfile string) profileView { return profileView{ - Name: p.Name, CorpID: p.CorpID, - CorpName: p.CorpName, + CorpName: profileOrgName(p), UserID: p.UserID, UserName: p.UserName, ClientID: p.ClientID, @@ -401,11 +714,34 @@ func clipProfileCell(value string, limit int) string { return string(runes[:limit-3]) + "..." } -func shortProfileCorpID(corpID string) string { - corpID = strings.TrimSpace(corpID) - runes := []rune(corpID) - if len(runes) <= 18 { - return corpID +func clipProfileDisplayCell(value string, limit int) string { + if limit <= 0 { + return "" + } + if lipgloss.Width(value) <= limit { + return value + } + if limit <= 3 { + var b strings.Builder + for _, r := range value { + rw := lipgloss.Width(string(r)) + if lipgloss.Width(b.String())+rw > limit { + break + } + b.WriteRune(r) + } + return b.String() + } + target := limit - 3 + var b strings.Builder + width := 0 + for _, r := range value { + rw := lipgloss.Width(string(r)) + if width+rw > target { + break + } + b.WriteRune(r) + width += rw } - return string(runes[:10]) + "..." + string(runes[len(runes)-4:]) + return b.String() + "..." } diff --git a/internal/app/profile_command_test.go b/internal/app/profile_command_test.go index fb1cbcc7..e4a28cd9 100644 --- a/internal/app/profile_command_test.go +++ b/internal/app/profile_command_test.go @@ -16,10 +16,13 @@ package app import ( "bytes" "encoding/json" + "fmt" "strings" "testing" authpkg "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/auth" + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" "github.com/spf13/cobra" ) @@ -42,6 +45,12 @@ func TestWriteProfileUseJSONKeepsPrimaryAndCurrentDistinct(t *testing.T) { if err := json.Unmarshal(buf.Bytes(), &resp); err != nil { t.Fatalf("Unmarshal() error = %v", err) } + if bytes.Contains(buf.Bytes(), []byte(`"name"`)) { + t.Fatalf("profile use JSON should not contain name when corpName is present:\n%s", buf.String()) + } + if resp.Profile.CorpName != "B Org" { + t.Fatalf("corpName = %q, want B Org", resp.Profile.CorpName) + } if !resp.Profile.IsCurrent { t.Fatalf("isCurrent = false, want true") } @@ -77,6 +86,9 @@ func TestProfileListRootCommandJSONIncludesCorpName(t *testing.T) { if len(resp.Profiles) != 2 { t.Fatalf("profiles len = %d, want 2", len(resp.Profiles)) } + if bytes.Contains(out.Bytes(), []byte(`"name"`)) { + t.Fatalf("profile list JSON should not contain name when corpName is present:\n%s", out.String()) + } for _, p := range resp.Profiles { if p.CorpName == "" { t.Fatalf("profile %s missing corpName in JSON response: %#v", p.CorpID, p) @@ -176,6 +188,65 @@ func TestProfileSwitchRootCommandSwitchesPrimaryOrganizationAndLegacyMirror(t *t } } +func TestProfileSwitchRootCommandSupportsCorpIDFlag(t *testing.T) { + configDir := setupAuthLogoutProfiles(t, + authLogoutTestToken("corp_primary"), + authLogoutTestToken("corp_secondary"), + ) + + cmd := NewRootCommand() + var out bytes.Buffer + cmd.SetOut(&out) + cmd.SetErr(&out) + cmd.SetArgs([]string{"--format", "table", "profile", "switch", "--corpId", "corp_primary"}) + if err := cmd.Execute(); err != nil { + t.Fatalf("profile switch --corpId error = %v\noutput:\n%s", err, out.String()) + } + cfg, err := authpkg.LoadProfiles(configDir) + if err != nil { + t.Fatalf("LoadProfiles() error = %v", err) + } + if cfg.CurrentProfile != "corp_primary" { + t.Fatalf("currentProfile = %q, want corp_primary", cfg.CurrentProfile) + } + + cmd = NewRootCommand() + out.Reset() + cmd.SetOut(&out) + cmd.SetErr(&out) + cmd.SetArgs([]string{"--format", "table", "profile", "use", "--corp", "corp_secondary"}) + if err := cmd.Execute(); err != nil { + t.Fatalf("profile use --corp error = %v\noutput:\n%s", err, out.String()) + } + cfg, err = authpkg.LoadProfiles(configDir) + if err != nil { + t.Fatalf("LoadProfiles() error = %v", err) + } + if cfg.CurrentProfile != "corp_secondary" { + t.Fatalf("currentProfile = %q, want corp_secondary", cfg.CurrentProfile) + } +} + +func TestProfileSwitchRootCommandRejectsConflictingSelectors(t *testing.T) { + setupAuthLogoutProfiles(t, + authLogoutTestToken("corp_primary"), + authLogoutTestToken("corp_secondary"), + ) + + cmd := NewRootCommand() + var out bytes.Buffer + cmd.SetOut(&out) + cmd.SetErr(&out) + cmd.SetArgs([]string{"profile", "switch", "corp_primary", "--corpId", "corp_secondary"}) + err := cmd.Execute() + if err == nil { + t.Fatalf("profile switch with conflicting selectors succeeded\noutput:\n%s", out.String()) + } + if !strings.Contains(err.Error(), "只能指定一个组织选择器") { + t.Fatalf("error = %v, want conflicting selector validation", err) + } +} + func TestProfileSwitchNoArgsUsesTUISelector(t *testing.T) { configDir := setupAuthLogoutProfiles(t, authLogoutTestToken("corp_primary"), @@ -217,14 +288,14 @@ func TestProfileSwitchNoArgsUsesTUISelector(t *testing.T) { } } -func TestProfileSwitchOptionsIncludeAllLoggedProfiles(t *testing.T) { +func TestProfileSwitchOptionLabelUsesOnlyOrganizationAndCurrentState(t *testing.T) { cfg := &authpkg.ProfilesConfig{ PrimaryProfile: "corp_primary", CurrentProfile: "corp_secondary", Profiles: []authpkg.Profile{ { CorpID: "corp_primary", - CorpName: "主组织", + CorpName: "第一组织", UserName: "alice", Status: authpkg.ProfileStatusActive, }, @@ -236,27 +307,156 @@ func TestProfileSwitchOptionsIncludeAllLoggedProfiles(t *testing.T) { }, }, } - options := profileSwitchOptions(cfg) - if len(options) != len(cfg.Profiles) { - t.Fatalf("options len = %d, want %d", len(options), len(cfg.Profiles)) + primary := profileSwitchOptionLabel(cfg.Profiles[0], cfg) + current := profileSwitchOptionLabel(cfg.Profiles[1], cfg) + for _, label := range []string{primary, current} { + if strings.Contains(label, "\n") { + t.Fatalf("profile switch label contains newline: %q", label) + } + } + if !strings.Contains(primary, "第一组织") { + t.Fatalf("primary option missing organization name: %q", primary) } - wantValues := []string{"corp_primary", "corp_secondary"} - for i, want := range wantValues { - if options[i].Value != want { - t.Fatalf("option[%d].Value = %q, want %q", i, options[i].Value, want) + if !strings.Contains(current, "当前组织") { + t.Fatalf("current option missing current marker: %q", current) + } + for _, unwanted := range []string{"alice", "bob", "已登录", "主组织", "corp_primary", "corp_secondary"} { + if strings.Contains(primary, unwanted) || strings.Contains(current, unwanted) { + t.Fatalf("profile switch option should not contain %q: %q / %q", unwanted, primary, current) } - if strings.Contains(options[i].Key, "\n") { - t.Fatalf("option[%d].Key contains newline: %q", i, options[i].Key) + } +} + +func TestProfileSwitchTUIViewUsesFixedOuterTable(t *testing.T) { + cfg := profileSwitchTestConfig(2) + model := newProfileSwitchTUIModel(cfg, "corp_00") + view := model.tableView() + if lines := strings.Split(view, "\n"); len(lines) != profileSwitchVisibleOptions+4 { + t.Fatalf("table line count = %d, want %d:\n%s", len(lines), profileSwitchVisibleOptions+4, view) + } + for _, want := range []string{"┌", "┬", "┐", "├", "┼", "┤", "└", "┴", "┘", "组织名", "本地状态"} { + if !strings.Contains(view, want) { + t.Fatalf("profile switch table missing %q in:\n%s", want, view) } } - if !strings.Contains(options[0].Key, "default") { - t.Fatalf("primary option missing default marker: %q", options[0].Key) + for _, unwanted := range []string{"CORP_ID", "ORGANIZATION", "STATUS"} { + if strings.Contains(view, unwanted) { + t.Fatalf("profile switch table should not contain %q:\n%s", unwanted, view) + } } - if !strings.Contains(options[1].Key, "current") { - t.Fatalf("current option missing current marker: %q", options[1].Key) + if got := strings.Count(view, "│"); got != (profileSwitchVisibleOptions+1)*3 { + t.Fatalf("table vertical separators = %d, want %d\n%s", got, (profileSwitchVisibleOptions+1)*3, view) + } + for _, profile := range cfg.Profiles { + if got := strings.Count(view, profile.CorpID); got != 0 { + t.Fatalf("profile corpId %s appears %d times, want hidden:\n%s", profile.CorpID, got, view) + } } } +func TestProfileSwitchTUISortsLatestLoggedInProfilesFirst(t *testing.T) { + cfg := &authpkg.ProfilesConfig{ + PrimaryProfile: "old", + CurrentProfile: "old", + Profiles: []authpkg.Profile{ + {CorpID: "old", CorpName: "旧组织", LastLoginAt: "2026-06-26T10:00:00+08:00"}, + {CorpID: "new", CorpName: "新组织", LastLoginAt: "2026-06-26T12:00:00+08:00"}, + {CorpID: "fallback", CorpName: "兜底组织", UpdatedAt: "2026-06-26T11:00:00+08:00"}, + }, + } + model := newProfileSwitchTUIModel(cfg, "old") + gotOrder := []string{model.profiles[0].CorpID, model.profiles[1].CorpID, model.profiles[2].CorpID} + wantOrder := []string{"new", "fallback", "old"} + if strings.Join(gotOrder, ",") != strings.Join(wantOrder, ",") { + t.Fatalf("profile order = %v, want %v", gotOrder, wantOrder) + } + if got := model.selectedCorpID(); got != "old" { + t.Fatalf("selectedCorpID = %q, want old", got) + } +} + +func TestProfileSwitchTUIArrowKeysMoveSelectionWithoutDuplicatingRows(t *testing.T) { + cfg := profileSwitchTestConfig(7) + model := newProfileSwitchTUIModel(cfg, "corp_00") + for step := 0; step < 6; step++ { + view := model.tableView() + if got := strings.Count(view, "›"); got != 1 { + t.Fatalf("step %d selected cursor count = %d, want 1:\n%s", step, got, view) + } + for _, profile := range cfg.Profiles { + name := profileOrgName(profile) + if got := strings.Count(view, name); got > 1 { + t.Fatalf("step %d profile %s appears %d times, want at most once:\n%s", step, name, got, view) + } + } + next, _ := model.Update(tea.KeyMsg{Type: tea.KeyDown}) + model = next.(profileSwitchTUIModel) + } + if model.selected != 6 || model.offset != 2 { + t.Fatalf("selection after down keys = selected %d offset %d, want 6/2", model.selected, model.offset) + } +} + +func TestProfileSwitchTableRowsKeepFixedDisplayWidth(t *testing.T) { + rows := []string{ + profileSwitchTableLine("组织名", "本地状态"), + profileSwitchTableLine("› 钉钉(中国)信息技术有限公司", "当前组织"), + profileSwitchTableLine(" ACME", ""), + profileSwitchTableLine("", ""), + profileSwitchStyledTableLine("组织名", "本地状态", profileSwitchHeaderStyle()), + profileSwitchStyledTableLine("› 钉钉(中国)信息技术有限公司", "当前组织", profileSwitchSelectedRowStyle()), + profileSwitchStyledTableLine(" ACME", "", profileSwitchNormalRowStyle()), + profileSwitchStyledTableLine("", "", profileSwitchNormalRowStyle()), + } + wantWidth := lipgloss.Width(rows[0]) + for i, row := range rows { + if got := lipgloss.Width(row); got != wantWidth { + t.Fatalf("row[%d] width = %d, want %d: %q", i, got, wantWidth, row) + } + if got := strings.Count(row, "│"); got != 3 { + t.Fatalf("row[%d] separator count = %d, want 3: %q", i, got, row) + } + } +} + +func TestProfileSwitchOptionLabelHidesCorpID(t *testing.T) { + const corpID = "ding8196cd9a2b2405da24f2f5cc6abecb85" + cfg := &authpkg.ProfilesConfig{ + PrimaryProfile: corpID, + CurrentProfile: corpID, + } + label := profileSwitchOptionLabel(authpkg.Profile{ + CorpID: corpID, + CorpName: "钉钉", + }, cfg) + for _, want := range []string{"钉钉", "当前组织"} { + if !strings.Contains(label, want) { + t.Fatalf("profile switch label missing %q in %q", want, label) + } + } + for _, unwanted := range []string{"ding8196", "cb85", "主组织"} { + if strings.Contains(label, unwanted) { + t.Fatalf("profile switch label should not contain %q in %q", unwanted, label) + } + } +} + +func profileSwitchTestConfig(count int) *authpkg.ProfilesConfig { + cfg := &authpkg.ProfilesConfig{ + PrimaryProfile: "corp_00", + CurrentProfile: "corp_00", + } + for i := 0; i < count; i++ { + corpID := fmt.Sprintf("corp_%02d", i) + cfg.Profiles = append(cfg.Profiles, authpkg.Profile{ + CorpID: corpID, + CorpName: fmt.Sprintf("组织%02d", i), + Status: authpkg.ProfileStatusActive, + }) + } + return cfg +} + func TestAuthCommandDoesNotExposeSwitch(t *testing.T) { cmd := NewRootCommand() var out bytes.Buffer @@ -358,6 +558,11 @@ func TestWriteProfileListTableIncludesCorpName(t *testing.T) { t.Fatalf("profile list table missing %q in output:\n%s", want, out) } } + for _, unwanted := range []string{"PROFILE", "DingTalk China"} { + if bytes.Contains(buf.Bytes(), []byte(unwanted)) { + t.Fatalf("profile list table should not contain %q in output:\n%s", unwanted, out) + } + } } func TestProfileUseMessageIncludesCorpName(t *testing.T) { @@ -366,9 +571,12 @@ func TestProfileUseMessageIncludesCorpName(t *testing.T) { CorpID: "ding8196", CorpName: "钉钉(中国)信息技术有限公司", }) - for _, want := range []string{"DingTalk China", "组织: 钉钉(中国)信息技术有限公司", "ding8196"} { + for _, want := range []string{"当前组织: 钉钉(中国)信息技术有限公司", "ding8196"} { if !bytes.Contains([]byte(got), []byte(want)) { t.Fatalf("profileUseMessage() missing %q in %q", want, got) } } + if bytes.Contains([]byte(got), []byte("DingTalk China")) { + t.Fatalf("profileUseMessage() should not include profile name when corpName is present: %q", got) + } } diff --git a/internal/auth/portable_store.go b/internal/auth/portable_store.go index 0bc3d256..a32023df 100644 --- a/internal/auth/portable_store.go +++ b/internal/auth/portable_store.go @@ -52,6 +52,9 @@ func PortableAuthTargetPopulated(configDir string) bool { if TokenDataExistsKeychain() { return true } + if _, err := os.Stat(ProfilesPath(configDir)); err == nil { + return true + } if _, err := os.Stat(filepath.Join(configDir, "app.json")); err == nil { return true } @@ -199,7 +202,7 @@ func ImportPortableAuthBundle(configDir string, r io.Reader) (PortableImportRepo func portableConfigFiles(configDir string) ([]string, error) { var files []string - patterns := []string{"app*.json", "mcp_url", "terminal_url"} + patterns := []string{"app*.json", profilesJSONFile, "mcp_url", "terminal_url"} for _, pattern := range patterns { matches, err := filepath.Glob(filepath.Join(configDir, pattern)) if err != nil { diff --git a/internal/auth/portable_store_test.go b/internal/auth/portable_store_test.go index 3e019a5b..95ae727b 100644 --- a/internal/auth/portable_store_test.go +++ b/internal/auth/portable_store_test.go @@ -138,3 +138,76 @@ func TestPortableAuthBundleRoundTripPreservesRefreshToken(t *testing.T) { t.Fatalf("imported app config = %#v, want client ID preserved", cfg) } } + +func TestPortableAuthBundleRoundTripPreservesProfiles(t *testing.T) { + t.Setenv(keychain.DisableKeychainEnv, "1") + SetRuntimeProfile("") + t.Cleanup(func() { SetRuntimeProfile("") }) + + sourceKeychain := filepath.Join(t.TempDir(), "source-keychain") + t.Setenv(keychain.StorageDirEnv, sourceKeychain) + sourceConfig := filepath.Join(t.TempDir(), ".dws") + + tokenA := &TokenData{ + AccessToken: "access-a", + RefreshToken: "refresh-a", + ExpiresAt: time.Now().Add(time.Hour), + RefreshExpAt: time.Now().Add(30 * 24 * time.Hour), + CorpID: "corp_a", + CorpName: "A Org", + ClientID: "client-a", + } + tokenB := &TokenData{ + AccessToken: "access-b", + RefreshToken: "refresh-b", + ExpiresAt: time.Now().Add(time.Hour), + RefreshExpAt: time.Now().Add(30 * 24 * time.Hour), + CorpID: "corp_b", + CorpName: "B Org", + ClientID: "client-b", + } + if err := SaveTokenData(sourceConfig, tokenA); err != nil { + t.Fatalf("SaveTokenData(A) error = %v", err) + } + if err := SaveTokenData(sourceConfig, tokenB); err != nil { + t.Fatalf("SaveTokenData(B) error = %v", err) + } + + var bundle bytes.Buffer + if err := ExportPortableAuthBundle(sourceConfig, &bundle); err != nil { + t.Fatalf("ExportPortableAuthBundle() error = %v", err) + } + + targetKeychain := filepath.Join(t.TempDir(), "target-keychain") + t.Setenv(keychain.StorageDirEnv, targetKeychain) + targetConfig := filepath.Join(t.TempDir(), ".dws") + if _, err := ImportPortableAuthBundle(targetConfig, bytes.NewReader(bundle.Bytes())); err != nil { + t.Fatalf("ImportPortableAuthBundle() error = %v", err) + } + + cfg, err := LoadProfiles(targetConfig) + if err != nil { + t.Fatalf("LoadProfiles() after import error = %v", err) + } + if cfg.PrimaryProfile != "corp_a" || cfg.CurrentProfile != "corp_b" || cfg.PreviousProfile != "corp_a" { + t.Fatalf("profiles after import = %#v", cfg) + } + if len(cfg.Profiles) != 2 { + t.Fatalf("profiles len = %d, want 2: %#v", len(cfg.Profiles), cfg.Profiles) + } + + loadedA, err := LoadTokenDataForProfile(targetConfig, "corp_a") + if err != nil { + t.Fatalf("LoadTokenDataForProfile(A) after import error = %v", err) + } + if loadedA.AccessToken != "access-a" { + t.Fatalf("profile A token = %q, want access-a", loadedA.AccessToken) + } + loadedB, err := LoadTokenDataForProfile(targetConfig, "corp_b") + if err != nil { + t.Fatalf("LoadTokenDataForProfile(B) after import error = %v", err) + } + if loadedB.AccessToken != "access-b" { + t.Fatalf("profile B token = %q, want access-b", loadedB.AccessToken) + } +} From 6491cc545dc8049dda496ffd21687cdb863d7b49 Mon Sep 17 00:00:00 2001 From: "shangguanxuan.sgx" Date: Fri, 26 Jun 2026 19:45:02 +0800 Subject: [PATCH 12/22] chore(install): add branch source installer --- scripts/install-from-branch.sh | 57 ++++++++++++++++++++++++++++++++++ 1 file changed, 57 insertions(+) create mode 100644 scripts/install-from-branch.sh diff --git a/scripts/install-from-branch.sh b/scripts/install-from-branch.sh new file mode 100644 index 00000000..f7d2468a --- /dev/null +++ b/scripts/install-from-branch.sh @@ -0,0 +1,57 @@ +#!/bin/sh +# Copyright 2026 Alibaba Group +# Licensed under the Apache License, Version 2.0 +# +# Build and install dws directly from a Git branch checkout. +# +# Usage: +# curl -fsSL https://raw.githubusercontent.com/shangguanxuan633-lab/dingtalk-workspace-cli/codex/dws-multi-profile-login/scripts/install-from-branch.sh | sh +# +# Environment variables: +# DWS_SOURCE_REPO owner/repo to clone (default: shangguanxuan633-lab/dingtalk-workspace-cli) +# DWS_SOURCE_BRANCH branch to build (default: codex/dws-multi-profile-login) +# DWS_INSTALL_DIR passed through to scripts/install.sh (default there: ~/.local/bin) +# DWS_INSTALL_NAME passed through to scripts/install.sh (default: dws) +# DWS_NO_SKILLS passed through to scripts/install.sh (set 1 to skip skills) +# DWS_KEEP_SOURCE set 1 to keep the temporary source checkout + +set -eu + +REPO="${DWS_SOURCE_REPO:-shangguanxuan633-lab/dingtalk-workspace-cli}" +BRANCH="${DWS_SOURCE_BRANCH:-codex/dws-multi-profile-login}" +KEEP_SOURCE="${DWS_KEEP_SOURCE:-0}" + +say() { + printf ' %s\n' "$@" +} + +err() { + printf ' ❌ %s\n' "$@" >&2 + exit 1 +} + +need_cmd() { + command -v "$1" >/dev/null 2>&1 || err "Missing required command: $1" +} + +need_cmd git +need_cmd sh + +tmpdir="$(mktemp -d 2>/dev/null || mktemp -d -t dws-src)" +cleanup() { + if [ "$KEEP_SOURCE" != "1" ]; then + rm -rf "$tmpdir" + else + say "Source checkout kept at: $tmpdir" + fi +} +trap cleanup EXIT INT TERM + +say "Cloning dws source:" +say " repo: https://github.com/${REPO}.git" +say " branch: ${BRANCH}" + +git clone --depth 1 --branch "$BRANCH" "https://github.com/${REPO}.git" "$tmpdir" + +say "Building and installing from source..." +sh "$tmpdir/scripts/install.sh" From c8197779ccb0fe685952a2bd72b335ed77081bff Mon Sep 17 00:00:00 2001 From: "shangguanxuan.sgx" Date: Mon, 29 Jun 2026 09:39:04 +0800 Subject: [PATCH 13/22] fix(profile): keep global profile out of tool params --- internal/app/profile_product_command_test.go | 158 +++++++++++++++++++ internal/compat/registry.go | 2 +- internal/compat/registry_test.go | 4 +- test/cli_compat/helpers_test.go | 5 + 4 files changed, 167 insertions(+), 2 deletions(-) create mode 100644 internal/app/profile_product_command_test.go diff --git a/internal/app/profile_product_command_test.go b/internal/app/profile_product_command_test.go new file mode 100644 index 00000000..3814725b --- /dev/null +++ b/internal/app/profile_product_command_test.go @@ -0,0 +1,158 @@ +package app + +import ( + "bytes" + "context" + "sync" + "testing" + + authpkg "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/auth" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/compat" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/executor" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/market" + "github.com/spf13/cobra" +) + +func TestProductCommandsAcceptGlobalProfileFlag(t *testing.T) { + const selectedProfile = "corp_profile_matrix" + + products := []struct { + name string + path []string + tool string + }{ + {name: "aitable", path: []string{"aitable", "profile-test", "probe"}, tool: "aitable_profile_probe"}, + {name: "attendance", path: []string{"attendance", "profile-test", "probe"}, tool: "attendance_profile_probe"}, + {name: "calendar", path: []string{"calendar", "profile-test", "probe"}, tool: "calendar_profile_probe"}, + {name: "contact", path: []string{"contact", "profile-test", "probe"}, tool: "contact_profile_probe"}, + {name: "devdoc", path: []string{"devdoc", "profile-test", "probe"}, tool: "devdoc_profile_probe"}, + {name: "ding", path: []string{"ding", "profile-test", "probe"}, tool: "ding_profile_probe"}, + {name: "report", path: []string{"report", "profile-test", "probe"}, tool: "report_profile_probe"}, + {name: "todo", path: []string{"todo", "profile-test", "probe"}, tool: "todo_profile_probe"}, + } + + descriptors := make([]market.ServerDescriptor, 0, len(products)) + for _, product := range products { + descriptors = append(descriptors, profileFlagProductDescriptor(product.name, product.tool)) + } + + capture := &profileFlagRunner{} + oldLoadDynamicCommands := loadDynamicCommandsFn + loadDynamicCommandsFn = func(_ context.Context, _ executor.Runner) []*cobra.Command { + SetDynamicServers(descriptors) + return compat.BuildDynamicCommands(descriptors, capture, nil) + } + authpkg.SetRuntimeProfile("") + ResetRuntimeTokenCache() + t.Cleanup(func() { + loadDynamicCommandsFn = oldLoadDynamicCommands + SetDynamicServers(nil) + authpkg.SetRuntimeProfile("") + ResetRuntimeTokenCache() + }) + + for _, product := range products { + t.Run(product.name, func(t *testing.T) { + capture.reset() + authpkg.SetRuntimeProfile("") + + cmd := NewRootCommand() + var out bytes.Buffer + cmd.SetOut(&out) + cmd.SetErr(&out) + args := append([]string{"-f", "json"}, product.path...) + args = append(args, "--profile", selectedProfile) + cmd.SetArgs(args) + + // Arrange / Act: execute a product command with root --profile after the leaf. + if err := cmd.Execute(); err != nil { + t.Fatalf("Execute(%v) error = %v\noutput:\n%s", args, err, out.String()) + } + + // Assert: the product tool runs under the selected profile without leaking it as a business arg. + call := capture.last() + if call == nil { + t.Fatal("expected product command to invoke runner") + } + if call.product != product.name { + t.Fatalf("canonical product = %q, want %q", call.product, product.name) + } + if call.tool != product.tool { + t.Fatalf("tool = %q, want %q", call.tool, product.tool) + } + if call.profile != selectedProfile { + t.Fatalf("runtime profile at execution = %q, want %q", call.profile, selectedProfile) + } + if _, ok := call.params["profile"]; ok { + t.Fatalf("--profile leaked into business params: %#v", call.params) + } + }) + } +} + +func profileFlagProductDescriptor(product, tool string) market.ServerDescriptor { + return market.ServerDescriptor{ + Key: product, + DisplayName: product, + Endpoint: "https://example.invalid/" + product, + CLI: market.CLIOverlay{ + ID: product, + Command: product, + Groups: map[string]market.CLIGroupDef{ + "profile-test": {Description: "profile-test"}, + }, + ToolOverrides: map[string]market.CLIToolOverride{ + tool: { + CLIName: "probe", + Group: "profile-test", + Description: tool, + RejectPositional: true, + }, + }, + }, + } +} + +type profileFlagCall struct { + product string + tool string + profile string + params map[string]any +} + +type profileFlagRunner struct { + mu sync.Mutex + calls []profileFlagCall +} + +func (r *profileFlagRunner) Run(_ context.Context, invocation executor.Invocation) (executor.Result, error) { + r.mu.Lock() + defer r.mu.Unlock() + params := make(map[string]any, len(invocation.Params)) + for key, value := range invocation.Params { + params[key] = value + } + r.calls = append(r.calls, profileFlagCall{ + product: invocation.CanonicalProduct, + tool: invocation.Tool, + profile: authpkg.RuntimeProfile(), + params: params, + }) + return executor.Result{Invocation: invocation}, nil +} + +func (r *profileFlagRunner) reset() { + r.mu.Lock() + defer r.mu.Unlock() + r.calls = nil +} + +func (r *profileFlagRunner) last() *profileFlagCall { + r.mu.Lock() + defer r.mu.Unlock() + if len(r.calls) == 0 { + return nil + } + call := r.calls[len(r.calls)-1] + return &call +} diff --git a/internal/compat/registry.go b/internal/compat/registry.go index eb01f959..ee557fa0 100644 --- a/internal/compat/registry.go +++ b/internal/compat/registry.go @@ -742,7 +742,7 @@ func collectSchemaFlags(cmd *cobra.Command, bindings []FlagBinding, params map[s "json": true, "params": true, "help": true, "format": true, "fields": true, "jq": true, "debug": true, "verbose": true, "dry-run": true, - "yes": true, "mock": true, "timeout": true, + "yes": true, "mock": true, "profile": true, "timeout": true, "client-id": true, "client-secret": true, } diff --git a/internal/compat/registry_test.go b/internal/compat/registry_test.go index 6c39d2fb..996d2d38 100644 --- a/internal/compat/registry_test.go +++ b/internal/compat/registry_test.go @@ -288,6 +288,7 @@ func TestCollectSchemaFlagsSkipsGlobalFlags(t *testing.T) { cmd.Flags().Bool("verbose", false, "Verbose") cmd.Flags().Bool("dry-run", false, "Dry run") cmd.Flags().String("format", "json", "Format") + cmd.Flags().String("profile", "", "Profile") cmd.Flags().String("json", "", "") cmd.Flags().String("params", "", "") @@ -296,6 +297,7 @@ func TestCollectSchemaFlagsSkipsGlobalFlags(t *testing.T) { _ = cmd.Flags().Set("verbose", "true") _ = cmd.Flags().Set("dry-run", "true") _ = cmd.Flags().Set("format", "table") + _ = cmd.Flags().Set("profile", "corp_profile") params := make(map[string]any) collectSchemaFlags(cmd, nil, params) @@ -304,7 +306,7 @@ func TestCollectSchemaFlagsSkipsGlobalFlags(t *testing.T) { t.Errorf("name = %v, want Bob", params["name"]) } // Global flags should be skipped - for _, skip := range []string{"debug", "verbose", "dry_run", "format"} { + for _, skip := range []string{"debug", "verbose", "dry_run", "format", "profile"} { if _, exists := params[skip]; exists { t.Errorf("%s should be skipped (global flag)", skip) } diff --git a/test/cli_compat/helpers_test.go b/test/cli_compat/helpers_test.go index 1433dd08..754b09dd 100644 --- a/test/cli_compat/helpers_test.go +++ b/test/cli_compat/helpers_test.go @@ -7,6 +7,7 @@ import ( "testing" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/app" + authpkg "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/auth" "github.com/spf13/cobra" ) @@ -92,6 +93,10 @@ func getCapture(t *testing.T) *mcpCallCapture { func setupTestDeps(t *testing.T, _ string) *mcpCallCapture { t.Helper() + authpkg.SetRuntimeProfile("") + t.Cleanup(func() { + authpkg.SetRuntimeProfile("") + }) cap := &mcpCallCapture{} linkCapture(t, cap) return cap From d7a7d286cf2805d7540dc16da46321068900d3a4 Mon Sep 17 00:00:00 2001 From: "shangguanxuan.sgx" Date: Mon, 29 Jun 2026 10:29:50 +0800 Subject: [PATCH 14/22] feat(profile): support csv multi-profile runtime --- .../dws-multi-profile-login/test-cases.md | 618 +++++++++++++++++ internal/app/flags.go | 2 +- internal/app/multi_profile_runner_test.go | 148 +++++ internal/app/profile_args_test.go | 82 +++ internal/app/root.go | 69 ++ internal/app/runner.go | 154 ++++- scripts/dev/test-multi-profile-e2e.sh | 623 ++++++++++++++++++ 7 files changed, 1694 insertions(+), 2 deletions(-) create mode 100644 docs/ralph/dws-multi-profile-login/test-cases.md create mode 100644 internal/app/multi_profile_runner_test.go create mode 100644 internal/app/profile_args_test.go create mode 100755 scripts/dev/test-multi-profile-e2e.sh diff --git a/docs/ralph/dws-multi-profile-login/test-cases.md b/docs/ralph/dws-multi-profile-login/test-cases.md new file mode 100644 index 00000000..ef55e1e2 --- /dev/null +++ b/docs/ralph/dws-multi-profile-login/test-cases.md @@ -0,0 +1,618 @@ +# dws 多组织登录完整测试用例 + +更新时间:2026-06-29 + +## 1. 测试目标 + +验证 dws 多组织登录新增能力在本地隔离环境下完整可用: + +- 多个组织 profile 可独立保存、刷新、切换、删除和迁移。 +- 当前组织、主组织、上一个组织指针行为符合 PRD F1-F8。 +- 业务命令可通过全局 `--profile` 进行单次组织覆盖,也可通过 `--profile corpA,corpB` 或 `--profile corpA, corpB` 一次性读取多个组织,且不修改持久 current profile。 +- 所有涉及 TUI / 交互选择的关键命令,都存在可由 Agent 或脚本执行的机器指令路径。 +- `profiles.json` 只存非敏感元数据,不落 access token、refresh token、persistent code、client secret。 + +## 2. 自动化入口 + +主测试脚本: + +```bash +bash scripts/dev/test-multi-profile-e2e.sh +``` + +调试模式: + +```bash +bash scripts/dev/test-multi-profile-e2e.sh --skip-go-tests --verbose +bash scripts/dev/test-multi-profile-e2e.sh --keep-workdir +``` + +脚本行为: + +- 使用临时 `DWS_CONFIG_DIR`、`DWS_KEYCHAIN_DIR`、`DWS_CACHE_DIR`。 +- 设置 `DWS_DISABLE_KEYCHAIN=1`,避免写入真实系统 Keychain。 +- 构建临时 `dws` 二进制。 +- 通过生产 auth 存储 API seed 登录后的 token 结果,不依赖真实扫码。 +- 使用真实 CLI 命令验证 profile/auth 命令面和状态变化。 + +## 2.1 多 profile 参数设计规范 + +核心规范参照 `lark-cli`:profile 仍是一个全局 string flag,多值由同一个 flag 值承载 CSV 列表。 + +- 推荐写法:`dws --profile corpA,corpB contact user get-self --format json`。 +- 容错写法:`dws --profile corpA, corpB contact user get-self --format json`,CLI 在 Cobra 解析前规整为 `corpA,corpB`。 +- 兼容目标:保持 `lark-cli` 一类 CSV 多值参数的简单心智模型,同时吸收钉钉历史 CLI 对逗号列表的支持方式。 +- 非目标:不把 `--profile corpA --profile corpB` 作为主推荐 API;重复 flag 若后续支持,只能作为兼容增强,不改变 CSV 为主的规范。 + +## 3. 覆盖矩阵 + +| PRD 功能 | 覆盖方式 | 用例 | +|---|---|---| +| F1 多组织登录写入 profile | seed token + CLI list/status/token assert | TC-03, TC-04, TC-05 | +| F2 profile 元数据列表 | `dws profile list --format json/table` | TC-02, TC-03, TC-04 | +| F3 切换当前组织 | `profile switch/use `、`profile switch -` | TC-07, TC-08 | +| F4 单次命令临时指定组织 | `dws --profile ... auth status`、`auth status --profile ...` | TC-10 | +| F5 按 profile 查看认证状态 | `auth status --format json` | TC-03, TC-10 | +| F6 退出与重置 | `auth reset`;`auth logout` 由 Go 回归覆盖 | TC-12, GT-05 | +| F7 legacy 单槽迁移 | seed legacy `auth-token` 后触发 `profile list` | TC-13 | +| F8 agent 跨组织聚合原语 | `--profile corpA,corpB` / `--profile corpA, corpB` 聚合读取 + `profile list` 枚举 | TC-11, MTC-01 | +| TUI 机器替代路径 | help surface + 非交互失败断言 | MTC-01 至 MTC-07 | + +## 4. 测试数据 + +自动化脚本使用以下虚拟组织: + +| 组织 | corpId | corpName | userId | access token | +|---|---|---|---|---| +| Alpha | `corp_alpha` | `Alpha Org` | `user_alpha` | `access-alpha-v1` | +| Beta | `corp_beta` | `Beta Org` | `user_beta` | `access-beta-v1/v2` | +| Gamma | `corp_gamma` | `Beta Org` | `user_gamma` | `access-gamma-v1` | +| Legacy | `corp_legacy` | `Legacy Org` | `user_legacy` | `access-legacy-v1` | + +Gamma 故意与 Beta 使用相同 `corpName`,用于验证重复组织名的稳定 fallback name。 + +## 5. 自动化黑盒用例 + +### TC-01 命令面与机器指令入口 + +目的:确认关键交互能力都有非 TUI / Agent 可执行入口。 + +步骤: + +```bash +dws --help +dws profile --help +dws auth login --help +dws skill setup --help +dws upgrade --help +dws dev connect --help +dws doc delete --help +dws aitable base delete --help +dws auth --help +``` + +断言: + +- 根命令展示 `--profile`、`--yes`、`--dry-run`。 +- `profile` 展示 `list`、`switch`、`use`、全局 `--profile`。 +- `auth login` 展示 `--device`、`--token`、`--recommend`、`--yes`。 +- `skill setup` 展示 `--mode`、`--target`、`--yes`、`--skill`、`--exclude`。 +- `upgrade` 展示 `--dry-run`、`--yes`。 +- `dev connect` 展示 `--robot-client-id`、`--robot-client-secret`、`--unified-app-id`、`--agent-cmd`、`--daemon`。 +- 删除类命令展示 `--yes`。 +- `auth` 命令组不暴露 `switch`。 + +### TC-02 空 profile 列表 + +步骤: + +```bash +dws profile list --format json +``` + +断言: + +- `success=true`。 +- `profiles=[]`。 +- `primaryProfile/currentProfile/previousProfile` 为空。 + +### TC-03 首次组织登录后创建 primary/current profile + +前置: + +- 通过 helper seed `corp_alpha` token。 + +步骤: + +```bash +dws profile list --format json +dws auth status --format json +``` + +断言: + +- `profiles` 数量为 1。 +- `primaryProfile=corp_alpha`。 +- `currentProfile=corp_alpha`。 +- `previousProfile` 为空。 +- `auth status` 返回 Alpha 的 `corpId/corpName/userId`。 +- 默认 token mirror 指向 `corp_alpha`。 +- corp-scoped token `auth-token:corp_alpha` 存在。 +- `profiles.json` 不包含敏感字段。 + +### TC-04 第二组织登录不覆盖第一组织 + +前置: + +- 已存在 `corp_alpha`。 +- seed `corp_beta` token。 + +步骤: + +```bash +dws profile list --format json +``` + +断言: + +- `profiles` 数量为 2。 +- `primaryProfile=corp_alpha`。 +- `currentProfile=corp_beta`。 +- `previousProfile=corp_alpha`。 +- Alpha token 仍为 `access-alpha-v1`。 +- Beta token 为 `access-beta-v1`。 +- legacy mirror 指向 `corp_beta`。 + +### TC-05 同组织重复登录只刷新 token,不新增 profile + +前置: + +- 已存在 `corp_beta`。 +- 再次 seed `corp_beta`,access token 改为 `access-beta-v2`。 + +步骤: + +```bash +dws profile list --format json +``` + +断言: + +- `profiles` 数量仍为 2。 +- `currentProfile=corp_beta`。 +- `previousProfile=corp_alpha`。 +- `auth-token:corp_beta` 的 access token 更新为 `access-beta-v2`。 + +### TC-06 重复组织名生成稳定 fallback name + +前置: + +- 已存在 `corp_beta`,`corpName=Beta Org`。 +- seed `corp_gamma`,`corpName=Beta Org`。 + +步骤: + +```bash +dws profile list --format json +``` + +断言: + +- `profiles` 数量为 3。 +- `currentProfile=corp_gamma`。 +- `previousProfile=corp_beta`。 +- `corp_gamma` 的 `corpName=Beta Org`。 +- `corp_gamma` 的本地 profile name 不等于裸 `Beta Org`,而是带 corpId 后缀的稳定 fallback。 +- JSON 输出不暴露本地 `name` 字段。 + +### TC-07 按 corpId 切换组织并同步 legacy mirror + +步骤: + +```bash +dws profile switch corp_alpha --format json +dws profile switch corp_beta --format table +``` + +断言: + +- 第一次切换后 `currentProfile=corp_alpha`。 +- 第一次切换后 `previousProfile=corp_gamma`。 +- JSON 输出包含 Alpha 的 `corpId/corpName`,且 `isCurrent=true`。 +- legacy mirror 指向 `corp_alpha`。 +- 第二次切换后 `currentProfile=corp_beta`。 +- 第二次切换后 `previousProfile=corp_alpha`。 +- 表格输出包含 `Beta Org` 和 `corp_beta`。 +- legacy mirror 指向 `corp_beta`。 + +### TC-08 使用 previousProfile 快速切回 + +步骤: + +```bash +dws profile switch - --format json +``` + +断言: + +- 当前组织从 `corp_beta` 切回 `corp_alpha`。 +- `previousProfile=corp_beta`。 +- JSON 输出包含 Alpha,并标记为 current。 + +### TC-09 `profile use` 兼容 switch 语义 + +步骤: + +```bash +dws profile use corp_gamma --format json +``` + +断言: + +- `currentProfile=corp_gamma`。 +- `previousProfile=corp_alpha`。 +- JSON 输出包含 Gamma。 +- legacy mirror 指向 `corp_gamma`。 + +### TC-10 单次 profile override 不修改 currentProfile + +步骤: + +```bash +dws --profile corp_alpha auth status --format json +dws auth status --profile corp_beta --format json +dws auth status --format json +``` + +断言: + +- 第一条返回 Alpha。 +- 第二条返回 Beta。 +- 两条 override 后 `currentProfile` 仍为 `corp_gamma`。 +- 第三条默认返回 Gamma。 +- `previousProfile` 不因 override 改变。 + +### TC-11 多 profile 一次性读取信息 + +步骤: + +```bash +dws --mock --profile corp_alpha, corp_beta contact user get-self --format json +dws --mock contact user get-self --profile corp_alpha, corp_beta --format json +``` + +断言: + +- 输出为聚合对象,`multiProfile=true`。 +- `success=true`。 +- `summary.total=2`、`summary.succeeded=2`、`summary.failed=0`。 +- `profiles[0].corpId=corp_alpha`,`profiles[1].corpId=corp_beta`。 +- 每个 `profiles[i].ok=true`,且每个组织都有独立 `result`。 +- 执行后 `currentProfile` 仍为 `corp_gamma`,`previousProfile` 仍为 `corp_alpha`。 +- `--profile` 放在根命令后或 leaf 命令后都可解析。 +- `--profile corp_alpha,corp_alpha,corp_beta` 按 resolved `corpId` 去重后只执行 Alpha/Beta 两个组织。 +- 若存在历史 profile name 本身为 `alpha,beta`,优先按单 profile 精确匹配,不触发聚合,保持向前兼容。 + +### TC-12 `auth reset` 清除所有本地认证态 + +前置: + +- 保存测试 app config。 + +步骤: + +```bash +dws auth reset +``` + +断言: + +- 输出包含 `[OK]`。 +- `profiles.json` 清空或不存在。 +- 所有 profile-scoped token 删除。 +- legacy `auth-token` 删除。 +- `token.json` marker 删除。 +- app config 删除。 + +### TC-13 legacy 单槽自动迁移 + +前置: + +- 清空 profiles。 +- 只写入 legacy `auth-token`,token 中包含 `corp_legacy`。 + +步骤: + +```bash +dws profile list --format json +``` + +断言: + +- 自动生成 profile。 +- `primaryProfile=corp_legacy`。 +- `currentProfile=corp_legacy`。 +- `previousProfile` 为空。 +- corp-scoped `auth-token:corp_legacy` 存在。 +- legacy mirror 仍可读取。 + +## 6. Go 回归用例组 + +脚本默认先执行: + +```bash +go test -timeout 180s -count=1 ./internal/auth ./internal/app ./test/cli -run 'Test(MultiProfile|RuntimeProfile|ProfileFlagArgs|PreparseProfileFlag|NormalizeProcessProfileArgs|CommaSeparated|CommaNamed|DeleteProfile|UpsertProfile|LoadProfiles|LegacyKeychain|WriteProfile|ProfileList|ProfileUse|ProfileSwitch|AuthCommandDoesNotExposeSwitch|AuthStatus|AuthLogout|AuthLogin|ResolveAuthLogin|EnrichAuthLogin|RootHelp|RootShortHelp|RootCommand|ProductCommandsAcceptGlobalProfileFlag)' +``` + +### GT-01 auth/profile 存储单元回归 + +覆盖: + +- `SaveTokenData` 写入 corp-scoped slot。 +- `UpsertProfileFromToken` 新增/刷新 profile。 +- `ResolveProfile` 按 corpId/name/corpName 查找。 +- `SetCurrentProfile` 和 `UsePreviousProfile` 指针更新。 +- `DeleteTokenDataForProfile` 单 profile 删除。 +- legacy token migration。 + +### GT-02 profile 命令回归 + +覆盖: + +- `profile list --format json` 包含 `corpName`。 +- `profile switch/use` 输出组织名和 corpId。 +- `profile switch -` toggle。 +- 无参数交互路径调用 selector。 +- 非交互无参数返回 validation error。 +- 冲突 selector 返回 validation error。 + +### GT-03 auth 命令回归 + +覆盖: + +- `auth status` 默认使用 current profile。 +- `auth status --profile` 不修改 current profile。 +- `auth logout` 默认删除全部 profile 且保留 app config。 +- `auth logout --profile` 只删除指定 profile。 +- `auth reset` 删除 token、profiles、marker、app config。 +- `auth login` 默认强制进入授权流程。 +- `auth login --profile` 可解析目标 corpId。 +- 登录后可从 contact profile 补充 corpName/userName。 + +### GT-04 全局 `--profile` 业务命令注入 + +覆盖: + +- 每个产品命令接受全局 `--profile`。 +- `--profile` 不泄漏为业务参数。 +- runtime profile 在调用 runner 前已设置。 +- `--profile corpA,corpB` 进入多组织聚合读取。 +- `--profile corpA, corpB` 在 Cobra 解析前规整为同一个 profile selector,避免 `corpB` 被误判为 command/arg。 +- 聚合读取按 resolved `corpId` 去重,并在执行后恢复原始 runtime profile。 +- 含逗号的历史 profile name 仍按单 profile 解析。 + +### GT-05 命令可见性和兼容性 + +覆盖: + +- root help 展示 profile。 +- root help 展示全局 `--profile`。 +- `auth switch` 不暴露。 +- 旧命令和 docs compatibility 不退化。 + +## 7. TUI / 交互入口机器替代审计用例 + +### MTC-01 profile TUI + +交互入口: + +```bash +dws profile switch +dws profile use +``` + +机器指令: + +```bash +dws profile switch +dws profile switch --corpId +dws profile switch --name +dws profile switch - +dws --profile +dws --profile , +dws --profile , +``` + +预期: + +- 交互终端可展示 TUI。 +- 非交互环境无 selector 时失败并提示传入 selector。 +- 机器指令可完成同等切换、单次覆盖或多组织聚合读取能力。 + +### MTC-02 auth login 交互授权 + +交互入口: + +```bash +dws auth login +dws auth login --recommend +``` + +机器指令: + +```bash +dws auth login --device --format json +dws auth login --token --format json +dws auth login --recommend --yes --format json +dws auth login --profile --format json +``` + +预期: + +- 无头环境可走 device flow。 +- Agent 可用 `--recommend --yes` 跳过登录后推荐授权 TUI。 +- 目标组织可由全局 `--profile` 指定。 +- OAuth 浏览器扫码本身属于授权链路,不视为 CLI TUI 强依赖。 + +### MTC-03 skill setup 模式选择和确认 + +交互入口: + +```bash +dws skill setup +``` + +机器指令: + +```bash +dws skill setup --mode mono --yes +dws skill setup --mode multi --target claude --yes +dws skill setup --mode multi --skill aitable --skill calendar --yes +dws skill setup --mode multi --exclude live --yes +``` + +预期: + +- 非交互未指定 mode 时默认 mono。 +- 指定 `--mode` + `--yes` 可完全绕过 TUI。 +- multi 子 skill 可通过 `--skill/--exclude` 明确选择。 + +### MTC-04 upgrade 确认 + +交互入口: + +```bash +dws upgrade +dws upgrade --rollback +``` + +机器指令: + +```bash +dws upgrade --dry-run +dws upgrade --yes +dws upgrade --rollback --yes +dws upgrade --check --format json +dws upgrade --list --format json +``` + +预期: + +- Agent 可先 `--dry-run` 获取计划。 +- 用户确认后追加 `--yes` 执行。 +- 查询类命令可用 JSON 输出。 + +### MTC-05 dev connect 建联引导 + +交互入口: + +```bash +dws dev connect +``` + +机器指令: + +```bash +dws dev connect --channel --robot-client-id --robot-client-secret +dws dev connect --channel --unified-app-id +dws dev connect --channel custom --agent-cmd "" --robot-client-id --robot-client-secret +dws dev connect --daemon --channel --robot-client-id --robot-client-secret +``` + +预期: + +- 非交互缺凭证时 fail-fast,不阻塞等待输入。 +- 现成凭证和 unified app id 均可绕过建联引导。 +- 自研 agent 可用 `--agent-cmd`。 + +### MTC-06 删除/敏感操作确认 + +交互入口: + +```bash +dws doc delete ... +dws drive delete ... +dws aitable base delete ... +dws todo task delete ... +``` + +机器指令: + +```bash +dws --dry-run +dws --yes +``` + +预期: + +- 默认需要确认。 +- `--dry-run` 可预览。 +- 用户确认后 `--yes` 可由 Agent 执行。 + +### MTC-07 PAT 批量授权 + +交互入口: + +```bash +dws pat chmod --products ... +dws pat chmod --recommend ... +``` + +机器指令: + +```bash +dws pat chmod ... --dry-run --format json +dws pat chmod ... --yes --format json +``` + +预期: + +- 批量授权未加 `--yes` 时阻断。 +- Agent 先展示 dry-run plan,用户明确确认后再追加 `--yes`。 + +## 8. 已知残余风险 + +| 风险 | 说明 | 建议 | +|---|---|---| +| 部分 legacy compat delete path 仍会读 stdin 确认 | `internal/compat/registry.go` 中 `_blocked` 后仍存在 `Confirm? (yes/no)` 交互路径 | 后续可改成非交互环境直接 validation,并提示 `--yes` | +| 真实 OAuth 登录未在黑盒脚本中扫码验证 | 自动脚本 seed 登录后 token,避免人工和网络依赖 | 发布前可追加一次手工 UAT:真实 `auth login` 登录 A/B 两个组织 | +| 远端 revoke/logout 不在黑盒脚本中直连验证 | 网络不稳定且可能影响真实环境 | 已由 Go 回归用 mock/隔离环境覆盖本地删除语义;真实环境只做冒烟 | +| TUI 视觉细节不在脚本中截图校验 | 脚本关注机器链路和非交互替代路径 | TUI 视觉可用人工验收或独立截图测试 | + +## 9. 手工 UAT 建议 + +在真实账号拥有两个钉钉组织的环境中执行: + +```bash +dws auth login --format json +dws profile list --format json +dws auth login --format json +dws profile list --format json +dws profile switch +dws auth status +dws --profile contact user get-self --format json +dws --profile , contact user get-self --format json +dws profile switch - +dws auth logout --profile +dws profile list --format json +dws auth logout +``` + +验收重点: + +- 第二次 `auth login` 能选择另一个组织,不因当前 token 有效而跳过授权。 +- `profile list` 表格和 JSON 均展示组织名。 +- `--profile` 取数返回对应组织身份。 +- `--profile A,B` 返回聚合结果,且不改变 current profile。 +- 单 profile logout 不影响另一个组织。 +- 默认 logout 清空全部组织登录态。 + +## 10. 通过标准 + +必须同时满足: + +- `bash scripts/dev/test-multi-profile-e2e.sh` 通过。 +- `profiles.json` 无敏感字段。 +- 所有 P0 功能 F1-F7 至少有一个自动化用例覆盖。 +- 关键 TUI/交互入口均有机器指令替代路径。 +- 手工 UAT 未发现真实 OAuth 组织选择和权限链路阻断。 diff --git a/internal/app/flags.go b/internal/app/flags.go index 6757c96e..1918002c 100644 --- a/internal/app/flags.go +++ b/internal/app/flags.go @@ -47,7 +47,7 @@ func bindPersistentFlags(cmd *cobra.Command, flags *GlobalFlags) { cmd.PersistentFlags().BoolVar(&flags.Mock, "mock", false, "使用 Mock 数据 (开发调试用)") cmd.PersistentFlags().StringVarP(&flags.Output, "output", "o", "", "Write command output to a file") _ = cmd.PersistentFlags().MarkHidden("output") - cmd.PersistentFlags().StringVar(&flags.Profile, "profile", "", "一次性指定本次命令使用的组织 profile 名或 corpId") + cmd.PersistentFlags().StringVar(&flags.Profile, "profile", "", "一次性指定本次命令使用的组织 profile 名或 corpId;多个按 CSV 逗号分隔,如 corpA,corpB") cmd.PersistentFlags().IntVar(&flags.Timeout, "timeout", 30, "HTTP 请求超时时间 (秒)") cmd.PersistentFlags().StringVar(&flags.Token, "token", "", "Override the configured API token") _ = cmd.PersistentFlags().MarkHidden("token") diff --git a/internal/app/multi_profile_runner_test.go b/internal/app/multi_profile_runner_test.go new file mode 100644 index 00000000..f4ff87c5 --- /dev/null +++ b/internal/app/multi_profile_runner_test.go @@ -0,0 +1,148 @@ +package app + +import ( + "context" + "strings" + "testing" + + authpkg "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/auth" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/executor" +) + +func TestRuntimeRunnerAggregatesCommaSeparatedProfiles(t *testing.T) { + setupAuthLogoutProfiles(t, + authLogoutTestToken("corp_a"), + authLogoutTestToken("corp_b"), + ) + authpkg.SetRuntimeProfile("corp_a, corp_b") + + runner := &runtimeRunner{fallback: multiProfileFallbackRunner{}} + result, err := runner.Run(context.Background(), executor.Invocation{ + Kind: "helper_invocation", + CanonicalProduct: "contact", + Tool: "get_current_user_profile", + Params: map[string]any{"limit": 10}, + }) + if err != nil { + t.Fatalf("Run() error = %v", err) + } + if got := authpkg.RuntimeProfile(); got != "corp_a, corp_b" { + t.Fatalf("runtime profile after Run = %q, want restored raw selector", got) + } + + content := result.Response["content"].(map[string]any) + if content["multiProfile"] != true { + t.Fatalf("multiProfile = %#v, want true", content["multiProfile"]) + } + if content["success"] != true { + t.Fatalf("success = %#v, want true", content["success"]) + } + profiles := content["profiles"].([]any) + if len(profiles) != 2 { + t.Fatalf("profiles len = %d, want 2", len(profiles)) + } + for i, wantCorpID := range []string{"corp_a", "corp_b"} { + entry := profiles[i].(map[string]any) + if entry["corpId"] != wantCorpID { + t.Fatalf("profiles[%d].corpId = %#v, want %q", i, entry["corpId"], wantCorpID) + } + if entry["ok"] != true { + t.Fatalf("profiles[%d].ok = %#v, want true", i, entry["ok"]) + } + resultPayload := entry["result"].(map[string]any) + if resultPayload["runtimeProfile"] != wantCorpID { + t.Fatalf("profiles[%d].result.runtimeProfile = %#v, want %q", i, resultPayload["runtimeProfile"], wantCorpID) + } + } +} + +func TestRuntimeRunnerDeduplicatesCommaSeparatedProfilesByCorpID(t *testing.T) { + configDir := setupAuthLogoutProfiles(t, authLogoutTestToken("corp_a"), authLogoutTestToken("corp_b")) + authpkg.SetRuntimeProfile("corp_a, corp_a org,corp_b") + + selections, multi, err := resolveMultiProfileSelections(configDir, authpkg.RuntimeProfile()) + if err != nil { + t.Fatalf("resolveMultiProfileSelections() error = %v", err) + } + if !multi { + t.Fatal("multi = false, want true") + } + if len(selections) != 2 { + t.Fatalf("selections len = %d, want 2", len(selections)) + } + if selections[0].Profile.CorpID != "corp_a" || selections[1].Profile.CorpID != "corp_b" { + t.Fatalf("resolved corp IDs = %q, %q; want corp_a, corp_b", selections[0].Profile.CorpID, selections[1].Profile.CorpID) + } +} + +func TestRuntimeRunnerKeepsSingleProfileBehavior(t *testing.T) { + setupAuthLogoutProfiles(t, authLogoutTestToken("corp_a"), authLogoutTestToken("corp_b")) + authpkg.SetRuntimeProfile("corp_a") + + runner := &runtimeRunner{fallback: multiProfileFallbackRunner{}} + result, err := runner.Run(context.Background(), executor.Invocation{ + Kind: "helper_invocation", + CanonicalProduct: "contact", + Tool: "get_current_user_profile", + }) + if err != nil { + t.Fatalf("Run() error = %v", err) + } + if _, ok := result.Response["content"].(map[string]any)["multiProfile"]; ok { + t.Fatalf("single profile unexpectedly returned aggregate content: %#v", result.Response) + } + if got := authpkg.RuntimeProfile(); got != "corp_a" { + t.Fatalf("runtime profile after Run = %q, want corp_a", got) + } +} + +func TestCommaNamedProfileStillResolvesAsSingleProfile(t *testing.T) { + configDir := setupAuthLogoutProfiles(t, authLogoutTestToken("corp_comma"), authLogoutTestToken("corp_other")) + cfg, err := authpkg.LoadProfiles(configDir) + if err != nil { + t.Fatalf("LoadProfiles() error = %v", err) + } + for i := range cfg.Profiles { + if cfg.Profiles[i].CorpID == "corp_comma" { + cfg.Profiles[i].Name = "alpha,beta" + } + } + if err := authpkg.SaveProfiles(configDir, cfg); err != nil { + t.Fatalf("SaveProfiles() error = %v", err) + } + + selections, multi, err := resolveMultiProfileSelections(configDir, "alpha,beta") + if err != nil { + t.Fatalf("resolveMultiProfileSelections() error = %v", err) + } + if multi { + t.Fatalf("multi = true, want false; selections=%#v", selections) + } +} + +func TestCommaSeparatedProfileRejectsEmptySelector(t *testing.T) { + configDir := setupAuthLogoutProfiles(t, authLogoutTestToken("corp_a"), authLogoutTestToken("corp_b")) + + _, _, err := resolveMultiProfileSelections(configDir, "corp_a,,corp_b") + if err == nil { + t.Fatal("resolveMultiProfileSelections() error = nil, want validation error") + } + if !strings.Contains(err.Error(), "empty profile selector") { + t.Fatalf("error = %q, want empty profile selector", err.Error()) + } +} + +type multiProfileFallbackRunner struct{} + +func (multiProfileFallbackRunner) Run(_ context.Context, invocation executor.Invocation) (executor.Result, error) { + invocation.Implemented = true + return executor.Result{ + Invocation: invocation, + Response: map[string]any{ + "content": map[string]any{ + "runtimeProfile": authpkg.RuntimeProfile(), + "tool": invocation.Tool, + }, + }, + }, nil +} diff --git a/internal/app/profile_args_test.go b/internal/app/profile_args_test.go new file mode 100644 index 00000000..031992df --- /dev/null +++ b/internal/app/profile_args_test.go @@ -0,0 +1,82 @@ +package app + +import ( + "os" + "reflect" + "testing" +) + +func TestNormalizeProfileFlagArgsAcceptsUnquotedCommaContinuation(t *testing.T) { + cases := []struct { + name string + args []string + want []string + }{ + { + name: "root profile before command", + args: []string{"--mock", "--profile", "corpA,", "corpB", "contact", "user", "get-self"}, + want: []string{"--mock", "--profile", "corpA,corpB", "contact", "user", "get-self"}, + }, + { + name: "profile after leaf command", + args: []string{"contact", "user", "get-self", "--profile", "corpA,", "corpB", "--format", "json"}, + want: []string{"contact", "user", "get-self", "--profile", "corpA,corpB", "--format", "json"}, + }, + { + name: "equals form", + args: []string{"--profile=corpA,", "corpB", "contact", "user", "get-self"}, + want: []string{"--profile=corpA,corpB", "contact", "user", "get-self"}, + }, + { + name: "three profiles", + args: []string{"--profile", "corpA,", "corpB,", "corpC", "contact", "user", "get-self"}, + want: []string{"--profile", "corpA,corpB,corpC", "contact", "user", "get-self"}, + }, + { + name: "already quoted by shell remains unchanged", + args: []string{"--profile", "corpA, corpB", "contact", "user", "get-self"}, + want: []string{"--profile", "corpA, corpB", "contact", "user", "get-self"}, + }, + { + name: "single profile remains unchanged", + args: []string{"--profile", "corpA", "contact", "user", "get-self"}, + want: []string{"--profile", "corpA", "contact", "user", "get-self"}, + }, + { + name: "trailing comma before next flag remains validation input", + args: []string{"--profile", "corpA,", "--format", "json", "contact", "user", "get-self"}, + want: []string{"--profile", "corpA,", "--format", "json", "contact", "user", "get-self"}, + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + got, _ := normalizeProfileFlagArgs(tc.args) + if !reflect.DeepEqual(got, tc.want) { + t.Fatalf("normalizeProfileFlagArgs() = %#v, want %#v", got, tc.want) + } + }) + } +} + +func TestPreparseProfileFlagUsesNormalizedProfileArgs(t *testing.T) { + got := preparseProfileFlag([]string{"--profile", "corpA,", "corpB", "contact", "user", "get-self"}) + if got != "corpA,corpB" { + t.Fatalf("preparseProfileFlag() = %q, want corpA,corpB", got) + } +} + +func TestNormalizeProcessProfileArgsRestoresOriginalArgv(t *testing.T) { + oldArgs := os.Args + t.Cleanup(func() { os.Args = oldArgs }) + + os.Args = []string{"dws", "--profile", "corpA,", "corpB", "contact", "user", "get-self"} + restore := normalizeProcessProfileArgs() + if want := []string{"dws", "--profile", "corpA,corpB", "contact", "user", "get-self"}; !reflect.DeepEqual(os.Args, want) { + t.Fatalf("os.Args after normalize = %#v, want %#v", os.Args, want) + } + restore() + if want := []string{"dws", "--profile", "corpA,", "corpB", "contact", "user", "get-self"}; !reflect.DeepEqual(os.Args, want) { + t.Fatalf("os.Args after restore = %#v, want %#v", os.Args, want) + } +} diff --git a/internal/app/root.go b/internal/app/root.go index 766f9652..316503c8 100644 --- a/internal/app/root.go +++ b/internal/app/root.go @@ -67,6 +67,9 @@ func Execute() (exitCode int) { } }() + restoreArgs := normalizeProcessProfileArgs() + defer restoreArgs() + timing := NewTimingCollector() defer func() { StopAllStdioClients() // Ensure child processes are terminated on exit @@ -376,6 +379,7 @@ func NewRootCommandWithEngine(rootCtx context.Context, engine *pipeline.Engine) } func preparseProfileFlag(args []string) string { + args, _ = normalizeProfileFlagArgs(args) for i := 0; i < len(args); i++ { arg := strings.TrimSpace(args[i]) switch { @@ -388,6 +392,71 @@ func preparseProfileFlag(args []string) string { return "" } +func normalizeProcessProfileArgs() func() { + original := append([]string(nil), os.Args...) + if len(os.Args) > 1 { + if normalized, changed := normalizeProfileFlagArgs(os.Args[1:]); changed { + os.Args = append([]string{os.Args[0]}, normalized...) + } + } + return func() { + os.Args = original + } +} + +func normalizeProfileFlagArgs(args []string) ([]string, bool) { + if len(args) == 0 { + return args, false + } + out := make([]string, 0, len(args)) + for i := 0; i < len(args); i++ { + arg := args[i] + trimmed := strings.TrimSpace(arg) + switch { + case trimmed == "--profile": + out = append(out, arg) + if i+1 >= len(args) { + continue + } + value, next := collectProfileFlagValue(args[i+1], args, i+2) + out = append(out, value) + i = next - 1 + case strings.HasPrefix(trimmed, "--profile="): + value, next := collectProfileFlagValue(strings.TrimPrefix(trimmed, "--profile="), args, i+1) + out = append(out, "--profile="+value) + i = next - 1 + default: + out = append(out, arg) + } + } + return out, argsChanged(args, out) +} + +func collectProfileFlagValue(first string, args []string, next int) (string, int) { + parts := []string{strings.TrimSpace(first)} + for len(parts) > 0 && strings.HasSuffix(strings.TrimSpace(parts[len(parts)-1]), ",") && next < len(args) { + candidate := strings.TrimSpace(args[next]) + if candidate == "" || strings.HasPrefix(candidate, "-") { + break + } + parts = append(parts, candidate) + next++ + } + return strings.Join(parts, ""), next +} + +func argsChanged(before, after []string) bool { + if len(before) != len(after) { + return true + } + for i := range before { + if before[i] != after[i] { + return true + } + } + return false +} + func newAuthCommand(patCaller edition.ToolCaller) *cobra.Command { return buildAuthCommand(patCaller) } diff --git a/internal/app/runner.go b/internal/app/runner.go index 0933033a..9099d323 100644 --- a/internal/app/runner.go +++ b/internal/app/runner.go @@ -161,6 +161,18 @@ func (r *runtimeRunner) Run(ctx context.Context, invocation executor.Invocation) // invocations within the same process free. logHostOwnedPATDecisionOnce() + selections, multi, err := resolveMultiProfileSelections(defaultConfigDir(), authpkg.RuntimeProfile()) + if err != nil { + return executor.Result{}, apperrors.NewValidation(err.Error()) + } + if multi { + return r.runMultiProfile(ctx, invocation, selections) + } + + return r.runSingle(ctx, invocation, true) +} + +func (r *runtimeRunner) runSingle(ctx context.Context, invocation executor.Invocation, prefetchToken bool) (executor.Result, error) { if r.loader == nil || r.transport == nil { return r.fallback.Run(ctx, invocation) } @@ -178,7 +190,9 @@ func (r *runtimeRunner) Run(ctx context.Context, invocation executor.Invocation) // Prefetch the Keychain token in the background. Keychain access costs // ~70ms on macOS; starting it here lets the load overlap with endpoint // resolution and catalog loading below. - go getCachedRuntimeToken(ctx) + if prefetchToken { + go getCachedRuntimeToken(ctx) + } if shouldUseDirectRuntime(invocation) { if endpoint, ok := directRuntimeEndpoint(invocation.CanonicalProduct, invocation.Tool); ok { @@ -238,6 +252,144 @@ func (r *runtimeRunner) Run(ctx context.Context, invocation executor.Invocation) return r.executeInvocation(ctx, endpoint, invocation) } +type multiProfileSelection struct { + Selector string + Profile authpkg.Profile +} + +func resolveMultiProfileSelections(configDir, rawSelector string) ([]multiProfileSelection, bool, error) { + rawSelector = strings.TrimSpace(rawSelector) + if rawSelector == "" || !strings.Contains(rawSelector, ",") { + return nil, false, nil + } + if p, err := authpkg.ResolveProfile(configDir, rawSelector); err == nil && p != nil { + return nil, false, nil + } + + parts := strings.Split(rawSelector, ",") + selections := make([]multiProfileSelection, 0, len(parts)) + seen := make(map[string]bool, len(parts)) + for _, part := range parts { + selector := strings.TrimSpace(part) + if selector == "" { + return nil, false, fmt.Errorf("--profile contains an empty profile selector: %q", rawSelector) + } + profile, err := authpkg.ResolveProfile(configDir, selector) + if err != nil { + return nil, false, err + } + if profile == nil { + return nil, false, fmt.Errorf("profile %q not found", selector) + } + if seen[profile.CorpID] { + continue + } + seen[profile.CorpID] = true + selections = append(selections, multiProfileSelection{ + Selector: selector, + Profile: *profile, + }) + } + if len(selections) == 0 { + return nil, false, nil + } + return selections, true, nil +} + +func (r *runtimeRunner) runMultiProfile(ctx context.Context, invocation executor.Invocation, selections []multiProfileSelection) (executor.Result, error) { + previousProfile := authpkg.RuntimeProfile() + defer authpkg.SetRuntimeProfile(previousProfile) + + entries := make([]any, 0, len(selections)) + succeeded := 0 + failed := 0 + + for _, selection := range selections { + authpkg.SetRuntimeProfile(selection.Profile.CorpID) + result, err := r.runSingle(ctx, cloneInvocation(invocation), false) + + entry := map[string]any{ + "selector": selection.Selector, + "corpId": selection.Profile.CorpID, + "corpName": selection.Profile.CorpName, + "ok": err == nil, + } + if err != nil { + failed++ + entry["error"] = multiProfileErrorPayload(err) + } else { + succeeded++ + if payload := multiProfileResultPayload(result); payload != nil { + entry["result"] = payload + } + if result.Response != nil { + if endpoint, ok := result.Response["endpoint"]; ok { + entry["endpoint"] = endpoint + } + } + } + entries = append(entries, entry) + } + + invocation.Implemented = true + return executor.Result{ + Invocation: invocation, + Response: map[string]any{ + "content": map[string]any{ + "success": failed == 0, + "multiProfile": true, + "summary": map[string]any{ + "total": len(selections), + "succeeded": succeeded, + "failed": failed, + }, + "profiles": entries, + }, + }, + }, nil +} + +func cloneInvocation(invocation executor.Invocation) executor.Invocation { + cloned := invocation + if invocation.Params != nil { + cloned.Params = make(map[string]any, len(invocation.Params)) + for key, value := range invocation.Params { + cloned.Params[key] = value + } + } + return cloned +} + +func multiProfileResultPayload(result executor.Result) any { + if result.Response == nil { + return nil + } + if content, ok := result.Response["content"]; ok { + return content + } + return result.Response +} + +func multiProfileErrorPayload(err error) map[string]any { + payload := map[string]any{ + "message": err.Error(), + } + var typed *apperrors.Error + if errors.As(err, &typed) { + payload["category"] = string(typed.Category) + if typed.Reason != "" { + payload["reason"] = typed.Reason + } + if typed.Operation != "" { + payload["operation"] = typed.Operation + } + if code := typed.ExitCode(); code != 0 { + payload["exitCode"] = code + } + } + return payload +} + // handleCatalogMiss decides what to do when discovery catalog does not cover the // requested product / tool and no `directRuntimeEndpoint` match fired earlier. // diff --git a/scripts/dev/test-multi-profile-e2e.sh b/scripts/dev/test-multi-profile-e2e.sh new file mode 100755 index 00000000..9e44e971 --- /dev/null +++ b/scripts/dev/test-multi-profile-e2e.sh @@ -0,0 +1,623 @@ +#!/usr/bin/env bash +# End-to-end regression script for multi-profile / multi-organization login. +# It uses an isolated DWS_CONFIG_DIR and DWS_KEYCHAIN_DIR, seeds post-login +# token results through the production auth storage API, then verifies the real +# dws CLI command surface. +# +# Usage: +# bash scripts/dev/test-multi-profile-e2e.sh +# bash scripts/dev/test-multi-profile-e2e.sh --skip-go-tests --verbose + +set -euo pipefail + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +RUN_GO_TESTS=1 +VERBOSE=0 +KEEP_WORKDIR=0 + +while [[ $# -gt 0 ]]; do + case "$1" in + --skip-go-tests) + RUN_GO_TESTS=0 + shift + ;; + --verbose) + VERBOSE=1 + shift + ;; + --keep-workdir) + KEEP_WORKDIR=1 + shift + ;; + -h|--help) + sed -n '1,12p' "$0" + exit 0 + ;; + *) + echo "unknown option: $1" >&2 + exit 2 + ;; + esac +done + +mkdir -p "$ROOT/.tmp-bin" +WORKDIR="$(mktemp -d "$ROOT/.tmp-bin/multi-profile-e2e.XXXXXX")" +BIN="$WORKDIR/bin/dws" +HELPER_DIR="$WORKDIR/helper" +CONFIG_DIR="$WORKDIR/config" +KEYCHAIN_DIR="$WORKDIR/keychain" +CACHE_DIR="$WORKDIR/cache" +OUT_DIR="$WORKDIR/out" + +cleanup() { + if [[ "$KEEP_WORKDIR" -eq 1 ]]; then + echo "[INFO] kept workdir: $WORKDIR" + else + rm -rf "$WORKDIR" + fi +} +trap cleanup EXIT + +export DWS_CONFIG_DIR="$CONFIG_DIR" +export DWS_KEYCHAIN_DIR="$KEYCHAIN_DIR" +export DWS_DISABLE_KEYCHAIN=1 +export DWS_CACHE_DIR="$CACHE_DIR" +export DWS_PERF_REPORT= +export DWS_PERF_DEBUG= + +mkdir -p "$HELPER_DIR" "$CONFIG_DIR" "$KEYCHAIN_DIR" "$CACHE_DIR" "$OUT_DIR" "$(dirname "$BIN")" + +log() { + printf '\n==> %s\n' "$*" +} + +fail() { + echo "[FAIL] $*" >&2 + exit 1 +} + +run() { + if [[ "$VERBOSE" -eq 1 ]]; then + "$@" + else + "$@" >/dev/null + fi +} + +capture() { + local file="$1" + shift + if [[ "$VERBOSE" -eq 1 ]]; then + echo "+ $*" >&2 + fi + "$@" >"$file" 2>"$file.stderr" +} + +expect_contains() { + local file="$1" + local needle="$2" + if ! grep -F -- "$needle" "$file" >/dev/null; then + echo "----- $file -----" >&2 + cat "$file" >&2 + fail "expected $file to contain: $needle" + fi +} + +expect_not_contains_line_command() { + local file="$1" + local command="$2" + if grep -E "^[[:space:]]+$command([[:space:]]|$)" "$file" >/dev/null; then + echo "----- $file -----" >&2 + cat "$file" >&2 + fail "did not expect command '$command' in $file" + fi +} + +expect_fail() { + local needle="$1" + shift + local output + set +e + output="$("$@" 2>&1)" + local code=$? + set -e + if [[ "$code" -eq 0 ]]; then + echo "$output" >&2 + fail "expected command to fail: $*" + fi + if ! grep -F -- "$needle" <<<"$output" >/dev/null; then + echo "$output" >&2 + fail "expected failure output to contain: $needle" + fi +} + +cat >"$HELPER_DIR/main.go" <<'GOEOF' +package main + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + "strings" + "time" + + auth "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/auth" +) + +type profileListResponse struct { + Success bool `json:"success"` + PrimaryProfile string `json:"primaryProfile"` + CurrentProfile string `json:"currentProfile"` + PreviousProfile string `json:"previousProfile"` + Profiles []profileView `json:"profiles"` +} + +type profileUseResponse struct { + Success bool `json:"success"` + Profile profileView `json:"profile"` +} + +type profileView struct { + CorpID string `json:"corpId"` + CorpName string `json:"corpName"` + UserID string `json:"userId"` + UserName string `json:"userName"` + Status string `json:"status"` + IsPrimary bool `json:"isPrimary"` + IsCurrent bool `json:"isCurrent"` +} + +type authStatusResponse struct { + Success bool `json:"success"` + Authenticated bool `json:"authenticated"` + TokenValid bool `json:"token_valid"` + RefreshTokenValid bool `json:"refresh_token_valid"` + CorpID string `json:"corp_id"` + CorpName string `json:"corp_name"` + UserID string `json:"user_id"` + UserName string `json:"user_name"` +} + +type multiProfileResponse struct { + Success bool `json:"success"` + MultiProfile bool `json:"multiProfile"` + Summary multiProfileSummary `json:"summary"` + Profiles []multiProfileResult `json:"profiles"` +} + +type multiProfileSummary struct { + Total int `json:"total"` + Succeeded int `json:"succeeded"` + Failed int `json:"failed"` +} + +type multiProfileResult struct { + Selector string `json:"selector"` + CorpID string `json:"corpId"` + CorpName string `json:"corpName"` + OK bool `json:"ok"` + Result map[string]any `json:"result"` +} + +func main() { + if len(os.Args) < 2 { + die("missing helper command") + } + configDir := os.Getenv("DWS_CONFIG_DIR") + if strings.TrimSpace(configDir) == "" { + die("DWS_CONFIG_DIR is required") + } + switch os.Args[1] { + case "seed": + needArgs(7) + data := token(os.Args[2], os.Args[3], os.Args[4], os.Args[5], os.Args[6]) + must(auth.SaveTokenData(configDir, data)) + case "seed-legacy": + needArgs(7) + data := token(os.Args[2], os.Args[3], os.Args[4], os.Args[5], os.Args[6]) + must(auth.SaveTokenDataKeychain(data)) + must(auth.WriteTokenMarker(configDir)) + case "write-app-config": + needArgs(4) + must(auth.SaveAppConfig(configDir, &auth.AppConfig{ + ClientID: os.Args[2], + ClientSecret: auth.PlainSecret(os.Args[3]), + })) + case "assert-app-config": + needArgs(3) + cfg, err := auth.LoadAppConfig(configDir) + must(err) + switch os.Args[2] { + case "exists": + if cfg == nil || strings.TrimSpace(cfg.ClientID) == "" { + die("expected app config to exist") + } + case "absent": + if cfg != nil { + die("expected app config to be absent, got clientID=%q", cfg.ClientID) + } + default: + die("unknown app config expectation %q", os.Args[2]) + } + case "assert-profiles": + needArgs(6) + cfg, err := auth.LoadProfiles(configDir) + must(err) + wantCount := atoi(os.Args[2]) + if len(cfg.Profiles) != wantCount { + die("profiles len=%d, want %d: %#v", len(cfg.Profiles), wantCount, cfg.Profiles) + } + assertEqual("primaryProfile", cfg.PrimaryProfile, emptySentinel(os.Args[3])) + assertEqual("currentProfile", cfg.CurrentProfile, emptySentinel(os.Args[4])) + assertEqual("previousProfile", cfg.PreviousProfile, emptySentinel(os.Args[5])) + assertNoSecrets(configDir) + assertProfileMetadata(cfg) + case "assert-list-json": + needArgs(7) + var resp profileListResponse + raw := readJSON(os.Args[2], &resp) + if strings.Contains(string(raw), `"name"`) { + die("profile list JSON must not expose local name: %s", string(raw)) + } + if !resp.Success { + die("profile list success=false") + } + wantCount := atoi(os.Args[3]) + if len(resp.Profiles) != wantCount { + die("list profiles len=%d, want %d: %#v", len(resp.Profiles), wantCount, resp.Profiles) + } + assertEqual("list primaryProfile", resp.PrimaryProfile, emptySentinel(os.Args[4])) + assertEqual("list currentProfile", resp.CurrentProfile, emptySentinel(os.Args[5])) + assertEqual("list previousProfile", resp.PreviousProfile, emptySentinel(os.Args[6])) + for _, p := range resp.Profiles { + if strings.TrimSpace(p.CorpID) == "" || strings.TrimSpace(p.CorpName) == "" { + die("profile list item missing corp identity: %#v", p) + } + if p.CorpID == resp.PrimaryProfile && !p.IsPrimary { + die("profile %s should be primary", p.CorpID) + } + if p.CorpID == resp.CurrentProfile && !p.IsCurrent { + die("profile %s should be current", p.CorpID) + } + } + case "assert-switch-json": + needArgs(5) + var resp profileUseResponse + readJSON(os.Args[2], &resp) + if !resp.Success { + die("switch JSON success=false") + } + assertEqual("switch corpId", resp.Profile.CorpID, os.Args[3]) + assertEqual("switch corpName", resp.Profile.CorpName, os.Args[4]) + if !resp.Profile.IsCurrent { + die("switch profile isCurrent=false") + } + case "assert-status-json": + needArgs(6) + var resp authStatusResponse + readJSON(os.Args[2], &resp) + if !resp.Success || !resp.Authenticated || !resp.TokenValid || !resp.RefreshTokenValid { + die("bad auth status response: %#v", resp) + } + assertEqual("status corpId", resp.CorpID, os.Args[3]) + assertEqual("status corpName", resp.CorpName, os.Args[4]) + assertEqual("status userId", resp.UserID, os.Args[5]) + case "assert-multi-profile-json": + needArgs(5) + var resp multiProfileResponse + readJSON(os.Args[2], &resp) + if !resp.Success || !resp.MultiProfile { + die("bad multi-profile response: %#v", resp) + } + wantCount := atoi(os.Args[3]) + if len(resp.Profiles) != wantCount { + die("multi-profile len=%d, want %d: %#v", len(resp.Profiles), wantCount, resp.Profiles) + } + if resp.Summary.Total != wantCount || resp.Summary.Succeeded != wantCount || resp.Summary.Failed != 0 { + die("bad multi-profile summary: %#v", resp.Summary) + } + wantCorpIDs := strings.Split(os.Args[4], ",") + if len(wantCorpIDs) != wantCount { + die("want corpId count=%d, want %d", len(wantCorpIDs), wantCount) + } + for i, want := range wantCorpIDs { + want = strings.TrimSpace(want) + got := resp.Profiles[i] + if !got.OK { + die("profile %d ok=false: %#v", i, got) + } + assertEqual(fmt.Sprintf("multi-profile corpId[%d]", i), got.CorpID, want) + if got.Result["_mock"] != true { + die("profile %s result is not mock payload: %#v", got.CorpID, got.Result) + } + } + case "assert-token": + needArgs(5) + data, err := loadToken(configDir, os.Args[2]) + must(err) + assertEqual("token corpId", data.CorpID, os.Args[3]) + assertEqual("token access", data.AccessToken, os.Args[4]) + case "assert-empty-auth": + needArgs(2) + cfg, err := auth.LoadProfiles(configDir) + must(err) + if cfg.PrimaryProfile != "" || cfg.CurrentProfile != "" || cfg.PreviousProfile != "" || len(cfg.Profiles) != 0 { + die("expected empty profiles after reset, got %#v", cfg) + } + if auth.TokenDataExistsKeychain() { + die("legacy auth-token still exists") + } + case "assert-duplicate-name-fallback": + needArgs(4) + cfg, err := auth.LoadProfiles(configDir) + must(err) + p := findProfile(cfg, os.Args[2]) + if p == nil { + die("profile %q not found", os.Args[2]) + } + if p.CorpName != os.Args[3] { + die("profile %s corpName=%q, want %q", p.CorpID, p.CorpName, os.Args[3]) + } + if p.Name == os.Args[3] || !strings.HasPrefix(p.Name, os.Args[3]+"-") { + die("profile %s name=%q, want stable fallback prefix %q", p.CorpID, p.Name, os.Args[3]+"-") + } + default: + die("unknown helper command %q", os.Args[1]) + } +} + +func token(corpID, corpName, userID, userName, access string) *auth.TokenData { + return &auth.TokenData{ + AccessToken: access, + RefreshToken: "refresh-" + corpID, + PersistentCode: "persistent-" + corpID, + ExpiresAt: time.Now().Add(2 * time.Hour), + RefreshExpAt: time.Now().Add(720 * time.Hour), + CorpID: corpID, + CorpName: corpName, + UserID: userID, + UserName: userName, + ClientID: "client-" + corpID, + Source: "multi-profile-e2e", + } +} + +func needArgs(n int) { + if len(os.Args) != n { + die("%s: got %d args, want %d", os.Args[1], len(os.Args)-2, n-2) + } +} + +func loadToken(configDir, selector string) (*auth.TokenData, error) { + if selector == "default" { + return auth.LoadTokenData(configDir) + } + return auth.LoadTokenDataForProfile(configDir, selector) +} + +func readJSON(path string, dst any) []byte { + data, err := os.ReadFile(path) + must(err) + if err := json.Unmarshal(data, dst); err != nil { + die("parse %s: %v\n%s", path, err, string(data)) + } + return data +} + +func assertProfileMetadata(cfg *auth.ProfilesConfig) { + names := map[string]string{} + for _, p := range cfg.Profiles { + if strings.TrimSpace(p.CorpID) == "" || strings.TrimSpace(p.CorpName) == "" { + die("profile missing corp metadata: %#v", p) + } + if prev, ok := names[p.Name]; ok { + die("duplicate profile local name %q for %s and %s", p.Name, prev, p.CorpID) + } + names[p.Name] = p.CorpID + } +} + +func assertNoSecrets(configDir string) { + data, err := os.ReadFile(filepath.Join(configDir, "profiles.json")) + if err != nil { + if os.IsNotExist(err) { + return + } + must(err) + } + for _, forbidden := range []string{"access_token", "refresh_token", "persistent_code", "client_secret"} { + if strings.Contains(string(data), forbidden) { + die("profiles.json contains secret field %q", forbidden) + } + } +} + +func findProfile(cfg *auth.ProfilesConfig, corpID string) *auth.Profile { + for i := range cfg.Profiles { + if cfg.Profiles[i].CorpID == corpID { + return &cfg.Profiles[i] + } + } + return nil +} + +func atoi(raw string) int { + var n int + if _, err := fmt.Sscanf(raw, "%d", &n); err != nil { + die("invalid integer %q", raw) + } + return n +} + +func emptySentinel(s string) string { + if s == "_" { + return "" + } + return s +} + +func assertEqual(label, got, want string) { + if got != want { + die("%s=%q, want %q", label, got, want) + } +} + +func must(err error) { + if err != nil { + die("%v", err) + } +} + +func die(format string, args ...any) { + fmt.Fprintf(os.Stderr, format+"\n", args...) + os.Exit(1) +} +GOEOF + +cd "$ROOT" + +if [[ "$RUN_GO_TESTS" -eq 1 ]]; then + log "running focused Go regressions" + go test -timeout 180s -count=1 ./internal/auth ./internal/app ./test/cli -run 'Test(MultiProfile|RuntimeProfile|ProfileFlagArgs|PreparseProfileFlag|NormalizeProcessProfileArgs|CommaSeparated|CommaNamed|DeleteProfile|UpsertProfile|LoadProfiles|LegacyKeychain|WriteProfile|ProfileList|ProfileUse|ProfileSwitch|AuthCommandDoesNotExposeSwitch|AuthStatus|AuthLogout|AuthLogin|ResolveAuthLogin|EnrichAuthLogin|RootHelp|RootShortHelp|RootCommand|ProductCommandsAcceptGlobalProfileFlag)' +fi + +log "building dws" +run go build -o "$BIN" ./cmd + +helper() { + go run "$HELPER_DIR" "$@" +} + +log "checking command surface" +capture "$OUT_DIR/root-help.txt" "$BIN" --help +expect_contains "$OUT_DIR/root-help.txt" "--profile" +expect_contains "$OUT_DIR/root-help.txt" "--yes" +expect_contains "$OUT_DIR/root-help.txt" "--dry-run" +expect_contains "$OUT_DIR/root-help.txt" "profile" +capture "$OUT_DIR/profile-help.txt" "$BIN" profile --help +expect_contains "$OUT_DIR/profile-help.txt" "list" +expect_contains "$OUT_DIR/profile-help.txt" "switch" +expect_contains "$OUT_DIR/profile-help.txt" "use" +expect_contains "$OUT_DIR/profile-help.txt" "--profile" +capture "$OUT_DIR/auth-login-help.txt" "$BIN" auth login --help +expect_contains "$OUT_DIR/auth-login-help.txt" "--device" +expect_contains "$OUT_DIR/auth-login-help.txt" "--token" +expect_contains "$OUT_DIR/auth-login-help.txt" "--recommend" +expect_contains "$OUT_DIR/auth-login-help.txt" "--yes" +capture "$OUT_DIR/skill-setup-help.txt" "$BIN" skill setup --help +expect_contains "$OUT_DIR/skill-setup-help.txt" "--mode" +expect_contains "$OUT_DIR/skill-setup-help.txt" "--target" +expect_contains "$OUT_DIR/skill-setup-help.txt" "--yes" +expect_contains "$OUT_DIR/skill-setup-help.txt" "--skill" +expect_contains "$OUT_DIR/skill-setup-help.txt" "--exclude" +capture "$OUT_DIR/upgrade-help.txt" "$BIN" upgrade --help +expect_contains "$OUT_DIR/upgrade-help.txt" "--dry-run" +expect_contains "$OUT_DIR/upgrade-help.txt" "--yes" +capture "$OUT_DIR/dev-connect-help.txt" "$BIN" dev connect --help +expect_contains "$OUT_DIR/dev-connect-help.txt" "--robot-client-id" +expect_contains "$OUT_DIR/dev-connect-help.txt" "--robot-client-secret" +expect_contains "$OUT_DIR/dev-connect-help.txt" "--unified-app-id" +expect_contains "$OUT_DIR/dev-connect-help.txt" "--agent-cmd" +expect_contains "$OUT_DIR/dev-connect-help.txt" "--daemon" +capture "$OUT_DIR/doc-delete-help.txt" "$BIN" doc delete --help +expect_contains "$OUT_DIR/doc-delete-help.txt" "--yes" +capture "$OUT_DIR/aitable-base-delete-help.txt" "$BIN" aitable base delete --help +expect_contains "$OUT_DIR/aitable-base-delete-help.txt" "--yes" +capture "$OUT_DIR/auth-help.txt" "$BIN" auth --help +expect_not_contains_line_command "$OUT_DIR/auth-help.txt" "switch" + +log "verifying empty profile list" +capture "$OUT_DIR/list-empty.json" "$BIN" profile list --format json +helper assert-list-json "$OUT_DIR/list-empty.json" 0 _ _ _ + +log "seeding first organization profile" +helper seed corp_alpha "Alpha Org" user_alpha "Alice Alpha" access-alpha-v1 +capture "$OUT_DIR/list-alpha.json" "$BIN" profile list --format json +helper assert-list-json "$OUT_DIR/list-alpha.json" 1 corp_alpha corp_alpha _ +helper assert-profiles 1 corp_alpha corp_alpha _ +helper assert-token default corp_alpha access-alpha-v1 +helper assert-token corp_alpha corp_alpha access-alpha-v1 +capture "$OUT_DIR/status-alpha-default.json" "$BIN" auth status --format json +helper assert-status-json "$OUT_DIR/status-alpha-default.json" corp_alpha "Alpha Org" user_alpha + +log "seeding second organization profile" +helper seed corp_beta "Beta Org" user_beta "Bob Beta" access-beta-v1 +capture "$OUT_DIR/list-alpha-beta.json" "$BIN" profile list --format json +helper assert-list-json "$OUT_DIR/list-alpha-beta.json" 2 corp_alpha corp_beta corp_alpha +helper assert-profiles 2 corp_alpha corp_beta corp_alpha +helper assert-token default corp_beta access-beta-v1 +helper assert-token corp_alpha corp_alpha access-alpha-v1 +helper assert-token corp_beta corp_beta access-beta-v1 + +log "refreshing existing organization without duplicating profile" +helper seed corp_beta "Beta Org" user_beta "Bob Beta" access-beta-v2 +capture "$OUT_DIR/list-beta-refresh.json" "$BIN" profile list --format json +helper assert-list-json "$OUT_DIR/list-beta-refresh.json" 2 corp_alpha corp_beta corp_alpha +helper assert-profiles 2 corp_alpha corp_beta corp_alpha +helper assert-token corp_beta corp_beta access-beta-v2 + +log "seeding duplicate organization name and checking stable fallback" +helper seed corp_gamma "Beta Org" user_gamma "Gina Gamma" access-gamma-v1 +capture "$OUT_DIR/list-duplicate-name.json" "$BIN" profile list --format json +helper assert-list-json "$OUT_DIR/list-duplicate-name.json" 3 corp_alpha corp_gamma corp_beta +helper assert-profiles 3 corp_alpha corp_gamma corp_beta +helper assert-duplicate-name-fallback corp_gamma "Beta Org" + +log "switching profiles and verifying legacy mirror" +capture "$OUT_DIR/switch-alpha.json" "$BIN" profile switch corp_alpha --format json +helper assert-switch-json "$OUT_DIR/switch-alpha.json" corp_alpha "Alpha Org" +helper assert-profiles 3 corp_alpha corp_alpha corp_gamma +helper assert-token default corp_alpha access-alpha-v1 +capture "$OUT_DIR/switch-beta.txt" "$BIN" profile switch corp_beta --format table +expect_contains "$OUT_DIR/switch-beta.txt" "Beta Org" +expect_contains "$OUT_DIR/switch-beta.txt" "corp_beta" +helper assert-profiles 3 corp_alpha corp_beta corp_alpha +helper assert-token default corp_beta access-beta-v2 +capture "$OUT_DIR/switch-previous.json" "$BIN" profile switch - --format json +helper assert-switch-json "$OUT_DIR/switch-previous.json" corp_alpha "Alpha Org" +helper assert-profiles 3 corp_alpha corp_alpha corp_beta +capture "$OUT_DIR/use-gamma.json" "$BIN" profile use corp_gamma --format json +helper assert-switch-json "$OUT_DIR/use-gamma.json" corp_gamma "Beta Org" +helper assert-profiles 3 corp_alpha corp_gamma corp_alpha + +log "checking profile switch validation" +expect_fail "profile selector required" "$BIN" profile switch +expect_fail "只能指定一个组织选择器" "$BIN" profile switch corp_alpha --corpId corp_beta +expect_fail "missing_org" "$BIN" profile switch missing_org + +log "checking one-shot profile override without changing current profile" +capture "$OUT_DIR/status-root-profile-alpha.json" "$BIN" --profile corp_alpha auth status --format json +helper assert-status-json "$OUT_DIR/status-root-profile-alpha.json" corp_alpha "Alpha Org" user_alpha +helper assert-profiles 3 corp_alpha corp_gamma corp_alpha +capture "$OUT_DIR/status-local-profile-beta.json" "$BIN" auth status --profile corp_beta --format json +helper assert-status-json "$OUT_DIR/status-local-profile-beta.json" corp_beta "Beta Org" user_beta +helper assert-profiles 3 corp_alpha corp_gamma corp_alpha +capture "$OUT_DIR/status-current-gamma.json" "$BIN" auth status --format json +helper assert-status-json "$OUT_DIR/status-current-gamma.json" corp_gamma "Beta Org" user_gamma +capture "$OUT_DIR/contact-multi-profile.json" "$BIN" --mock --profile corp_alpha, corp_beta contact user get-self --format json +helper assert-multi-profile-json "$OUT_DIR/contact-multi-profile.json" 2 corp_alpha,corp_beta +helper assert-profiles 3 corp_alpha corp_gamma corp_alpha +capture "$OUT_DIR/contact-multi-profile-leaf-profile.json" "$BIN" --mock contact user get-self --profile corp_alpha, corp_beta --format json +helper assert-multi-profile-json "$OUT_DIR/contact-multi-profile-leaf-profile.json" 2 corp_alpha,corp_beta +helper assert-profiles 3 corp_alpha corp_gamma corp_alpha + +log "checking auth reset cleanup" +helper write-app-config client-reset secret-reset +helper assert-app-config exists +capture "$OUT_DIR/auth-reset.txt" "$BIN" auth reset +expect_contains "$OUT_DIR/auth-reset.txt" "[OK]" +helper assert-empty-auth +helper assert-app-config absent + +log "checking legacy single-slot migration" +helper seed-legacy corp_legacy "Legacy Org" user_legacy "Lena Legacy" access-legacy-v1 +helper assert-profiles 0 _ _ _ +capture "$OUT_DIR/list-legacy-migrated.json" "$BIN" profile list --format json +helper assert-list-json "$OUT_DIR/list-legacy-migrated.json" 1 corp_legacy corp_legacy _ +helper assert-profiles 1 corp_legacy corp_legacy _ +helper assert-token default corp_legacy access-legacy-v1 +helper assert-token corp_legacy corp_legacy access-legacy-v1 + +log "multi-profile e2e passed" +echo "[PASS] isolated multi-profile chain completed" From 4ee869781fca8594480878283412ca0ef84c52c8 Mon Sep 17 00:00:00 2001 From: "shangguanxuan.sgx" Date: Mon, 29 Jun 2026 10:57:57 +0800 Subject: [PATCH 15/22] ci: add multi-profile e2e workflow --- .github/workflows/multi-profile-e2e.yml | 45 +++ .../dws-multi-profile-login/test-cases.md | 343 +++++++++++++++++- 2 files changed, 380 insertions(+), 8 deletions(-) create mode 100644 .github/workflows/multi-profile-e2e.yml diff --git a/.github/workflows/multi-profile-e2e.yml b/.github/workflows/multi-profile-e2e.yml new file mode 100644 index 00000000..bbb663ef --- /dev/null +++ b/.github/workflows/multi-profile-e2e.yml @@ -0,0 +1,45 @@ +name: Multi Profile E2E + +on: + pull_request: + push: + branches: + - main + - codex/** + - feat/** + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: multi-profile-e2e-${{ github.ref }} + cancel-in-progress: true + +jobs: + multi-profile-e2e: + name: Multi Profile E2E + runs-on: ubuntu-latest + timeout-minutes: 15 + + steps: + - name: Check out repository + uses: actions/checkout@v4 + + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version-file: go.mod + + - name: Run isolated multi-profile chain + run: bash scripts/dev/test-multi-profile-e2e.sh --keep-workdir + + - name: Upload debug artifacts + if: failure() + uses: actions/upload-artifact@v4 + with: + name: multi-profile-e2e-debug + path: | + .tmp-bin/multi-profile-e2e.*/out + .tmp-bin/multi-profile-e2e.*/config + if-no-files-found: ignore diff --git a/docs/ralph/dws-multi-profile-login/test-cases.md b/docs/ralph/dws-multi-profile-login/test-cases.md index ef55e1e2..9ae9c0f3 100644 --- a/docs/ralph/dws-multi-profile-login/test-cases.md +++ b/docs/ralph/dws-multi-profile-login/test-cases.md @@ -20,6 +20,18 @@ bash scripts/dev/test-multi-profile-e2e.sh ``` +GitHub Actions 入口: + +```text +.github/workflows/multi-profile-e2e.yml +``` + +触发方式: + +- PR 自动触发。 +- `main`、`codex/**`、`feat/**` 分支 push 自动触发。 +- Actions 页面手动执行 `workflow_dispatch`。 + 调试模式: ```bash @@ -58,6 +70,92 @@ bash scripts/dev/test-multi-profile-e2e.sh --keep-workdir | F8 agent 跨组织聚合原语 | `--profile corpA,corpB` / `--profile corpA, corpB` 聚合读取 + `profile list` 枚举 | TC-11, MTC-01 | | TUI 机器替代路径 | help surface + 非交互失败断言 | MTC-01 至 MTC-07 | +## 3.1 `--profile` 命令级覆盖矩阵 + +本次 `--profile` 变更影响的是 root persistent flag、runtime runner、auth/profile 管理命令和所有会读取登录态的命令。测试按以下语义分层: + +| 语义 | 说明 | 断言重点 | +|---|---|---| +| P-SINGLE | 单 profile 临时覆盖 | 使用指定组织 token / runtime profile;不修改 `currentProfile`、`previousProfile` | +| P-MULTI | CSV 多 profile 聚合 | 输出 `multiProfile=true`,按输入顺序返回每个组织结果;不修改持久 profile 指针 | +| P-LOCAL | 命令自己的 `--profile` | local flag 优先于 root persistent flag;只影响该命令定义的局部行为 | +| P-IGNORED | 接受 root `--profile` 但命令本身不读组织态 | 命令成功;输出与未传 profile 一致;不修改 profile 指针 | +| P-UNSUPPORTED | 多 profile 对该命令无业务语义,应明确失败或不扩大影响 | 不静默误删、不默认切换、不把第二个 profile 当业务参数 | + +### 3.1.1 Auth / Profile 管理命令 + +| 指令 | 必测 `--profile` 场景 | 预期 | +|---|---|---| +| `dws auth login` | `dws --profile corp_alpha auth login --device --format json`、`dws auth login --profile corp_alpha` help 示例 | 指定本次授权目标组织;不持久切换 current;缺失 profile 返回 validation | +| `dws auth status` | `dws --profile corp_alpha auth status --format json`、`dws auth status --profile corp_beta --format json`、root + local 同时存在 | local `--profile` 优先;root `--profile` 可选中 token;均不修改 current | +| `dws auth logout` | `dws auth logout --profile corp_alpha`、`dws --profile corp_alpha auth logout`、`dws auth logout` | local `--profile` 只退出单组织;root `--profile` 不应被误认为单组织 logout;默认仍退出全部 | +| `dws auth reset` | `dws --profile corp_alpha auth reset`、`dws auth reset` | reset 是全局清理;root `--profile` 不改变清理范围 | +| `dws auth export` | `dws --profile corp_alpha auth export --base64` | 导出当前 runtime profile 对应 token/配置,或明确说明导出全量;不修改 current | +| `dws auth import` | `dws --profile corp_alpha auth import --input ` | import 的写入语义不被 root `--profile` 误导;导入后 profile 指针符合 bundle/实现契约 | +| `dws auth exchange` | `dws --profile corp_alpha auth exchange --code ` | 仅在真实 OAuth/UAT 中验证;目标 profile 解析不应泄漏到业务参数 | +| `dws profile list` | `dws --profile corp_alpha profile list --format json` | list 永远列出全部 profile;root `--profile` 不过滤、不切换 | +| `dws profile switch` | `dws --profile corp_alpha profile switch corp_beta --format json`、`dws profile switch --corpId corp_beta`、`dws profile switch --name "Beta Org"`、`dws profile switch -` | 显式 selector 决定持久切换;root `--profile` 不覆盖 switch 目标 | +| `dws profile use` | `dws --profile corp_alpha profile use corp_gamma --format json`、`dws profile use -` | 与 switch 等价;root `--profile` 不改变 use 目标 | + +### 3.1.2 Runtime / 产品命令 + +所有 runtime 产品命令均必须覆盖 `P-SINGLE` 和 `P-MULTI`,但不能把 `--mock` 当作唯一测试方式。测试分三层: + +- L1 自动化回归:允许使用 `--mock`,只验证 CLI 参数解析、runner 注入、聚合结构、无副作用和 `profile` 不泄漏为业务参数。 +- L2 真实只读冒烟:不加 `--mock`,对每个产品 root 选择一个当前可见的只读 leaf,验证真实 token 读取、endpoint 解析、远端调用链路。业务可以因权限/数据为空返回结构化错误,但不能是 CLI 解析错误、profile 解析错误或错误组织 token。 +- L3 高风险写操作:不加 `--mock` 时必须使用 `--dry-run`、测试租户或显式 `--yes` 确认;测试重点是 `--profile` 只选择组织,不替代确认、不扩大操作范围。 + +| 产品根命令 | L1 自动化回归(可 mock) | L2 真实只读冒烟(不可 mock) | 断言 | +|---|---|---|---| +| `aisearch` | `dws --mock --profile corp_alpha,corp_beta aisearch person --keyword Alice --format json` | `dws --profile corp_alpha aisearch person --keyword Alice --format json` | L1 聚合;L2 使用 Alpha token 调真实链路 | +| `aitable` | `dws --mock --profile corp_alpha,corp_beta aitable base list --format json` | `dws --profile corp_alpha aitable base list --format json` | 不泄漏 `profile` 业务参数;真实链路不因 profile 解析失败 | +| `attendance` | `dws --mock --profile corp_alpha,corp_beta attendance group list --format json` | `dws --profile corp_alpha attendance group list --format json` | 输出按组织聚合;真实链路使用 selected profile | +| `calendar` | `dws --mock --profile corp_alpha,corp_beta calendar event list --format json` | `dws --profile corp_alpha calendar event list --format json` | 单 profile 不改 current;真实只读日程链路可达 | +| `chat` | `dws --mock chat search --query test --profile corp_alpha, corp_beta --format json` | `dws --profile corp_alpha chat search --query test --format json` | leaf 后 `--profile` 解析;真实链路不误用 current | +| `conference` | `dws --mock --profile corp_alpha,corp_beta conference list --format json` | `dws --profile corp_alpha conference list --format json` | L1 不触网;L2 真实会议只读链路 | +| `contact` | `dws --mock --profile corp_alpha, corp_beta contact user get-self --format json` | `dws --profile corp_alpha contact user get-self --format json` | 覆盖不加引号 CSV continuation;真实返回当前用户身份 | +| `devdoc` | `dws --mock --profile corp_alpha,corp_beta devdoc article search --query auth --format json` | `dws --profile corp_alpha devdoc article search --query auth --format json` | 文档类命令也接受 global profile;真实搜索链路可达 | +| `ding` | `dws --mock --profile corp_alpha,corp_beta ding list --format json` | `dws --profile corp_alpha ding list --format json` | 无业务参数污染;真实只读链路可达或结构化权限错误 | +| `doc` | `dws --mock --profile corp_alpha,corp_beta doc search --query test --format json` | `dws --profile corp_alpha doc search --query test --format json` | doc helper/runtime 均覆盖;真实文档搜索链路 | +| `doc-comment` | `dws --mock --profile corp_alpha,corp_beta doc-comment list --node doc_x --format json` | `dws --profile corp_alpha doc-comment list --node --format json` | serverOverride 子 server 覆盖;真实用测试文档节点 | +| `drive` | `dws --mock --profile corp_alpha,corp_beta drive file list --format json` | `dws --profile corp_alpha drive file list --format json` | 真实云盘只读链路;不修改 current | +| `hrmregister` | `dws --mock --profile corp_alpha,corp_beta hrmregister field list --format json` | `dws --profile corp_alpha hrmregister field list --format json` | 子 server 覆盖;真实权限错误也要结构化 | +| `live` | `dws --mock --profile corp_alpha,corp_beta live list --format json` | `dws --profile corp_alpha live list --format json` | 聚合结构一致;真实只读直播列表 | +| `mail` | `dws --mock --profile corp_alpha,corp_beta mail message list --format json` | `dws --profile corp_alpha mail message list --format json` | 不泄漏 profile 参数;真实邮箱权限链路 | +| `minutes` | `dws --mock --profile corp_alpha,corp_beta minutes list mine --format json` | `dws --profile corp_alpha minutes list mine --format json` | 多 profile 每组织独立 result;真实听记列表 | +| `oa` | `dws --mock --profile corp_alpha,corp_beta oa list-pending --format json` | `dws --profile corp_alpha oa list-pending --format json` | 真实审批只读列表;不修改 current | +| `pat` | `dws --mock --profile corp_alpha,corp_beta pat status --format json` | `dws --profile corp_alpha pat status --format json` | PAT runtime 命令和 `pat chmod` utility 分开测 | +| `report` | `dws --mock --profile corp_alpha,corp_beta report template list --format json` | `dws --profile corp_alpha report template list --format json` | 日志产品真实只读链路 | +| `sheet` | `dws --mock --profile corp_alpha,corp_beta sheet read --sheet-id sh_x --format json` | `dws --profile corp_alpha sheet read --sheet-id --format json` | 参数存在时 profile 不混入 params;真实用测试表格 | +| `todo` | `dws --mock --profile corp_alpha,corp_beta todo task list --format json` | `dws --profile corp_alpha todo task list --format json` | 已有 helper merge 路径覆盖;真实待办只读链路 | +| `wiki` | `dws --mock --profile corp_alpha,corp_beta wiki space list --format json` | `dws --profile corp_alpha wiki space list --format json` | 真实知识库只读链路 | + +说明:若某个示例 leaf 在当前 discovery 快照中不存在,自动化生成器必须用该产品当前可见的第一个只读 leaf 替换,并在测试报告记录替换后的真实路径。覆盖目标是“每个产品 root 至少一个 leaf 的 L1 + L2”,不是绑定上表的文案路径。 + +### 3.1.3 Utility / 非 runtime 命令 + +这些命令必须测试 root `--profile` 是否被正确接受、正确忽略或正确用于 token 读取。 + +| 指令 | 必测命令 | 预期 | +|---|---|---| +| `dws api` | `dws --profile corp_alpha api GET /v1.0/contact/users/me --dry-run --format json` | raw API token 从 selected profile 解析;dry-run 不触网;不修改 current | +| `dws doctor` | `dws --profile corp_alpha doctor --json` | auth check 使用 selected profile;网络/cache/version 检查不被 profile 污染 | +| `dws cache status` | `dws --profile corp_alpha cache status --json` | profile 被接受但不影响 cache status | +| `dws cache refresh` | `dws --profile corp_alpha cache refresh --product contact` | refresh 请求使用 selected profile token;不修改 current | +| `dws schema` | `dws --profile corp_alpha schema contact.user.get-self --format json` | schema/discovery 读取 selected token;无 profile 参数泄漏 | +| `dws recovery plan` | `dws --profile corp_alpha recovery plan --last -f json` | 恢复分析中的 runtime 调用使用 selected profile;无快照时按原错误返回 | +| `dws recovery execute` | `dws --profile corp_alpha recovery execute --last -f json` | 同 plan;不修改 current | +| `dws recovery finalize` | `dws --profile corp_alpha recovery finalize --event-id evt --outcome recovered` | finalize 为本地状态写入,profile 应被接受但不改变语义 | +| `dws skill setup` | `dws --profile corp_alpha skill setup --mode mono --yes` | profile 被接受但 skill 安装布局不被影响 | +| `dws skill install` | `dws --profile corp_alpha skill install claude` | 若需联网,profile 不应成为业务参数;认证失败仍按原错误 | +| `dws skill get/search/find/add` | `dws --profile corp_alpha skill search --query doc` 等 | profile 被接受;输出与未传 profile 一致 | +| `dws plugin list/info/install/remove/enable/disable/validate/create/dev/build/config` | 每个子命令加 root `--profile corp_alpha` 跑 help 或 dry-run/validation | 插件管理不应读取/修改组织 profile | +| `dws config list` | `dws --profile corp_alpha config list --json` | profile 被接受;配置输出不被过滤 | +| `dws completion` | `dws --profile corp_alpha completion zsh` | profile 被接受;补全文本包含 profile flag | +| `dws version` | `dws --profile corp_alpha version --format json` | 输出版本信息;无 profile 副作用 | +| `dws upgrade` | `dws --profile corp_alpha upgrade --check --format json`、`dws --profile corp_alpha upgrade --dry-run` | upgrade 与组织无关;profile 被接受但不改变升级计划 | +| `dws pat chmod` | `dws --profile corp_alpha pat chmod --products calendar --dry-run --format json` | 批量授权读取 selected profile / agent context;未加 `--yes` 不执行授权 | + ## 4. 测试数据 自动化脚本使用以下虚拟组织: @@ -274,20 +372,31 @@ dws auth status --format json ### TC-11 多 profile 一次性读取信息 -步骤: +L1 自动化回归步骤: ```bash dws --mock --profile corp_alpha, corp_beta contact user get-self --format json dws --mock contact user get-self --profile corp_alpha, corp_beta --format json ``` +L2 真实只读冒烟步骤: + +```bash +dws --profile corp_alpha contact user get-self --format json +dws --profile corp_beta contact user get-self --format json +dws --profile corp_alpha,corp_beta contact user get-self --format json +dws contact user get-self --profile corp_alpha, corp_beta --format json +``` + 断言: -- 输出为聚合对象,`multiProfile=true`。 -- `success=true`。 -- `summary.total=2`、`summary.succeeded=2`、`summary.failed=0`。 -- `profiles[0].corpId=corp_alpha`,`profiles[1].corpId=corp_beta`。 -- 每个 `profiles[i].ok=true`,且每个组织都有独立 `result`。 +- L1 输出为聚合对象,`multiProfile=true`。 +- L1 `success=true`。 +- L1 `summary.total=2`、`summary.succeeded=2`、`summary.failed=0`。 +- L1 `profiles[0].corpId=corp_alpha`,`profiles[1].corpId=corp_beta`。 +- L1 每个 `profiles[i].ok=true`,且每个组织都有独立 `result`。 +- L2 不允许使用 `--mock`;真实返回应能证明使用了对应组织 token,若远端权限不足,也必须是结构化权限/业务错误而不是 CLI profile 解析错误。 +- L2 多 profile 输出仍为聚合对象;允许单个组织因权限/数据状态失败,但 `summary` 和每个 `profiles[i].error` 必须结构化。 - 执行后 `currentProfile` 仍为 `corp_gamma`,`previousProfile` 仍为 `corp_alpha`。 - `--profile` 放在根命令后或 leaf 命令后都可解析。 - `--profile corp_alpha,corp_alpha,corp_beta` 按 resolved `corpId` 去重后只执行 Alpha/Beta 两个组织。 @@ -336,6 +445,221 @@ dws profile list --format json - corp-scoped `auth-token:corp_legacy` 存在。 - legacy mirror 仍可读取。 +### TC-14 `--profile` 参数解析全形态 + +目的:确保 root persistent `--profile` 和 leaf 后置 `--profile` 都符合 `lark-cli` 风格 CSV 规范,并对未加引号空格写法做容错。 + +L1 parser/runner 自动化步骤,可使用 `--mock` 隔离远端依赖: + +```bash +dws --mock --profile corp_alpha contact user get-self --format json +dws --mock --profile corp_alpha,corp_beta contact user get-self --format json +dws --mock --profile corp_alpha, corp_beta contact user get-self --format json +dws --mock --profile=corp_alpha, corp_beta contact user get-self --format json +dws --mock contact user get-self --profile corp_alpha --format json +dws --mock contact user get-self --profile corp_alpha, corp_beta --format json +dws --mock --profile corp_alpha,corp_alpha,corp_beta contact user get-self --format json +``` + +负向步骤: + +```bash +dws --mock --profile corp_alpha, contact user get-self --format json +dws --mock --profile corp_alpha,,corp_beta contact user get-self --format json +dws --mock --profile missing_org,corp_beta contact user get-self --format json +``` + +L2 真实链路步骤,不使用 `--mock`: + +```bash +dws --profile corp_alpha contact user get-self --format json +dws --profile corp_alpha,corp_beta contact user get-self --format json +dws contact user get-self --profile corp_alpha, corp_beta --format json +``` + +断言: + +- L1 单 profile 输出不是聚合对象,且 current profile 不变。 +- L1 CSV 多 profile 输出 `multiProfile=true`。 +- L1 `corp_alpha, corp_beta` 在 Cobra 解析前被规整,不会把 `corp_beta` 识别为子命令或位置参数。 +- `--profile=corp_alpha, corp_beta` 与 `--profile corp_alpha, corp_beta` 等价。 +- leaf 后置 `--profile` 与 root 前置 `--profile` 等价。 +- 重复 profile 按 resolved `corpId` 去重,返回 Alpha/Beta 两项。 +- 尾部逗号、连续逗号、缺失 profile 均返回 validation error,且不执行任何 runtime 调用。 +- 若存在本地 profile name 为 `alpha,beta`,`--profile alpha,beta` 先按单 profile 精确匹配,不触发聚合。 +- L2 真实链路必须实际读取对应组织 token;失败只接受认证、权限或业务层结构化错误,不接受 mock payload。 + +### TC-15 Auth 命令 `--profile` 覆盖 + +目的:覆盖每个 auth 子命令对 root/local `--profile` 的支持、忽略或拒绝语义,防止误删、误切换、误用 token。 + +前置: + +- 已存在 `corp_alpha`、`corp_beta`、`corp_gamma`。 +- 当前组织为 `corp_gamma`,previous 为 `corp_alpha`。 + +步骤与断言: + +| 用例 | 命令 | 断言 | +|---|---|---| +| AUTH-P01 root profile status | `dws --profile corp_alpha auth status --format json` | 返回 Alpha;`currentProfile` 仍为 Gamma | +| AUTH-P02 local profile status | `dws auth status --profile corp_beta --format json` | 返回 Beta;`currentProfile` 仍为 Gamma | +| AUTH-P03 local wins | `dws --profile corp_alpha auth status --profile corp_beta --format json` | 返回 Beta;root profile 不覆盖 local profile | +| AUTH-P04 missing status profile | `dws auth status --profile missing_org --format json` | 返回未登录或 validation,不修改 current | +| AUTH-P05 multi status unsupported | `dws auth status --profile corp_alpha,corp_beta --format json` | 不聚合;返回 validation/未登录,避免静默读取错误 profile | +| AUTH-P06 scoped logout | `dws auth logout --profile corp_alpha` | 只删除 Alpha token/profile;Beta/Gamma 保留;current/primary 指针重算正确 | +| AUTH-P07 root profile must not scope logout | `dws --profile corp_alpha auth logout` | 按默认 logout 全部清理,或未来若改为拒绝则必须明确报错;绝不能“看似成功但只删一部分” | +| AUTH-P08 logout local wins | `dws --profile corp_alpha auth logout --profile corp_beta` | 只退出 Beta;Alpha/Gamma 保留 | +| AUTH-P09 reset ignores profile | `dws --profile corp_alpha auth reset` | 清空所有 token、profiles、marker、app config | +| AUTH-P10 login target | `dws --profile corp_alpha auth login --device --format json` | 授权目标解析为 Alpha corpId;不持久切换 current | +| AUTH-P11 login missing profile | `dws --profile missing_org auth login --device --format json` | 授权前 validation fail | +| AUTH-P12 export profile | `dws --profile corp_alpha auth export --base64` | 导出与 Alpha 相关的认证资料,输出不包含明文 secret | +| AUTH-P13 import with profile | `dws --profile corp_alpha auth import --input bundle.json` | import 语义不被 root profile 误导;导入后 token/profile 指针符合 bundle 内容 | +| AUTH-P14 exchange with profile | `dws --profile corp_alpha auth exchange --code ` | 真实 UAT 验证;profile 不泄漏为 exchange 业务参数 | + +### TC-16 Profile 命令 `--profile` 覆盖 + +目的:确认 profile 管理命令的显式 selector 优先,root `--profile` 只作为全局 flag 被接受,不会偷偷切换或过滤。 + +步骤: + +```bash +dws --profile corp_alpha profile list --format json +dws --profile corp_alpha profile switch corp_beta --format json +dws --profile corp_alpha profile switch --corpId corp_gamma --format json +dws --profile corp_beta profile switch --name "Alpha Org" --format json +dws --profile corp_beta profile switch - --format json +dws --profile corp_alpha profile use corp_gamma --format json +dws --profile corp_alpha profile use - --format json +``` + +负向步骤: + +```bash +dws --profile corp_alpha profile switch +dws --profile corp_alpha profile switch corp_beta --corpId corp_gamma +dws --profile corp_alpha profile switch missing_org +``` + +断言: + +- `profile list` 仍返回全部组织,不被 root `--profile` 过滤。 +- `profile switch/use` 的位置参数、`--corpId`、`--name`、`-` 决定持久切换目标。 +- root `--profile` 不覆盖显式 selector。 +- 切换后 legacy mirror 同步到新的 current。 +- 非交互无 selector 仍 validation fail,不因 root `--profile` 自动选择。 +- 冲突 selector 返回 validation fail,不修改 current/previous。 + +### TC-17 Utility 命令 `--profile` 覆盖 + +目的:覆盖所有 utility 命令是否正确接受、忽略或使用 root `--profile`。 + +步骤与断言: + +| 用例 | 命令 | 断言 | +|---|---|---| +| UTIL-P01 raw API dry-run | `dws --profile corp_alpha api GET /v1.0/contact/users/me --dry-run --format json` | 使用 Alpha token 构造请求;不触网;current 不变 | +| UTIL-P02 doctor auth | `dws --profile corp_alpha doctor --json` | auth check 针对 Alpha;network/cache/version check 不被污染 | +| UTIL-P03 cache status | `dws --profile corp_alpha cache status --json` | 输出 cache 状态;profile 无副作用 | +| UTIL-P04 cache refresh | `dws --profile corp_alpha cache refresh --product contact` | refresh 读取 Alpha token;不修改 current | +| UTIL-P05 schema | `dws --profile corp_alpha schema contact.user.get-self --format json` | schema/discovery 使用 Alpha 上下文;输出不含 profile 业务参数 | +| UTIL-P06 recovery plan | `dws --profile corp_alpha recovery plan --last -f json` | 有快照时 runtime 分析用 Alpha;无快照时错误语义不变 | +| UTIL-P07 recovery execute | `dws --profile corp_alpha recovery execute --last -f json` | 同 plan | +| UTIL-P08 recovery finalize | `dws --profile corp_alpha recovery finalize --event-id evt_test --outcome recovered` | 本地 finalize 不受 profile 影响 | +| UTIL-P09 skill setup | `dws --profile corp_alpha skill setup --mode mono --yes` | skill 布局与未传 profile 一致 | +| UTIL-P10 skill query | `dws --profile corp_alpha skill search --query doc` | 搜索/查询类输出与未传 profile 一致 | +| UTIL-P11 plugin list | `dws --profile corp_alpha plugin list --format json` | 插件列表不读组织态 | +| UTIL-P12 plugin validate | `dws --profile corp_alpha plugin validate ` | 插件校验不读组织态 | +| UTIL-P13 config list | `dws --profile corp_alpha config list --json` | 配置列表不被 profile 过滤 | +| UTIL-P14 completion | `dws --profile corp_alpha completion zsh` | 生成补全脚本,包含 `--profile` flag | +| UTIL-P15 version | `dws --profile corp_alpha version --format json` | 版本输出不变 | +| UTIL-P16 upgrade check | `dws --profile corp_alpha upgrade --check --format json` | 升级检查与组织无关 | +| UTIL-P17 upgrade dry-run | `dws --profile corp_alpha upgrade --dry-run` | 计划输出与未传 profile 一致 | +| UTIL-P18 pat chmod dry-run | `dws --profile corp_alpha pat chmod --products calendar --dry-run --format json` | 只生成授权 plan;未加 `--yes` 不执行授权;使用 Alpha 上下文 | + +断言通用要求: + +- 所有命令执行前后 `currentProfile/previousProfile` 不变,除非命令本身是 `profile switch/use` 或 auth 清理命令。 +- 对 P-IGNORED 命令,输出不得因 `--profile` 改变业务范围。 +- 对 P-SINGLE 命令,token 解析必须落到 selected profile。 +- 对 P-UNSUPPORTED 命令,多 profile 必须明确失败或保持原语义,不能将第二个 profile 当位置参数继续执行。 + +### TC-18 Runtime 产品命令全量 `--profile` 覆盖 + +目的:每个 runtime 产品 root 至少选一个只读 leaf,覆盖 mock 自动化回归和非 mock 真实冒烟。若 discovery 中 leaf 名称变化,脚本动态选择该产品第一个只读 leaf,并在测试报告记录真实命令。 + +L1 自动化回归步骤: + +```bash +for product in aisearch aitable attendance calendar chat conference contact devdoc ding doc doc-comment drive hrmregister live mail minutes oa pat report sheet todo wiki; do + dws --mock --profile corp_alpha "$product" --format json + dws --mock --profile corp_alpha,corp_beta "$product" --format json + dws --mock "$product" --profile corp_alpha, corp_beta --format json +done +``` + +L2 真实只读冒烟步骤: + +```bash +for product in aisearch aitable attendance calendar chat conference contact devdoc ding doc doc-comment drive hrmregister live mail minutes oa pat report sheet todo wiki; do + dws --profile corp_alpha "$product" --format json + dws --profile corp_alpha,corp_beta "$product" --format json +done +``` + +每个产品的断言: + +- L1 单 profile:runner 执行时 `RuntimeProfile=corp_alpha`;输出不是聚合对象。 +- L1 多 profile:输出 `multiProfile=true`。 +- L1 `summary.total` 等于去重后组织数。 +- L1 `profiles[*].corpId` 按输入顺序返回。 +- 每个 entry 都有 `ok`;成功时有 `result`;失败时有结构化 `error`。 +- 任意产品命令的 invocation params 中不能出现 root `profile` 业务参数。 +- leaf 后置 `--profile corp_alpha, corp_beta` 也能被 parser 规整。 +- L2 不允许使用 `--mock`;必须真实经过 token resolver、endpoint resolver、transport 调用或真实 stdio client。 +- L2 允许远端返回权限/业务错误,但必须能证明请求落在 selected profile;不能是 current profile 泄漏、profile 参数泄漏或 CLI 解析错误。 +- 命令执行后 current/previous 不变。 + +### TC-19 多 profile 聚合负向与局部失败 + +目的:验证多组织聚合不是“全有或全无”的脆弱实现,局部失败可被结构化表达。 + +步骤: + +```bash +dws --mock --profile corp_alpha,missing_org contact user get-self --format json +dws --profile corp_alpha,missing_org contact user get-self --format json +dws --profile corp_alpha,corp_expired contact user get-self --format json +dws --profile corp_alpha,corp_no_token contact user get-self --format json +dws --profile corp_alpha,,corp_beta contact user get-self --format json +dws --profile corp_alpha, contact user get-self --format json +``` + +断言: + +- selector 解析阶段失败(如 `missing_org`、空 selector)应整体 validation fail,不执行任何组织调用。 +- 已解析 profile 中单组织 token 过期/缺失时,聚合输出 `success=false`、`summary.failed>0`,成功组织仍返回 result。 +- 错误 entry 包含 `message`,若是 typed error 还包含 `category/reason/operation/exitCode`。 +- 局部失败不修改 current/previous。 +- 日志和 stdout 不输出 access token、refresh token、persistent code。 + +### TC-20 高风险指令与 `--profile` 防误用覆盖 + +目的:覆盖删除、授权、升级、导入导出等高风险指令在 `--profile` 下不会被误解为用户确认或范围扩大。 + +步骤与断言: + +| 风险点 | 命令 | 断言 | +|---|---|---| +| 删除类命令 | `dws --profile corp_alpha doc delete --dry-run`、`dws --profile corp_alpha aitable base delete --dry-run` | `--profile` 只选组织,不等同 `--yes`;未确认不执行 | +| 删除类确认 | `dws --profile corp_alpha doc delete --yes` | 只在 Alpha 组织上下文执行;输出记录目标组织/endpoint | +| PAT 批量授权 | `dws --profile corp_alpha pat chmod --products calendar --grant-type once --dry-run --format json` | dry-run 只出 plan;无 `--yes` 不授权 | +| PAT 执行确认 | `dws --profile corp_alpha pat chmod --products calendar --grant-type once --yes --format json` | 用户已确认时才执行;agentCode/sessionId/profile 不混淆 | +| auth logout | `dws --profile corp_alpha auth logout` | 按 TC-15 明确验证:root profile 不能被误认为 local scoped logout | +| auth reset | `dws --profile corp_alpha auth reset` | 仍是全局清理或明确拒绝;不能只清 Alpha 后声称 reset 成功 | +| upgrade rollback | `dws --profile corp_alpha upgrade --rollback --yes` | profile 与 rollback 无关;回滚确认仍由 `--yes` 控制 | +| import/export | `dws --profile corp_alpha auth export/import ...` | 不输出明文 secret;不把 profile 写入 bundle 的业务字段 | + ## 6. Go 回归用例组 脚本默认先执行: @@ -575,8 +899,9 @@ dws pat chmod ... --yes --format json | 风险 | 说明 | 建议 | |---|---|---| | 部分 legacy compat delete path 仍会读 stdin 确认 | `internal/compat/registry.go` 中 `_blocked` 后仍存在 `Confirm? (yes/no)` 交互路径 | 后续可改成非交互环境直接 validation,并提示 `--yes` | -| 真实 OAuth 登录未在黑盒脚本中扫码验证 | 自动脚本 seed 登录后 token,避免人工和网络依赖 | 发布前可追加一次手工 UAT:真实 `auth login` 登录 A/B 两个组织 | -| 远端 revoke/logout 不在黑盒脚本中直连验证 | 网络不稳定且可能影响真实环境 | 已由 Go 回归用 mock/隔离环境覆盖本地删除语义;真实环境只做冒烟 | +| 真实 OAuth 登录未在黑盒脚本中扫码验证 | 自动脚本 seed 登录后 token,避免人工和网络依赖 | 发布前必须追加一次手工 UAT:真实 `auth login` 登录 A/B 两个组织 | +| Runtime 自动化大量使用 `--mock` | `--mock` 只覆盖 CLI 解析和 runner 聚合,不能证明真实 MCP 链路、权限和租户隔离 | 每个产品 root 还必须执行 L2 非 mock 只读冒烟;权限失败也要是结构化远端错误 | +| 远端 revoke/logout 不在黑盒脚本中直连验证 | 网络不稳定且可能影响真实环境 | 已由 Go 回归用隔离环境覆盖本地删除语义;真实环境只做单组织 logout 冒烟 | | TUI 视觉细节不在脚本中截图校验 | 脚本关注机器链路和非交互替代路径 | TUI 视觉可用人工验收或独立截图测试 | ## 9. 手工 UAT 建议 @@ -612,6 +937,8 @@ dws auth logout 必须同时满足: - `bash scripts/dev/test-multi-profile-e2e.sh` 通过。 +- L2 非 mock 真实只读冒烟通过,或对权限不足场景产出结构化错误并确认使用了 selected profile。 +- L3 高风险写操作只在 `--dry-run`、测试租户或显式用户确认后执行。 - `profiles.json` 无敏感字段。 - 所有 P0 功能 F1-F7 至少有一个自动化用例覆盖。 - 关键 TUI/交互入口均有机器指令替代路径。 From ef9c763f0518c6413ec40b4a8b44fa82beec47e4 Mon Sep 17 00:00:00 2001 From: "shangguanxuan.sgx" Date: Mon, 29 Jun 2026 10:59:09 +0800 Subject: [PATCH 16/22] ci: run multi-profile e2e on all branches --- .github/workflows/multi-profile-e2e.yml | 4 ---- docs/ralph/dws-multi-profile-login/test-cases.md | 2 +- 2 files changed, 1 insertion(+), 5 deletions(-) diff --git a/.github/workflows/multi-profile-e2e.yml b/.github/workflows/multi-profile-e2e.yml index bbb663ef..60cd674b 100644 --- a/.github/workflows/multi-profile-e2e.yml +++ b/.github/workflows/multi-profile-e2e.yml @@ -3,10 +3,6 @@ name: Multi Profile E2E on: pull_request: push: - branches: - - main - - codex/** - - feat/** workflow_dispatch: permissions: diff --git a/docs/ralph/dws-multi-profile-login/test-cases.md b/docs/ralph/dws-multi-profile-login/test-cases.md index 9ae9c0f3..c9745051 100644 --- a/docs/ralph/dws-multi-profile-login/test-cases.md +++ b/docs/ralph/dws-multi-profile-login/test-cases.md @@ -29,7 +29,7 @@ GitHub Actions 入口: 触发方式: - PR 自动触发。 -- `main`、`codex/**`、`feat/**` 分支 push 自动触发。 +- 任意分支 push 自动触发。 - Actions 页面手动执行 `workflow_dispatch`。 调试模式: From f973250086ba61557cc5ff592d054041f158cc53 Mon Sep 17 00:00:00 2001 From: "shangguanxuan.sgx" Date: Mon, 29 Jun 2026 11:01:43 +0800 Subject: [PATCH 17/22] docs: document multi-profile e2e ci gate --- .../dws-multi-profile-login/test-cases.md | 39 ++++++++++++++++++- 1 file changed, 38 insertions(+), 1 deletion(-) diff --git a/docs/ralph/dws-multi-profile-login/test-cases.md b/docs/ralph/dws-multi-profile-login/test-cases.md index c9745051..a8f035f3 100644 --- a/docs/ralph/dws-multi-profile-login/test-cases.md +++ b/docs/ralph/dws-multi-profile-login/test-cases.md @@ -47,7 +47,44 @@ bash scripts/dev/test-multi-profile-e2e.sh --keep-workdir - 通过生产 auth 存储 API seed 登录后的 token 结果,不依赖真实扫码。 - 使用真实 CLI 命令验证 profile/auth 命令面和状态变化。 -## 2.1 多 profile 参数设计规范 +## 2.1 CI/CD 质量门 + +新增 GitHub Actions workflow:`Multi Profile E2E`。 + +触发策略: + +- `pull_request`:所有 PR 自动触发。 +- `push`:所有分支 push 自动触发,不限制 `main`、`codex/**` 或 `feat/**`。 +- `workflow_dispatch`:支持在 GitHub Actions 页面手动触发。 + +执行环境: + +- Runner:`ubuntu-latest`。 +- Go 版本:通过 `actions/setup-go@v5` 读取 `go.mod`。 +- Job timeout:15 分钟。 +- 权限:`contents: read`。 +- 并发:同一 ref 使用 `multi-profile-e2e-${{ github.ref }}` 分组,新 push 会取消同分支旧 run。 + +执行命令: + +```bash +bash scripts/dev/test-multi-profile-e2e.sh --keep-workdir +``` + +CI 覆盖内容: + +- 运行 focused Go regressions:`internal/auth`、`internal/app`、`test/cli` 中与 multi-profile、profile flag、auth/profile 命令面相关的用例。 +- 构建临时 `dws` 二进制。 +- 在隔离目录内 seed 多组织 token,不依赖真实扫码和真实 Keychain。 +- 执行 profile list/switch/use、auth status/reset、legacy migration、`--profile corpA, corpB` 多组织读取等黑盒链路。 +- 失败时上传 `.tmp-bin/multi-profile-e2e.*/out` 和 `.tmp-bin/multi-profile-e2e.*/config` 作为排查 artifact。 + +当前落地验证: + +- 本地执行 `bash scripts/dev/test-multi-profile-e2e.sh` 已通过。 +- GitHub Actions 已通过 `Multi Profile E2E`:`https://github.com/shangguanxuan633-lab/dingtalk-workspace-cli/actions/runs/28345765214`。 + +## 2.2 多 profile 参数设计规范 核心规范参照 `lark-cli`:profile 仍是一个全局 string flag,多值由同一个 flag 值承载 CSV 列表。 From e3fac0a0c757b115d2630fb930e1a3a8f33922c6 Mon Sep 17 00:00:00 2001 From: "shangguanxuan.sgx" Date: Mon, 29 Jun 2026 11:04:02 +0800 Subject: [PATCH 18/22] docs: remove multi-profile test cases from pr --- .../dws-multi-profile-login/test-cases.md | 982 ------------------ 1 file changed, 982 deletions(-) delete mode 100644 docs/ralph/dws-multi-profile-login/test-cases.md diff --git a/docs/ralph/dws-multi-profile-login/test-cases.md b/docs/ralph/dws-multi-profile-login/test-cases.md deleted file mode 100644 index a8f035f3..00000000 --- a/docs/ralph/dws-multi-profile-login/test-cases.md +++ /dev/null @@ -1,982 +0,0 @@ -# dws 多组织登录完整测试用例 - -更新时间:2026-06-29 - -## 1. 测试目标 - -验证 dws 多组织登录新增能力在本地隔离环境下完整可用: - -- 多个组织 profile 可独立保存、刷新、切换、删除和迁移。 -- 当前组织、主组织、上一个组织指针行为符合 PRD F1-F8。 -- 业务命令可通过全局 `--profile` 进行单次组织覆盖,也可通过 `--profile corpA,corpB` 或 `--profile corpA, corpB` 一次性读取多个组织,且不修改持久 current profile。 -- 所有涉及 TUI / 交互选择的关键命令,都存在可由 Agent 或脚本执行的机器指令路径。 -- `profiles.json` 只存非敏感元数据,不落 access token、refresh token、persistent code、client secret。 - -## 2. 自动化入口 - -主测试脚本: - -```bash -bash scripts/dev/test-multi-profile-e2e.sh -``` - -GitHub Actions 入口: - -```text -.github/workflows/multi-profile-e2e.yml -``` - -触发方式: - -- PR 自动触发。 -- 任意分支 push 自动触发。 -- Actions 页面手动执行 `workflow_dispatch`。 - -调试模式: - -```bash -bash scripts/dev/test-multi-profile-e2e.sh --skip-go-tests --verbose -bash scripts/dev/test-multi-profile-e2e.sh --keep-workdir -``` - -脚本行为: - -- 使用临时 `DWS_CONFIG_DIR`、`DWS_KEYCHAIN_DIR`、`DWS_CACHE_DIR`。 -- 设置 `DWS_DISABLE_KEYCHAIN=1`,避免写入真实系统 Keychain。 -- 构建临时 `dws` 二进制。 -- 通过生产 auth 存储 API seed 登录后的 token 结果,不依赖真实扫码。 -- 使用真实 CLI 命令验证 profile/auth 命令面和状态变化。 - -## 2.1 CI/CD 质量门 - -新增 GitHub Actions workflow:`Multi Profile E2E`。 - -触发策略: - -- `pull_request`:所有 PR 自动触发。 -- `push`:所有分支 push 自动触发,不限制 `main`、`codex/**` 或 `feat/**`。 -- `workflow_dispatch`:支持在 GitHub Actions 页面手动触发。 - -执行环境: - -- Runner:`ubuntu-latest`。 -- Go 版本:通过 `actions/setup-go@v5` 读取 `go.mod`。 -- Job timeout:15 分钟。 -- 权限:`contents: read`。 -- 并发:同一 ref 使用 `multi-profile-e2e-${{ github.ref }}` 分组,新 push 会取消同分支旧 run。 - -执行命令: - -```bash -bash scripts/dev/test-multi-profile-e2e.sh --keep-workdir -``` - -CI 覆盖内容: - -- 运行 focused Go regressions:`internal/auth`、`internal/app`、`test/cli` 中与 multi-profile、profile flag、auth/profile 命令面相关的用例。 -- 构建临时 `dws` 二进制。 -- 在隔离目录内 seed 多组织 token,不依赖真实扫码和真实 Keychain。 -- 执行 profile list/switch/use、auth status/reset、legacy migration、`--profile corpA, corpB` 多组织读取等黑盒链路。 -- 失败时上传 `.tmp-bin/multi-profile-e2e.*/out` 和 `.tmp-bin/multi-profile-e2e.*/config` 作为排查 artifact。 - -当前落地验证: - -- 本地执行 `bash scripts/dev/test-multi-profile-e2e.sh` 已通过。 -- GitHub Actions 已通过 `Multi Profile E2E`:`https://github.com/shangguanxuan633-lab/dingtalk-workspace-cli/actions/runs/28345765214`。 - -## 2.2 多 profile 参数设计规范 - -核心规范参照 `lark-cli`:profile 仍是一个全局 string flag,多值由同一个 flag 值承载 CSV 列表。 - -- 推荐写法:`dws --profile corpA,corpB contact user get-self --format json`。 -- 容错写法:`dws --profile corpA, corpB contact user get-self --format json`,CLI 在 Cobra 解析前规整为 `corpA,corpB`。 -- 兼容目标:保持 `lark-cli` 一类 CSV 多值参数的简单心智模型,同时吸收钉钉历史 CLI 对逗号列表的支持方式。 -- 非目标:不把 `--profile corpA --profile corpB` 作为主推荐 API;重复 flag 若后续支持,只能作为兼容增强,不改变 CSV 为主的规范。 - -## 3. 覆盖矩阵 - -| PRD 功能 | 覆盖方式 | 用例 | -|---|---|---| -| F1 多组织登录写入 profile | seed token + CLI list/status/token assert | TC-03, TC-04, TC-05 | -| F2 profile 元数据列表 | `dws profile list --format json/table` | TC-02, TC-03, TC-04 | -| F3 切换当前组织 | `profile switch/use `、`profile switch -` | TC-07, TC-08 | -| F4 单次命令临时指定组织 | `dws --profile ... auth status`、`auth status --profile ...` | TC-10 | -| F5 按 profile 查看认证状态 | `auth status --format json` | TC-03, TC-10 | -| F6 退出与重置 | `auth reset`;`auth logout` 由 Go 回归覆盖 | TC-12, GT-05 | -| F7 legacy 单槽迁移 | seed legacy `auth-token` 后触发 `profile list` | TC-13 | -| F8 agent 跨组织聚合原语 | `--profile corpA,corpB` / `--profile corpA, corpB` 聚合读取 + `profile list` 枚举 | TC-11, MTC-01 | -| TUI 机器替代路径 | help surface + 非交互失败断言 | MTC-01 至 MTC-07 | - -## 3.1 `--profile` 命令级覆盖矩阵 - -本次 `--profile` 变更影响的是 root persistent flag、runtime runner、auth/profile 管理命令和所有会读取登录态的命令。测试按以下语义分层: - -| 语义 | 说明 | 断言重点 | -|---|---|---| -| P-SINGLE | 单 profile 临时覆盖 | 使用指定组织 token / runtime profile;不修改 `currentProfile`、`previousProfile` | -| P-MULTI | CSV 多 profile 聚合 | 输出 `multiProfile=true`,按输入顺序返回每个组织结果;不修改持久 profile 指针 | -| P-LOCAL | 命令自己的 `--profile` | local flag 优先于 root persistent flag;只影响该命令定义的局部行为 | -| P-IGNORED | 接受 root `--profile` 但命令本身不读组织态 | 命令成功;输出与未传 profile 一致;不修改 profile 指针 | -| P-UNSUPPORTED | 多 profile 对该命令无业务语义,应明确失败或不扩大影响 | 不静默误删、不默认切换、不把第二个 profile 当业务参数 | - -### 3.1.1 Auth / Profile 管理命令 - -| 指令 | 必测 `--profile` 场景 | 预期 | -|---|---|---| -| `dws auth login` | `dws --profile corp_alpha auth login --device --format json`、`dws auth login --profile corp_alpha` help 示例 | 指定本次授权目标组织;不持久切换 current;缺失 profile 返回 validation | -| `dws auth status` | `dws --profile corp_alpha auth status --format json`、`dws auth status --profile corp_beta --format json`、root + local 同时存在 | local `--profile` 优先;root `--profile` 可选中 token;均不修改 current | -| `dws auth logout` | `dws auth logout --profile corp_alpha`、`dws --profile corp_alpha auth logout`、`dws auth logout` | local `--profile` 只退出单组织;root `--profile` 不应被误认为单组织 logout;默认仍退出全部 | -| `dws auth reset` | `dws --profile corp_alpha auth reset`、`dws auth reset` | reset 是全局清理;root `--profile` 不改变清理范围 | -| `dws auth export` | `dws --profile corp_alpha auth export --base64` | 导出当前 runtime profile 对应 token/配置,或明确说明导出全量;不修改 current | -| `dws auth import` | `dws --profile corp_alpha auth import --input ` | import 的写入语义不被 root `--profile` 误导;导入后 profile 指针符合 bundle/实现契约 | -| `dws auth exchange` | `dws --profile corp_alpha auth exchange --code ` | 仅在真实 OAuth/UAT 中验证;目标 profile 解析不应泄漏到业务参数 | -| `dws profile list` | `dws --profile corp_alpha profile list --format json` | list 永远列出全部 profile;root `--profile` 不过滤、不切换 | -| `dws profile switch` | `dws --profile corp_alpha profile switch corp_beta --format json`、`dws profile switch --corpId corp_beta`、`dws profile switch --name "Beta Org"`、`dws profile switch -` | 显式 selector 决定持久切换;root `--profile` 不覆盖 switch 目标 | -| `dws profile use` | `dws --profile corp_alpha profile use corp_gamma --format json`、`dws profile use -` | 与 switch 等价;root `--profile` 不改变 use 目标 | - -### 3.1.2 Runtime / 产品命令 - -所有 runtime 产品命令均必须覆盖 `P-SINGLE` 和 `P-MULTI`,但不能把 `--mock` 当作唯一测试方式。测试分三层: - -- L1 自动化回归:允许使用 `--mock`,只验证 CLI 参数解析、runner 注入、聚合结构、无副作用和 `profile` 不泄漏为业务参数。 -- L2 真实只读冒烟:不加 `--mock`,对每个产品 root 选择一个当前可见的只读 leaf,验证真实 token 读取、endpoint 解析、远端调用链路。业务可以因权限/数据为空返回结构化错误,但不能是 CLI 解析错误、profile 解析错误或错误组织 token。 -- L3 高风险写操作:不加 `--mock` 时必须使用 `--dry-run`、测试租户或显式 `--yes` 确认;测试重点是 `--profile` 只选择组织,不替代确认、不扩大操作范围。 - -| 产品根命令 | L1 自动化回归(可 mock) | L2 真实只读冒烟(不可 mock) | 断言 | -|---|---|---|---| -| `aisearch` | `dws --mock --profile corp_alpha,corp_beta aisearch person --keyword Alice --format json` | `dws --profile corp_alpha aisearch person --keyword Alice --format json` | L1 聚合;L2 使用 Alpha token 调真实链路 | -| `aitable` | `dws --mock --profile corp_alpha,corp_beta aitable base list --format json` | `dws --profile corp_alpha aitable base list --format json` | 不泄漏 `profile` 业务参数;真实链路不因 profile 解析失败 | -| `attendance` | `dws --mock --profile corp_alpha,corp_beta attendance group list --format json` | `dws --profile corp_alpha attendance group list --format json` | 输出按组织聚合;真实链路使用 selected profile | -| `calendar` | `dws --mock --profile corp_alpha,corp_beta calendar event list --format json` | `dws --profile corp_alpha calendar event list --format json` | 单 profile 不改 current;真实只读日程链路可达 | -| `chat` | `dws --mock chat search --query test --profile corp_alpha, corp_beta --format json` | `dws --profile corp_alpha chat search --query test --format json` | leaf 后 `--profile` 解析;真实链路不误用 current | -| `conference` | `dws --mock --profile corp_alpha,corp_beta conference list --format json` | `dws --profile corp_alpha conference list --format json` | L1 不触网;L2 真实会议只读链路 | -| `contact` | `dws --mock --profile corp_alpha, corp_beta contact user get-self --format json` | `dws --profile corp_alpha contact user get-self --format json` | 覆盖不加引号 CSV continuation;真实返回当前用户身份 | -| `devdoc` | `dws --mock --profile corp_alpha,corp_beta devdoc article search --query auth --format json` | `dws --profile corp_alpha devdoc article search --query auth --format json` | 文档类命令也接受 global profile;真实搜索链路可达 | -| `ding` | `dws --mock --profile corp_alpha,corp_beta ding list --format json` | `dws --profile corp_alpha ding list --format json` | 无业务参数污染;真实只读链路可达或结构化权限错误 | -| `doc` | `dws --mock --profile corp_alpha,corp_beta doc search --query test --format json` | `dws --profile corp_alpha doc search --query test --format json` | doc helper/runtime 均覆盖;真实文档搜索链路 | -| `doc-comment` | `dws --mock --profile corp_alpha,corp_beta doc-comment list --node doc_x --format json` | `dws --profile corp_alpha doc-comment list --node --format json` | serverOverride 子 server 覆盖;真实用测试文档节点 | -| `drive` | `dws --mock --profile corp_alpha,corp_beta drive file list --format json` | `dws --profile corp_alpha drive file list --format json` | 真实云盘只读链路;不修改 current | -| `hrmregister` | `dws --mock --profile corp_alpha,corp_beta hrmregister field list --format json` | `dws --profile corp_alpha hrmregister field list --format json` | 子 server 覆盖;真实权限错误也要结构化 | -| `live` | `dws --mock --profile corp_alpha,corp_beta live list --format json` | `dws --profile corp_alpha live list --format json` | 聚合结构一致;真实只读直播列表 | -| `mail` | `dws --mock --profile corp_alpha,corp_beta mail message list --format json` | `dws --profile corp_alpha mail message list --format json` | 不泄漏 profile 参数;真实邮箱权限链路 | -| `minutes` | `dws --mock --profile corp_alpha,corp_beta minutes list mine --format json` | `dws --profile corp_alpha minutes list mine --format json` | 多 profile 每组织独立 result;真实听记列表 | -| `oa` | `dws --mock --profile corp_alpha,corp_beta oa list-pending --format json` | `dws --profile corp_alpha oa list-pending --format json` | 真实审批只读列表;不修改 current | -| `pat` | `dws --mock --profile corp_alpha,corp_beta pat status --format json` | `dws --profile corp_alpha pat status --format json` | PAT runtime 命令和 `pat chmod` utility 分开测 | -| `report` | `dws --mock --profile corp_alpha,corp_beta report template list --format json` | `dws --profile corp_alpha report template list --format json` | 日志产品真实只读链路 | -| `sheet` | `dws --mock --profile corp_alpha,corp_beta sheet read --sheet-id sh_x --format json` | `dws --profile corp_alpha sheet read --sheet-id --format json` | 参数存在时 profile 不混入 params;真实用测试表格 | -| `todo` | `dws --mock --profile corp_alpha,corp_beta todo task list --format json` | `dws --profile corp_alpha todo task list --format json` | 已有 helper merge 路径覆盖;真实待办只读链路 | -| `wiki` | `dws --mock --profile corp_alpha,corp_beta wiki space list --format json` | `dws --profile corp_alpha wiki space list --format json` | 真实知识库只读链路 | - -说明:若某个示例 leaf 在当前 discovery 快照中不存在,自动化生成器必须用该产品当前可见的第一个只读 leaf 替换,并在测试报告记录替换后的真实路径。覆盖目标是“每个产品 root 至少一个 leaf 的 L1 + L2”,不是绑定上表的文案路径。 - -### 3.1.3 Utility / 非 runtime 命令 - -这些命令必须测试 root `--profile` 是否被正确接受、正确忽略或正确用于 token 读取。 - -| 指令 | 必测命令 | 预期 | -|---|---|---| -| `dws api` | `dws --profile corp_alpha api GET /v1.0/contact/users/me --dry-run --format json` | raw API token 从 selected profile 解析;dry-run 不触网;不修改 current | -| `dws doctor` | `dws --profile corp_alpha doctor --json` | auth check 使用 selected profile;网络/cache/version 检查不被 profile 污染 | -| `dws cache status` | `dws --profile corp_alpha cache status --json` | profile 被接受但不影响 cache status | -| `dws cache refresh` | `dws --profile corp_alpha cache refresh --product contact` | refresh 请求使用 selected profile token;不修改 current | -| `dws schema` | `dws --profile corp_alpha schema contact.user.get-self --format json` | schema/discovery 读取 selected token;无 profile 参数泄漏 | -| `dws recovery plan` | `dws --profile corp_alpha recovery plan --last -f json` | 恢复分析中的 runtime 调用使用 selected profile;无快照时按原错误返回 | -| `dws recovery execute` | `dws --profile corp_alpha recovery execute --last -f json` | 同 plan;不修改 current | -| `dws recovery finalize` | `dws --profile corp_alpha recovery finalize --event-id evt --outcome recovered` | finalize 为本地状态写入,profile 应被接受但不改变语义 | -| `dws skill setup` | `dws --profile corp_alpha skill setup --mode mono --yes` | profile 被接受但 skill 安装布局不被影响 | -| `dws skill install` | `dws --profile corp_alpha skill install claude` | 若需联网,profile 不应成为业务参数;认证失败仍按原错误 | -| `dws skill get/search/find/add` | `dws --profile corp_alpha skill search --query doc` 等 | profile 被接受;输出与未传 profile 一致 | -| `dws plugin list/info/install/remove/enable/disable/validate/create/dev/build/config` | 每个子命令加 root `--profile corp_alpha` 跑 help 或 dry-run/validation | 插件管理不应读取/修改组织 profile | -| `dws config list` | `dws --profile corp_alpha config list --json` | profile 被接受;配置输出不被过滤 | -| `dws completion` | `dws --profile corp_alpha completion zsh` | profile 被接受;补全文本包含 profile flag | -| `dws version` | `dws --profile corp_alpha version --format json` | 输出版本信息;无 profile 副作用 | -| `dws upgrade` | `dws --profile corp_alpha upgrade --check --format json`、`dws --profile corp_alpha upgrade --dry-run` | upgrade 与组织无关;profile 被接受但不改变升级计划 | -| `dws pat chmod` | `dws --profile corp_alpha pat chmod --products calendar --dry-run --format json` | 批量授权读取 selected profile / agent context;未加 `--yes` 不执行授权 | - -## 4. 测试数据 - -自动化脚本使用以下虚拟组织: - -| 组织 | corpId | corpName | userId | access token | -|---|---|---|---|---| -| Alpha | `corp_alpha` | `Alpha Org` | `user_alpha` | `access-alpha-v1` | -| Beta | `corp_beta` | `Beta Org` | `user_beta` | `access-beta-v1/v2` | -| Gamma | `corp_gamma` | `Beta Org` | `user_gamma` | `access-gamma-v1` | -| Legacy | `corp_legacy` | `Legacy Org` | `user_legacy` | `access-legacy-v1` | - -Gamma 故意与 Beta 使用相同 `corpName`,用于验证重复组织名的稳定 fallback name。 - -## 5. 自动化黑盒用例 - -### TC-01 命令面与机器指令入口 - -目的:确认关键交互能力都有非 TUI / Agent 可执行入口。 - -步骤: - -```bash -dws --help -dws profile --help -dws auth login --help -dws skill setup --help -dws upgrade --help -dws dev connect --help -dws doc delete --help -dws aitable base delete --help -dws auth --help -``` - -断言: - -- 根命令展示 `--profile`、`--yes`、`--dry-run`。 -- `profile` 展示 `list`、`switch`、`use`、全局 `--profile`。 -- `auth login` 展示 `--device`、`--token`、`--recommend`、`--yes`。 -- `skill setup` 展示 `--mode`、`--target`、`--yes`、`--skill`、`--exclude`。 -- `upgrade` 展示 `--dry-run`、`--yes`。 -- `dev connect` 展示 `--robot-client-id`、`--robot-client-secret`、`--unified-app-id`、`--agent-cmd`、`--daemon`。 -- 删除类命令展示 `--yes`。 -- `auth` 命令组不暴露 `switch`。 - -### TC-02 空 profile 列表 - -步骤: - -```bash -dws profile list --format json -``` - -断言: - -- `success=true`。 -- `profiles=[]`。 -- `primaryProfile/currentProfile/previousProfile` 为空。 - -### TC-03 首次组织登录后创建 primary/current profile - -前置: - -- 通过 helper seed `corp_alpha` token。 - -步骤: - -```bash -dws profile list --format json -dws auth status --format json -``` - -断言: - -- `profiles` 数量为 1。 -- `primaryProfile=corp_alpha`。 -- `currentProfile=corp_alpha`。 -- `previousProfile` 为空。 -- `auth status` 返回 Alpha 的 `corpId/corpName/userId`。 -- 默认 token mirror 指向 `corp_alpha`。 -- corp-scoped token `auth-token:corp_alpha` 存在。 -- `profiles.json` 不包含敏感字段。 - -### TC-04 第二组织登录不覆盖第一组织 - -前置: - -- 已存在 `corp_alpha`。 -- seed `corp_beta` token。 - -步骤: - -```bash -dws profile list --format json -``` - -断言: - -- `profiles` 数量为 2。 -- `primaryProfile=corp_alpha`。 -- `currentProfile=corp_beta`。 -- `previousProfile=corp_alpha`。 -- Alpha token 仍为 `access-alpha-v1`。 -- Beta token 为 `access-beta-v1`。 -- legacy mirror 指向 `corp_beta`。 - -### TC-05 同组织重复登录只刷新 token,不新增 profile - -前置: - -- 已存在 `corp_beta`。 -- 再次 seed `corp_beta`,access token 改为 `access-beta-v2`。 - -步骤: - -```bash -dws profile list --format json -``` - -断言: - -- `profiles` 数量仍为 2。 -- `currentProfile=corp_beta`。 -- `previousProfile=corp_alpha`。 -- `auth-token:corp_beta` 的 access token 更新为 `access-beta-v2`。 - -### TC-06 重复组织名生成稳定 fallback name - -前置: - -- 已存在 `corp_beta`,`corpName=Beta Org`。 -- seed `corp_gamma`,`corpName=Beta Org`。 - -步骤: - -```bash -dws profile list --format json -``` - -断言: - -- `profiles` 数量为 3。 -- `currentProfile=corp_gamma`。 -- `previousProfile=corp_beta`。 -- `corp_gamma` 的 `corpName=Beta Org`。 -- `corp_gamma` 的本地 profile name 不等于裸 `Beta Org`,而是带 corpId 后缀的稳定 fallback。 -- JSON 输出不暴露本地 `name` 字段。 - -### TC-07 按 corpId 切换组织并同步 legacy mirror - -步骤: - -```bash -dws profile switch corp_alpha --format json -dws profile switch corp_beta --format table -``` - -断言: - -- 第一次切换后 `currentProfile=corp_alpha`。 -- 第一次切换后 `previousProfile=corp_gamma`。 -- JSON 输出包含 Alpha 的 `corpId/corpName`,且 `isCurrent=true`。 -- legacy mirror 指向 `corp_alpha`。 -- 第二次切换后 `currentProfile=corp_beta`。 -- 第二次切换后 `previousProfile=corp_alpha`。 -- 表格输出包含 `Beta Org` 和 `corp_beta`。 -- legacy mirror 指向 `corp_beta`。 - -### TC-08 使用 previousProfile 快速切回 - -步骤: - -```bash -dws profile switch - --format json -``` - -断言: - -- 当前组织从 `corp_beta` 切回 `corp_alpha`。 -- `previousProfile=corp_beta`。 -- JSON 输出包含 Alpha,并标记为 current。 - -### TC-09 `profile use` 兼容 switch 语义 - -步骤: - -```bash -dws profile use corp_gamma --format json -``` - -断言: - -- `currentProfile=corp_gamma`。 -- `previousProfile=corp_alpha`。 -- JSON 输出包含 Gamma。 -- legacy mirror 指向 `corp_gamma`。 - -### TC-10 单次 profile override 不修改 currentProfile - -步骤: - -```bash -dws --profile corp_alpha auth status --format json -dws auth status --profile corp_beta --format json -dws auth status --format json -``` - -断言: - -- 第一条返回 Alpha。 -- 第二条返回 Beta。 -- 两条 override 后 `currentProfile` 仍为 `corp_gamma`。 -- 第三条默认返回 Gamma。 -- `previousProfile` 不因 override 改变。 - -### TC-11 多 profile 一次性读取信息 - -L1 自动化回归步骤: - -```bash -dws --mock --profile corp_alpha, corp_beta contact user get-self --format json -dws --mock contact user get-self --profile corp_alpha, corp_beta --format json -``` - -L2 真实只读冒烟步骤: - -```bash -dws --profile corp_alpha contact user get-self --format json -dws --profile corp_beta contact user get-self --format json -dws --profile corp_alpha,corp_beta contact user get-self --format json -dws contact user get-self --profile corp_alpha, corp_beta --format json -``` - -断言: - -- L1 输出为聚合对象,`multiProfile=true`。 -- L1 `success=true`。 -- L1 `summary.total=2`、`summary.succeeded=2`、`summary.failed=0`。 -- L1 `profiles[0].corpId=corp_alpha`,`profiles[1].corpId=corp_beta`。 -- L1 每个 `profiles[i].ok=true`,且每个组织都有独立 `result`。 -- L2 不允许使用 `--mock`;真实返回应能证明使用了对应组织 token,若远端权限不足,也必须是结构化权限/业务错误而不是 CLI profile 解析错误。 -- L2 多 profile 输出仍为聚合对象;允许单个组织因权限/数据状态失败,但 `summary` 和每个 `profiles[i].error` 必须结构化。 -- 执行后 `currentProfile` 仍为 `corp_gamma`,`previousProfile` 仍为 `corp_alpha`。 -- `--profile` 放在根命令后或 leaf 命令后都可解析。 -- `--profile corp_alpha,corp_alpha,corp_beta` 按 resolved `corpId` 去重后只执行 Alpha/Beta 两个组织。 -- 若存在历史 profile name 本身为 `alpha,beta`,优先按单 profile 精确匹配,不触发聚合,保持向前兼容。 - -### TC-12 `auth reset` 清除所有本地认证态 - -前置: - -- 保存测试 app config。 - -步骤: - -```bash -dws auth reset -``` - -断言: - -- 输出包含 `[OK]`。 -- `profiles.json` 清空或不存在。 -- 所有 profile-scoped token 删除。 -- legacy `auth-token` 删除。 -- `token.json` marker 删除。 -- app config 删除。 - -### TC-13 legacy 单槽自动迁移 - -前置: - -- 清空 profiles。 -- 只写入 legacy `auth-token`,token 中包含 `corp_legacy`。 - -步骤: - -```bash -dws profile list --format json -``` - -断言: - -- 自动生成 profile。 -- `primaryProfile=corp_legacy`。 -- `currentProfile=corp_legacy`。 -- `previousProfile` 为空。 -- corp-scoped `auth-token:corp_legacy` 存在。 -- legacy mirror 仍可读取。 - -### TC-14 `--profile` 参数解析全形态 - -目的:确保 root persistent `--profile` 和 leaf 后置 `--profile` 都符合 `lark-cli` 风格 CSV 规范,并对未加引号空格写法做容错。 - -L1 parser/runner 自动化步骤,可使用 `--mock` 隔离远端依赖: - -```bash -dws --mock --profile corp_alpha contact user get-self --format json -dws --mock --profile corp_alpha,corp_beta contact user get-self --format json -dws --mock --profile corp_alpha, corp_beta contact user get-self --format json -dws --mock --profile=corp_alpha, corp_beta contact user get-self --format json -dws --mock contact user get-self --profile corp_alpha --format json -dws --mock contact user get-self --profile corp_alpha, corp_beta --format json -dws --mock --profile corp_alpha,corp_alpha,corp_beta contact user get-self --format json -``` - -负向步骤: - -```bash -dws --mock --profile corp_alpha, contact user get-self --format json -dws --mock --profile corp_alpha,,corp_beta contact user get-self --format json -dws --mock --profile missing_org,corp_beta contact user get-self --format json -``` - -L2 真实链路步骤,不使用 `--mock`: - -```bash -dws --profile corp_alpha contact user get-self --format json -dws --profile corp_alpha,corp_beta contact user get-self --format json -dws contact user get-self --profile corp_alpha, corp_beta --format json -``` - -断言: - -- L1 单 profile 输出不是聚合对象,且 current profile 不变。 -- L1 CSV 多 profile 输出 `multiProfile=true`。 -- L1 `corp_alpha, corp_beta` 在 Cobra 解析前被规整,不会把 `corp_beta` 识别为子命令或位置参数。 -- `--profile=corp_alpha, corp_beta` 与 `--profile corp_alpha, corp_beta` 等价。 -- leaf 后置 `--profile` 与 root 前置 `--profile` 等价。 -- 重复 profile 按 resolved `corpId` 去重,返回 Alpha/Beta 两项。 -- 尾部逗号、连续逗号、缺失 profile 均返回 validation error,且不执行任何 runtime 调用。 -- 若存在本地 profile name 为 `alpha,beta`,`--profile alpha,beta` 先按单 profile 精确匹配,不触发聚合。 -- L2 真实链路必须实际读取对应组织 token;失败只接受认证、权限或业务层结构化错误,不接受 mock payload。 - -### TC-15 Auth 命令 `--profile` 覆盖 - -目的:覆盖每个 auth 子命令对 root/local `--profile` 的支持、忽略或拒绝语义,防止误删、误切换、误用 token。 - -前置: - -- 已存在 `corp_alpha`、`corp_beta`、`corp_gamma`。 -- 当前组织为 `corp_gamma`,previous 为 `corp_alpha`。 - -步骤与断言: - -| 用例 | 命令 | 断言 | -|---|---|---| -| AUTH-P01 root profile status | `dws --profile corp_alpha auth status --format json` | 返回 Alpha;`currentProfile` 仍为 Gamma | -| AUTH-P02 local profile status | `dws auth status --profile corp_beta --format json` | 返回 Beta;`currentProfile` 仍为 Gamma | -| AUTH-P03 local wins | `dws --profile corp_alpha auth status --profile corp_beta --format json` | 返回 Beta;root profile 不覆盖 local profile | -| AUTH-P04 missing status profile | `dws auth status --profile missing_org --format json` | 返回未登录或 validation,不修改 current | -| AUTH-P05 multi status unsupported | `dws auth status --profile corp_alpha,corp_beta --format json` | 不聚合;返回 validation/未登录,避免静默读取错误 profile | -| AUTH-P06 scoped logout | `dws auth logout --profile corp_alpha` | 只删除 Alpha token/profile;Beta/Gamma 保留;current/primary 指针重算正确 | -| AUTH-P07 root profile must not scope logout | `dws --profile corp_alpha auth logout` | 按默认 logout 全部清理,或未来若改为拒绝则必须明确报错;绝不能“看似成功但只删一部分” | -| AUTH-P08 logout local wins | `dws --profile corp_alpha auth logout --profile corp_beta` | 只退出 Beta;Alpha/Gamma 保留 | -| AUTH-P09 reset ignores profile | `dws --profile corp_alpha auth reset` | 清空所有 token、profiles、marker、app config | -| AUTH-P10 login target | `dws --profile corp_alpha auth login --device --format json` | 授权目标解析为 Alpha corpId;不持久切换 current | -| AUTH-P11 login missing profile | `dws --profile missing_org auth login --device --format json` | 授权前 validation fail | -| AUTH-P12 export profile | `dws --profile corp_alpha auth export --base64` | 导出与 Alpha 相关的认证资料,输出不包含明文 secret | -| AUTH-P13 import with profile | `dws --profile corp_alpha auth import --input bundle.json` | import 语义不被 root profile 误导;导入后 token/profile 指针符合 bundle 内容 | -| AUTH-P14 exchange with profile | `dws --profile corp_alpha auth exchange --code ` | 真实 UAT 验证;profile 不泄漏为 exchange 业务参数 | - -### TC-16 Profile 命令 `--profile` 覆盖 - -目的:确认 profile 管理命令的显式 selector 优先,root `--profile` 只作为全局 flag 被接受,不会偷偷切换或过滤。 - -步骤: - -```bash -dws --profile corp_alpha profile list --format json -dws --profile corp_alpha profile switch corp_beta --format json -dws --profile corp_alpha profile switch --corpId corp_gamma --format json -dws --profile corp_beta profile switch --name "Alpha Org" --format json -dws --profile corp_beta profile switch - --format json -dws --profile corp_alpha profile use corp_gamma --format json -dws --profile corp_alpha profile use - --format json -``` - -负向步骤: - -```bash -dws --profile corp_alpha profile switch -dws --profile corp_alpha profile switch corp_beta --corpId corp_gamma -dws --profile corp_alpha profile switch missing_org -``` - -断言: - -- `profile list` 仍返回全部组织,不被 root `--profile` 过滤。 -- `profile switch/use` 的位置参数、`--corpId`、`--name`、`-` 决定持久切换目标。 -- root `--profile` 不覆盖显式 selector。 -- 切换后 legacy mirror 同步到新的 current。 -- 非交互无 selector 仍 validation fail,不因 root `--profile` 自动选择。 -- 冲突 selector 返回 validation fail,不修改 current/previous。 - -### TC-17 Utility 命令 `--profile` 覆盖 - -目的:覆盖所有 utility 命令是否正确接受、忽略或使用 root `--profile`。 - -步骤与断言: - -| 用例 | 命令 | 断言 | -|---|---|---| -| UTIL-P01 raw API dry-run | `dws --profile corp_alpha api GET /v1.0/contact/users/me --dry-run --format json` | 使用 Alpha token 构造请求;不触网;current 不变 | -| UTIL-P02 doctor auth | `dws --profile corp_alpha doctor --json` | auth check 针对 Alpha;network/cache/version check 不被污染 | -| UTIL-P03 cache status | `dws --profile corp_alpha cache status --json` | 输出 cache 状态;profile 无副作用 | -| UTIL-P04 cache refresh | `dws --profile corp_alpha cache refresh --product contact` | refresh 读取 Alpha token;不修改 current | -| UTIL-P05 schema | `dws --profile corp_alpha schema contact.user.get-self --format json` | schema/discovery 使用 Alpha 上下文;输出不含 profile 业务参数 | -| UTIL-P06 recovery plan | `dws --profile corp_alpha recovery plan --last -f json` | 有快照时 runtime 分析用 Alpha;无快照时错误语义不变 | -| UTIL-P07 recovery execute | `dws --profile corp_alpha recovery execute --last -f json` | 同 plan | -| UTIL-P08 recovery finalize | `dws --profile corp_alpha recovery finalize --event-id evt_test --outcome recovered` | 本地 finalize 不受 profile 影响 | -| UTIL-P09 skill setup | `dws --profile corp_alpha skill setup --mode mono --yes` | skill 布局与未传 profile 一致 | -| UTIL-P10 skill query | `dws --profile corp_alpha skill search --query doc` | 搜索/查询类输出与未传 profile 一致 | -| UTIL-P11 plugin list | `dws --profile corp_alpha plugin list --format json` | 插件列表不读组织态 | -| UTIL-P12 plugin validate | `dws --profile corp_alpha plugin validate ` | 插件校验不读组织态 | -| UTIL-P13 config list | `dws --profile corp_alpha config list --json` | 配置列表不被 profile 过滤 | -| UTIL-P14 completion | `dws --profile corp_alpha completion zsh` | 生成补全脚本,包含 `--profile` flag | -| UTIL-P15 version | `dws --profile corp_alpha version --format json` | 版本输出不变 | -| UTIL-P16 upgrade check | `dws --profile corp_alpha upgrade --check --format json` | 升级检查与组织无关 | -| UTIL-P17 upgrade dry-run | `dws --profile corp_alpha upgrade --dry-run` | 计划输出与未传 profile 一致 | -| UTIL-P18 pat chmod dry-run | `dws --profile corp_alpha pat chmod --products calendar --dry-run --format json` | 只生成授权 plan;未加 `--yes` 不执行授权;使用 Alpha 上下文 | - -断言通用要求: - -- 所有命令执行前后 `currentProfile/previousProfile` 不变,除非命令本身是 `profile switch/use` 或 auth 清理命令。 -- 对 P-IGNORED 命令,输出不得因 `--profile` 改变业务范围。 -- 对 P-SINGLE 命令,token 解析必须落到 selected profile。 -- 对 P-UNSUPPORTED 命令,多 profile 必须明确失败或保持原语义,不能将第二个 profile 当位置参数继续执行。 - -### TC-18 Runtime 产品命令全量 `--profile` 覆盖 - -目的:每个 runtime 产品 root 至少选一个只读 leaf,覆盖 mock 自动化回归和非 mock 真实冒烟。若 discovery 中 leaf 名称变化,脚本动态选择该产品第一个只读 leaf,并在测试报告记录真实命令。 - -L1 自动化回归步骤: - -```bash -for product in aisearch aitable attendance calendar chat conference contact devdoc ding doc doc-comment drive hrmregister live mail minutes oa pat report sheet todo wiki; do - dws --mock --profile corp_alpha "$product" --format json - dws --mock --profile corp_alpha,corp_beta "$product" --format json - dws --mock "$product" --profile corp_alpha, corp_beta --format json -done -``` - -L2 真实只读冒烟步骤: - -```bash -for product in aisearch aitable attendance calendar chat conference contact devdoc ding doc doc-comment drive hrmregister live mail minutes oa pat report sheet todo wiki; do - dws --profile corp_alpha "$product" --format json - dws --profile corp_alpha,corp_beta "$product" --format json -done -``` - -每个产品的断言: - -- L1 单 profile:runner 执行时 `RuntimeProfile=corp_alpha`;输出不是聚合对象。 -- L1 多 profile:输出 `multiProfile=true`。 -- L1 `summary.total` 等于去重后组织数。 -- L1 `profiles[*].corpId` 按输入顺序返回。 -- 每个 entry 都有 `ok`;成功时有 `result`;失败时有结构化 `error`。 -- 任意产品命令的 invocation params 中不能出现 root `profile` 业务参数。 -- leaf 后置 `--profile corp_alpha, corp_beta` 也能被 parser 规整。 -- L2 不允许使用 `--mock`;必须真实经过 token resolver、endpoint resolver、transport 调用或真实 stdio client。 -- L2 允许远端返回权限/业务错误,但必须能证明请求落在 selected profile;不能是 current profile 泄漏、profile 参数泄漏或 CLI 解析错误。 -- 命令执行后 current/previous 不变。 - -### TC-19 多 profile 聚合负向与局部失败 - -目的:验证多组织聚合不是“全有或全无”的脆弱实现,局部失败可被结构化表达。 - -步骤: - -```bash -dws --mock --profile corp_alpha,missing_org contact user get-self --format json -dws --profile corp_alpha,missing_org contact user get-self --format json -dws --profile corp_alpha,corp_expired contact user get-self --format json -dws --profile corp_alpha,corp_no_token contact user get-self --format json -dws --profile corp_alpha,,corp_beta contact user get-self --format json -dws --profile corp_alpha, contact user get-self --format json -``` - -断言: - -- selector 解析阶段失败(如 `missing_org`、空 selector)应整体 validation fail,不执行任何组织调用。 -- 已解析 profile 中单组织 token 过期/缺失时,聚合输出 `success=false`、`summary.failed>0`,成功组织仍返回 result。 -- 错误 entry 包含 `message`,若是 typed error 还包含 `category/reason/operation/exitCode`。 -- 局部失败不修改 current/previous。 -- 日志和 stdout 不输出 access token、refresh token、persistent code。 - -### TC-20 高风险指令与 `--profile` 防误用覆盖 - -目的:覆盖删除、授权、升级、导入导出等高风险指令在 `--profile` 下不会被误解为用户确认或范围扩大。 - -步骤与断言: - -| 风险点 | 命令 | 断言 | -|---|---|---| -| 删除类命令 | `dws --profile corp_alpha doc delete --dry-run`、`dws --profile corp_alpha aitable base delete --dry-run` | `--profile` 只选组织,不等同 `--yes`;未确认不执行 | -| 删除类确认 | `dws --profile corp_alpha doc delete --yes` | 只在 Alpha 组织上下文执行;输出记录目标组织/endpoint | -| PAT 批量授权 | `dws --profile corp_alpha pat chmod --products calendar --grant-type once --dry-run --format json` | dry-run 只出 plan;无 `--yes` 不授权 | -| PAT 执行确认 | `dws --profile corp_alpha pat chmod --products calendar --grant-type once --yes --format json` | 用户已确认时才执行;agentCode/sessionId/profile 不混淆 | -| auth logout | `dws --profile corp_alpha auth logout` | 按 TC-15 明确验证:root profile 不能被误认为 local scoped logout | -| auth reset | `dws --profile corp_alpha auth reset` | 仍是全局清理或明确拒绝;不能只清 Alpha 后声称 reset 成功 | -| upgrade rollback | `dws --profile corp_alpha upgrade --rollback --yes` | profile 与 rollback 无关;回滚确认仍由 `--yes` 控制 | -| import/export | `dws --profile corp_alpha auth export/import ...` | 不输出明文 secret;不把 profile 写入 bundle 的业务字段 | - -## 6. Go 回归用例组 - -脚本默认先执行: - -```bash -go test -timeout 180s -count=1 ./internal/auth ./internal/app ./test/cli -run 'Test(MultiProfile|RuntimeProfile|ProfileFlagArgs|PreparseProfileFlag|NormalizeProcessProfileArgs|CommaSeparated|CommaNamed|DeleteProfile|UpsertProfile|LoadProfiles|LegacyKeychain|WriteProfile|ProfileList|ProfileUse|ProfileSwitch|AuthCommandDoesNotExposeSwitch|AuthStatus|AuthLogout|AuthLogin|ResolveAuthLogin|EnrichAuthLogin|RootHelp|RootShortHelp|RootCommand|ProductCommandsAcceptGlobalProfileFlag)' -``` - -### GT-01 auth/profile 存储单元回归 - -覆盖: - -- `SaveTokenData` 写入 corp-scoped slot。 -- `UpsertProfileFromToken` 新增/刷新 profile。 -- `ResolveProfile` 按 corpId/name/corpName 查找。 -- `SetCurrentProfile` 和 `UsePreviousProfile` 指针更新。 -- `DeleteTokenDataForProfile` 单 profile 删除。 -- legacy token migration。 - -### GT-02 profile 命令回归 - -覆盖: - -- `profile list --format json` 包含 `corpName`。 -- `profile switch/use` 输出组织名和 corpId。 -- `profile switch -` toggle。 -- 无参数交互路径调用 selector。 -- 非交互无参数返回 validation error。 -- 冲突 selector 返回 validation error。 - -### GT-03 auth 命令回归 - -覆盖: - -- `auth status` 默认使用 current profile。 -- `auth status --profile` 不修改 current profile。 -- `auth logout` 默认删除全部 profile 且保留 app config。 -- `auth logout --profile` 只删除指定 profile。 -- `auth reset` 删除 token、profiles、marker、app config。 -- `auth login` 默认强制进入授权流程。 -- `auth login --profile` 可解析目标 corpId。 -- 登录后可从 contact profile 补充 corpName/userName。 - -### GT-04 全局 `--profile` 业务命令注入 - -覆盖: - -- 每个产品命令接受全局 `--profile`。 -- `--profile` 不泄漏为业务参数。 -- runtime profile 在调用 runner 前已设置。 -- `--profile corpA,corpB` 进入多组织聚合读取。 -- `--profile corpA, corpB` 在 Cobra 解析前规整为同一个 profile selector,避免 `corpB` 被误判为 command/arg。 -- 聚合读取按 resolved `corpId` 去重,并在执行后恢复原始 runtime profile。 -- 含逗号的历史 profile name 仍按单 profile 解析。 - -### GT-05 命令可见性和兼容性 - -覆盖: - -- root help 展示 profile。 -- root help 展示全局 `--profile`。 -- `auth switch` 不暴露。 -- 旧命令和 docs compatibility 不退化。 - -## 7. TUI / 交互入口机器替代审计用例 - -### MTC-01 profile TUI - -交互入口: - -```bash -dws profile switch -dws profile use -``` - -机器指令: - -```bash -dws profile switch -dws profile switch --corpId -dws profile switch --name -dws profile switch - -dws --profile -dws --profile , -dws --profile , -``` - -预期: - -- 交互终端可展示 TUI。 -- 非交互环境无 selector 时失败并提示传入 selector。 -- 机器指令可完成同等切换、单次覆盖或多组织聚合读取能力。 - -### MTC-02 auth login 交互授权 - -交互入口: - -```bash -dws auth login -dws auth login --recommend -``` - -机器指令: - -```bash -dws auth login --device --format json -dws auth login --token --format json -dws auth login --recommend --yes --format json -dws auth login --profile --format json -``` - -预期: - -- 无头环境可走 device flow。 -- Agent 可用 `--recommend --yes` 跳过登录后推荐授权 TUI。 -- 目标组织可由全局 `--profile` 指定。 -- OAuth 浏览器扫码本身属于授权链路,不视为 CLI TUI 强依赖。 - -### MTC-03 skill setup 模式选择和确认 - -交互入口: - -```bash -dws skill setup -``` - -机器指令: - -```bash -dws skill setup --mode mono --yes -dws skill setup --mode multi --target claude --yes -dws skill setup --mode multi --skill aitable --skill calendar --yes -dws skill setup --mode multi --exclude live --yes -``` - -预期: - -- 非交互未指定 mode 时默认 mono。 -- 指定 `--mode` + `--yes` 可完全绕过 TUI。 -- multi 子 skill 可通过 `--skill/--exclude` 明确选择。 - -### MTC-04 upgrade 确认 - -交互入口: - -```bash -dws upgrade -dws upgrade --rollback -``` - -机器指令: - -```bash -dws upgrade --dry-run -dws upgrade --yes -dws upgrade --rollback --yes -dws upgrade --check --format json -dws upgrade --list --format json -``` - -预期: - -- Agent 可先 `--dry-run` 获取计划。 -- 用户确认后追加 `--yes` 执行。 -- 查询类命令可用 JSON 输出。 - -### MTC-05 dev connect 建联引导 - -交互入口: - -```bash -dws dev connect -``` - -机器指令: - -```bash -dws dev connect --channel --robot-client-id --robot-client-secret -dws dev connect --channel --unified-app-id -dws dev connect --channel custom --agent-cmd "" --robot-client-id --robot-client-secret -dws dev connect --daemon --channel --robot-client-id --robot-client-secret -``` - -预期: - -- 非交互缺凭证时 fail-fast,不阻塞等待输入。 -- 现成凭证和 unified app id 均可绕过建联引导。 -- 自研 agent 可用 `--agent-cmd`。 - -### MTC-06 删除/敏感操作确认 - -交互入口: - -```bash -dws doc delete ... -dws drive delete ... -dws aitable base delete ... -dws todo task delete ... -``` - -机器指令: - -```bash -dws --dry-run -dws --yes -``` - -预期: - -- 默认需要确认。 -- `--dry-run` 可预览。 -- 用户确认后 `--yes` 可由 Agent 执行。 - -### MTC-07 PAT 批量授权 - -交互入口: - -```bash -dws pat chmod --products ... -dws pat chmod --recommend ... -``` - -机器指令: - -```bash -dws pat chmod ... --dry-run --format json -dws pat chmod ... --yes --format json -``` - -预期: - -- 批量授权未加 `--yes` 时阻断。 -- Agent 先展示 dry-run plan,用户明确确认后再追加 `--yes`。 - -## 8. 已知残余风险 - -| 风险 | 说明 | 建议 | -|---|---|---| -| 部分 legacy compat delete path 仍会读 stdin 确认 | `internal/compat/registry.go` 中 `_blocked` 后仍存在 `Confirm? (yes/no)` 交互路径 | 后续可改成非交互环境直接 validation,并提示 `--yes` | -| 真实 OAuth 登录未在黑盒脚本中扫码验证 | 自动脚本 seed 登录后 token,避免人工和网络依赖 | 发布前必须追加一次手工 UAT:真实 `auth login` 登录 A/B 两个组织 | -| Runtime 自动化大量使用 `--mock` | `--mock` 只覆盖 CLI 解析和 runner 聚合,不能证明真实 MCP 链路、权限和租户隔离 | 每个产品 root 还必须执行 L2 非 mock 只读冒烟;权限失败也要是结构化远端错误 | -| 远端 revoke/logout 不在黑盒脚本中直连验证 | 网络不稳定且可能影响真实环境 | 已由 Go 回归用隔离环境覆盖本地删除语义;真实环境只做单组织 logout 冒烟 | -| TUI 视觉细节不在脚本中截图校验 | 脚本关注机器链路和非交互替代路径 | TUI 视觉可用人工验收或独立截图测试 | - -## 9. 手工 UAT 建议 - -在真实账号拥有两个钉钉组织的环境中执行: - -```bash -dws auth login --format json -dws profile list --format json -dws auth login --format json -dws profile list --format json -dws profile switch -dws auth status -dws --profile contact user get-self --format json -dws --profile , contact user get-self --format json -dws profile switch - -dws auth logout --profile -dws profile list --format json -dws auth logout -``` - -验收重点: - -- 第二次 `auth login` 能选择另一个组织,不因当前 token 有效而跳过授权。 -- `profile list` 表格和 JSON 均展示组织名。 -- `--profile` 取数返回对应组织身份。 -- `--profile A,B` 返回聚合结果,且不改变 current profile。 -- 单 profile logout 不影响另一个组织。 -- 默认 logout 清空全部组织登录态。 - -## 10. 通过标准 - -必须同时满足: - -- `bash scripts/dev/test-multi-profile-e2e.sh` 通过。 -- L2 非 mock 真实只读冒烟通过,或对权限不足场景产出结构化错误并确认使用了 selected profile。 -- L3 高风险写操作只在 `--dry-run`、测试租户或显式用户确认后执行。 -- `profiles.json` 无敏感字段。 -- 所有 P0 功能 F1-F7 至少有一个自动化用例覆盖。 -- 关键 TUI/交互入口均有机器指令替代路径。 -- 手工 UAT 未发现真实 OAuth 组织选择和权限链路阻断。 From 9f10c3155ac1f9ea2681c08042acdc306cf5da55 Mon Sep 17 00:00:00 2001 From: "shangguanxuan.sgx" Date: Mon, 29 Jun 2026 11:15:10 +0800 Subject: [PATCH 19/22] ci: harden multi-profile e2e gates --- .github/workflows/auto-dev-release.yml | 3 +++ .github/workflows/multi-profile-e2e.yml | 17 +++++++++++++++-- .github/workflows/release.yml | 3 +++ scripts/dev/test-multi-profile-e2e.sh | 4 ++-- 4 files changed, 23 insertions(+), 4 deletions(-) diff --git a/.github/workflows/auto-dev-release.yml b/.github/workflows/auto-dev-release.yml index 9cc2426f..9dbf0012 100644 --- a/.github/workflows/auto-dev-release.yml +++ b/.github/workflows/auto-dev-release.yml @@ -47,6 +47,9 @@ jobs: - name: Test run: go test -race -count=1 -timeout=5m ./cmd/... ./internal/... + - name: Multi Profile E2E + run: bash scripts/dev/test-multi-profile-e2e.sh + # ---- 算下一个 dev 版本号并打 tag ---- - name: Compute next dev version id: ver diff --git a/.github/workflows/multi-profile-e2e.yml b/.github/workflows/multi-profile-e2e.yml index 60cd674b..61a1d9ae 100644 --- a/.github/workflows/multi-profile-e2e.yml +++ b/.github/workflows/multi-profile-e2e.yml @@ -17,6 +17,8 @@ jobs: name: Multi Profile E2E runs-on: ubuntu-latest timeout-minutes: 15 + env: + MULTI_PROFILE_E2E_LOG: .tmp-bin/multi-profile-e2e.log steps: - name: Check out repository @@ -28,7 +30,17 @@ jobs: go-version-file: go.mod - name: Run isolated multi-profile chain - run: bash scripts/dev/test-multi-profile-e2e.sh --keep-workdir + shell: bash + run: | + set -o pipefail + mkdir -p .tmp-bin + bash scripts/dev/test-multi-profile-e2e.sh --keep-workdir | tee "$MULTI_PROFILE_E2E_LOG" + { + echo "### Multi Profile E2E" + echo "- Command: \`bash scripts/dev/test-multi-profile-e2e.sh --keep-workdir\`" + echo "- Scope: isolated auth/profile storage, profile switch/use, one-shot profile override, CSV multi-profile aggregation, legacy migration" + echo "- Result: passed" + } >> "$GITHUB_STEP_SUMMARY" - name: Upload debug artifacts if: failure() @@ -37,5 +49,6 @@ jobs: name: multi-profile-e2e-debug path: | .tmp-bin/multi-profile-e2e.*/out - .tmp-bin/multi-profile-e2e.*/config + .tmp-bin/multi-profile-e2e.log if-no-files-found: ignore + retention-days: 3 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index b888bc8f..e54347b3 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -31,6 +31,9 @@ jobs: - name: Install archive tooling run: sudo apt-get update && sudo apt-get install -y zip unzip + - name: Multi Profile E2E + run: bash scripts/dev/test-multi-profile-e2e.sh + - name: Install rcodesign (ad-hoc sign darwin binaries from Linux) run: | set -eu diff --git a/scripts/dev/test-multi-profile-e2e.sh b/scripts/dev/test-multi-profile-e2e.sh index 9e44e971..74fe6498 100755 --- a/scripts/dev/test-multi-profile-e2e.sh +++ b/scripts/dev/test-multi-profile-e2e.sh @@ -478,8 +478,8 @@ GOEOF cd "$ROOT" if [[ "$RUN_GO_TESTS" -eq 1 ]]; then - log "running focused Go regressions" - go test -timeout 180s -count=1 ./internal/auth ./internal/app ./test/cli -run 'Test(MultiProfile|RuntimeProfile|ProfileFlagArgs|PreparseProfileFlag|NormalizeProcessProfileArgs|CommaSeparated|CommaNamed|DeleteProfile|UpsertProfile|LoadProfiles|LegacyKeychain|WriteProfile|ProfileList|ProfileUse|ProfileSwitch|AuthCommandDoesNotExposeSwitch|AuthStatus|AuthLogout|AuthLogin|ResolveAuthLogin|EnrichAuthLogin|RootHelp|RootShortHelp|RootCommand|ProductCommandsAcceptGlobalProfileFlag)' + log "running multi-profile Go regressions" + go test -timeout 180s -count=1 ./internal/auth ./internal/app ./test/cli fi log "building dws" From e8ce2aeada5de02cb5423367afae7df2c8558870 Mon Sep 17 00:00:00 2001 From: qinze Date: Mon, 29 Jun 2026 16:43:36 +0800 Subject: [PATCH 20/22] 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. --- internal/auth/oauth_helpers.go | 8 +- internal/auth/profiles.go | 129 +++++++++++++++++++++++++++++---- internal/auth/token.go | 104 +++++++++++++++++--------- 3 files changed, 191 insertions(+), 50 deletions(-) diff --git a/internal/auth/oauth_helpers.go b/internal/auth/oauth_helpers.go index 00ba7ad3..9c64960a 100644 --- a/internal/auth/oauth_helpers.go +++ b/internal/auth/oauth_helpers.go @@ -148,7 +148,9 @@ func (p *OAuthProvider) refreshWithRefreshToken(ctx context.Context, data *Token updated.CorpName = data.CorpName } - if err := SaveTokenData(p.configDir, updated); err != nil { + // Refresh runs under lockedRefresh's dual-layer lock; use the lock-free + // saver to avoid re-acquiring the non-reentrant lock (deadlock). + if err := saveTokenDataLocked(p.configDir, updated); err != nil { return nil, fmt.Errorf("保存刷新后的 token 失败(旧 refresh_token 已失效,请重新登录): %w", err) } return updated, nil @@ -192,7 +194,9 @@ func (p *OAuthProvider) refreshViaMCP(ctx context.Context, data *TokenData) (*To updated.CorpName = data.CorpName } - if err := SaveTokenData(p.configDir, updated); err != nil { + // Refresh runs under lockedRefresh's dual-layer lock; use the lock-free + // saver to avoid re-acquiring the non-reentrant lock (deadlock). + if err := saveTokenDataLocked(p.configDir, updated); err != nil { return nil, fmt.Errorf("保存刷新后的 token 失败(旧 refresh_token 已失效,请重新登录): %w", err) } return updated, nil diff --git a/internal/auth/profiles.go b/internal/auth/profiles.go index bb353d61..c72770b3 100644 --- a/internal/auth/profiles.go +++ b/internal/auth/profiles.go @@ -14,6 +14,7 @@ package auth import ( + "context" "encoding/json" "fmt" "os" @@ -22,9 +23,28 @@ import ( "sync" "time" + "github.com/google/uuid" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/config" ) +// withProfilesLock runs fn while holding the auth dual-layer lock (process + +// cross-process file lock) so that all read-modify-write cycles on +// profiles.json and the legacy token mirror are serialized. +// +// The lock is NOT reentrant. fn must only call the lock-free *Locked variants; +// calling a public (locking) function from within fn would deadlock. Paths that +// already hold the lock (e.g. OAuthProvider.lockedRefresh and the read path +// reached from it) must likewise call the lock-free variants directly. +func withProfilesLock(configDir string, fn func() error) error { + lock, err := AcquireDualLock(context.Background(), configDir) + if err != nil { + return err + } + defer lock.Release() + return fn() +} + const profilesJSONFile = "profiles.json" const ( @@ -95,7 +115,12 @@ func LoadProfiles(configDir string) (*ProfilesConfig, error) { } var cfg ProfilesConfig if err := json.Unmarshal(data, &cfg); err != nil { - return nil, fmt.Errorf("parse profiles: %w", err) + // Corrupt file (e.g. an interrupted concurrent write): quarantine it and + // rebuild an empty config so the CLI can self-heal (auth reset / re-login) + // instead of being permanently locked out by an unreadable profiles.json. + quarantine := path + ".corrupt-" + time.Now().Format("20060102-150405.000") + _ = os.Rename(path, quarantine) + return &ProfilesConfig{Version: 1}, nil } normalizeProfilesConfig(&cfg) return &cfg, nil @@ -116,7 +141,10 @@ func SaveProfiles(configDir string, cfg *ProfilesConfig) error { } data = append(data, '\n') path := ProfilesPath(configDir) - tmp := path + ".tmp" + // Per-write random temp name: a fixed "profiles.json.tmp" lets two + // concurrent writers interleave into the same temp file and rename a + // corrupted result into place. + tmp := path + "." + uuid.New().String() + ".tmp" if err := os.WriteFile(tmp, data, config.FilePerm); err != nil { return fmt.Errorf("write profiles tmp: %w", err) } @@ -128,7 +156,16 @@ func SaveProfiles(configDir string, cfg *ProfilesConfig) error { } // EnsureProfilesMigration initializes profiles.json from the legacy auth-token slot when needed. +// EnsureProfilesMigration migrates a legacy single-slot token into the +// profiles registry. It acquires the lock; call ensureProfilesMigrationLocked +// from contexts that already hold it (refresh / read paths). func EnsureProfilesMigration(configDir string) error { + return withProfilesLock(configDir, func() error { + return ensureProfilesMigrationLocked(configDir) + }) +} + +func ensureProfilesMigrationLocked(configDir string) error { cfg, err := LoadProfiles(configDir) if err != nil { return err @@ -157,6 +194,12 @@ func UpsertProfileFromToken(configDir string, data *TokenData) error { // UpsertProfileFromTokenWithCurrent updates profiles.json and optionally makes // the token's corp the persistent current profile. func UpsertProfileFromTokenWithCurrent(configDir string, data *TokenData, makeCurrent bool) error { + return withProfilesLock(configDir, func() error { + return upsertProfileFromTokenWithCurrentLocked(configDir, data, makeCurrent) + }) +} + +func upsertProfileFromTokenWithCurrentLocked(configDir string, data *TokenData, makeCurrent bool) error { cfg, err := LoadProfiles(configDir) if err != nil { return err @@ -232,7 +275,7 @@ func upsertProfileFromToken(configDir string, cfg *ProfilesConfig, data *TokenDa // ResolveProfile returns a profile selected by name/corpId or by current/primary fallback. func ResolveProfile(configDir, selector string) (*Profile, error) { - if err := EnsureProfilesMigration(configDir); err != nil { + if err := ensureProfilesMigrationLocked(configDir); err != nil { return nil, err } cfg, err := LoadProfiles(configDir) @@ -257,7 +300,7 @@ func ResolveProfile(configDir, selector string) (*Profile, error) { } func resolveProfileForLoad(configDir, selector string) (*Profile, error) { - if err := EnsureProfilesMigration(configDir); err != nil { + if err := ensureProfilesMigrationLocked(configDir); err != nil { return nil, err } cfg, err := LoadProfiles(configDir) @@ -288,7 +331,17 @@ func resolveProfileForLoad(configDir, selector string) (*Profile, error) { // SetCurrentProfile persists the selected current profile. func SetCurrentProfile(configDir, selector string) (*Profile, error) { - if err := EnsureProfilesMigration(configDir); err != nil { + var result *Profile + err := withProfilesLock(configDir, func() error { + p, e := setCurrentProfileLocked(configDir, selector) + result = p + return e + }) + return result, err +} + +func setCurrentProfileLocked(configDir, selector string) (*Profile, error) { + if err := ensureProfilesMigrationLocked(configDir); err != nil { return nil, err } cfg, err := LoadProfiles(configDir) @@ -309,7 +362,7 @@ func SetCurrentProfile(configDir, selector string) (*Profile, error) { if err := SaveProfiles(configDir, cfg); err != nil { return nil, err } - if err := SyncLegacyTokenMirror(configDir); err != nil { + if err := syncLegacyTokenMirrorLocked(configDir); err != nil { return nil, err } return findProfile(cfg, p.CorpID), nil @@ -317,7 +370,17 @@ func SetCurrentProfile(configDir, selector string) (*Profile, error) { // UsePreviousProfile toggles currentProfile and previousProfile. func UsePreviousProfile(configDir string) (*Profile, error) { - if err := EnsureProfilesMigration(configDir); err != nil { + var result *Profile + err := withProfilesLock(configDir, func() error { + p, e := usePreviousProfileLocked(configDir) + result = p + return e + }) + return result, err +} + +func usePreviousProfileLocked(configDir string) (*Profile, error) { + if err := ensureProfilesMigrationLocked(configDir); err != nil { return nil, err } cfg, err := LoadProfiles(configDir) @@ -337,7 +400,7 @@ func UsePreviousProfile(configDir string) (*Profile, error) { if err := SaveProfiles(configDir, cfg); err != nil { return nil, err } - if err := SyncLegacyTokenMirror(configDir); err != nil { + if err := syncLegacyTokenMirrorLocked(configDir); err != nil { return nil, err } return findProfile(cfg, p.CorpID), nil @@ -345,6 +408,16 @@ func UsePreviousProfile(configDir string) (*Profile, error) { // RemoveProfile removes a profile from metadata and returns the removed profile. func RemoveProfile(configDir, selector string) (*Profile, error) { + var result *Profile + err := withProfilesLock(configDir, func() error { + p, e := removeProfileLocked(configDir, selector) + result = p + return e + }) + return result, err +} + +func removeProfileLocked(configDir, selector string) (*Profile, error) { cfg, err := LoadProfiles(configDir) if err != nil { return nil, err @@ -389,6 +462,12 @@ func MarkProfileStatus(configDir, corpID, status string) error { if strings.TrimSpace(corpID) == "" { return nil } + return withProfilesLock(configDir, func() error { + return markProfileStatusLocked(configDir, corpID, status) + }) +} + +func markProfileStatusLocked(configDir, corpID, status string) error { cfg, err := LoadProfiles(configDir) if err != nil { return err @@ -404,21 +483,41 @@ func MarkProfileStatus(configDir, corpID, status string) error { // SyncLegacyTokenMirror mirrors the current profile token into legacy auth-token. func SyncLegacyTokenMirror(configDir string) error { + return withProfilesLock(configDir, func() error { + return syncLegacyTokenMirrorLocked(configDir) + }) +} + +func syncLegacyTokenMirrorLocked(configDir string) error { cfg, err := LoadProfiles(configDir) if err != nil { return err } + hadReadError := false for _, candidate := range []string{cfg.CurrentProfile, cfg.PrimaryProfile} { - if p := findProfile(cfg, candidate); p != nil { - data, loadErr := LoadTokenDataKeychainForCorpID(p.CorpID) - if loadErr == nil && data != nil { - if err := SaveTokenDataKeychain(data); err != nil { - return err - } - return WriteTokenMarker(configDir) + p := findProfile(cfg, candidate) + if p == nil { + continue + } + data, loadErr := LoadTokenDataKeychainForCorpID(p.CorpID) + if loadErr != nil { + // Transient keychain read failure: do NOT touch the existing mirror. + hadReadError = true + continue + } + if data != nil { + if err := SaveTokenDataKeychain(data); err != nil { + return err } + return WriteTokenMarker(configDir) } } + if hadReadError { + // Keep the existing legacy mirror untouched rather than wiping a host + // app's login state just because keychain was momentarily unavailable. + return nil + } + // All candidate profiles confirmed absent (no token): clear the mirror. _ = DeleteTokenDataKeychain() _ = DeleteTokenMarker(configDir) return nil diff --git a/internal/auth/token.go b/internal/auth/token.go index b63aa9d3..f1c3097e 100644 --- a/internal/auth/token.go +++ b/internal/auth/token.go @@ -25,6 +25,8 @@ import ( "strings" "time" + "github.com/google/uuid" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition" ) @@ -83,7 +85,7 @@ func WriteTokenMarker(configDir string) error { if err := os.MkdirAll(configDir, 0o700); err != nil { return err } - tmp := filepath.Join(configDir, tokenJSONFile+".tmp") + tmp := filepath.Join(configDir, tokenJSONFile+"."+uuid.New().String()+".tmp") if err := os.WriteFile(tmp, data, 0o600); err != nil { return err } @@ -103,25 +105,35 @@ func DeleteTokenMarker(configDir string) error { // to the default keychain-based storage. func SaveTokenData(configDir string, data *TokenData) error { if h := edition.Get(); h.SaveToken != nil { - jsonData, err := json.MarshalIndent(data, "", " ") - if err != nil { - return fmt.Errorf("marshaling token data for hook: %w", err) - } - return h.SaveToken(configDir, jsonData) + return saveTokenViaHook(h, configDir, data) + } + return withProfilesLock(configDir, func() error { + return saveTokenDataLocked(configDir, data) + }) +} + +// saveTokenDataLocked performs the keychain + profiles.json + legacy mirror +// writes assuming the auth dual-layer lock is already held. Callers that +// already hold the lock (OAuthProvider refresh path, the legacy secure->keychain +// migration in LoadTokenDataForProfile) must use this instead of SaveTokenData +// to avoid deadlocking on the non-reentrant lock. +func saveTokenDataLocked(configDir string, data *TokenData) error { + if h := edition.Get(); h.SaveToken != nil { + return saveTokenViaHook(h, configDir, data) } if data != nil && strings.TrimSpace(data.CorpID) != "" { if err := SaveTokenDataKeychainForCorpID(data.CorpID, data); err != nil { return err } makeCurrent := strings.TrimSpace(RuntimeProfile()) == "" - if err := UpsertProfileFromTokenWithCurrent(configDir, data, makeCurrent); err != nil { + if err := upsertProfileFromTokenWithCurrentLocked(configDir, data, makeCurrent); err != nil { return err } if makeCurrent { if err := SaveTokenDataKeychain(data); err != nil { return err } - } else if err := SyncLegacyTokenMirror(configDir); err != nil { + } else if err := syncLegacyTokenMirrorLocked(configDir); err != nil { return err } return WriteTokenMarker(configDir) @@ -132,6 +144,14 @@ func SaveTokenData(configDir string, data *TokenData) error { return WriteTokenMarker(configDir) } +func saveTokenViaHook(h *edition.Hooks, configDir string, data *TokenData) error { + jsonData, err := json.MarshalIndent(data, "", " ") + if err != nil { + return fmt.Errorf("marshaling token data for hook: %w", err) + } + return h.SaveToken(configDir, jsonData) +} + // LoadTokenData reads TokenData. When an edition hook (LoadToken) is // registered, it delegates entirely to the hook; otherwise it falls back // to keychain with legacy .data migration. @@ -178,7 +198,9 @@ func LoadTokenDataForProfile(configDir, profile string) (*TokenData, error) { if err != nil { return nil, err } - if err := SaveTokenData(configDir, data); err == nil { + // One-time legacy secure-store -> keychain migration. This read path may run + // while the refresh lock is already held, so use the lock-free saver. + if err := saveTokenDataLocked(configDir, data); err == nil { _ = DeleteSecureData(configDir) } return data, nil @@ -200,14 +222,20 @@ func DeleteTokenDataForProfile(configDir, profile string) error { } return h.DeleteToken(configDir) } + return withProfilesLock(configDir, func() error { + return deleteTokenDataForProfileLocked(configDir, profile) + }) +} + +func deleteTokenDataForProfileLocked(configDir, profile string) error { selected, err := resolveProfileForLoad(configDir, profile) if err != nil { return err } if selected != nil { keychainErr := DeleteTokenDataKeychainForCorpID(selected.CorpID) - _, removeErr := RemoveProfile(configDir, selected.CorpID) - legacyErr := SyncLegacyTokenMirror(configDir) + _, removeErr := removeProfileLocked(configDir, selected.CorpID) + legacyErr := syncLegacyTokenMirrorLocked(configDir) secureErr := DeleteSecureData(configDir) if keychainErr != nil { return keychainErr @@ -238,29 +266,39 @@ func DeleteAllTokenData(configDir string) error { if h := edition.Get(); h.DeleteToken != nil { return h.DeleteToken(configDir) } - cfg, err := LoadProfiles(configDir) - if err != nil { - return err - } - var firstErr error - for _, profile := range cfg.Profiles { - if err := DeleteTokenDataKeychainForCorpID(profile.CorpID); err != nil && firstErr == nil { - firstErr = err + return withProfilesLock(configDir, func() error { + var firstErr error + // Best-effort: even if profiles.json is unreadable, still clear every + // other slot so the user can always self-heal via auth reset / logout. + if cfg, err := LoadProfiles(configDir); err == nil { + for _, profile := range cfg.Profiles { + if e := DeleteTokenDataKeychainForCorpID(profile.CorpID); e != nil && firstErr == nil { + firstErr = e + } + } } - } - if err := os.Remove(ProfilesPath(configDir)); err != nil && !os.IsNotExist(err) && firstErr == nil { - firstErr = err - } - if err := DeleteTokenDataKeychain(); err != nil && firstErr == nil { - firstErr = err - } - if err := DeleteSecureData(configDir); err != nil && firstErr == nil { - firstErr = err - } - if err := DeleteTokenMarker(configDir); err != nil && firstErr == nil { - firstErr = err - } - return firstErr + if e := os.Remove(ProfilesPath(configDir)); e != nil && !os.IsNotExist(e) && firstErr == nil { + firstErr = e + } + // Sweep any quarantined corrupt-profiles files so they don't accumulate. + if matches, _ := filepath.Glob(ProfilesPath(configDir) + ".corrupt-*"); len(matches) > 0 { + for _, m := range matches { + if e := os.Remove(m); e != nil && !os.IsNotExist(e) && firstErr == nil { + firstErr = e + } + } + } + if e := DeleteTokenDataKeychain(); e != nil && firstErr == nil { + firstErr = e + } + if e := DeleteSecureData(configDir); e != nil && firstErr == nil { + firstErr = e + } + if e := DeleteTokenMarker(configDir); e != nil && firstErr == nil { + firstErr = e + } + return firstErr + }) } // RevokeTokenRemote calls the appropriate logout/revoke endpoint to invalidate the access token. From e0ae5682883cd4c8d84cdbc070e12daa384abb6f Mon Sep 17 00:00:00 2001 From: audanye-sudo Date: Mon, 29 Jun 2026 17:33:08 +0800 Subject: [PATCH 21/22] 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. --- internal/auth/token.go | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/internal/auth/token.go b/internal/auth/token.go index f1c3097e..3582263c 100644 --- a/internal/auth/token.go +++ b/internal/auth/token.go @@ -190,6 +190,15 @@ func LoadTokenDataForProfile(configDir, profile string) (*TokenData, error) { if strings.TrimSpace(profile) != "" { return nil, err } + // No explicit --profile: `selected` is the resolved current/primary + // profile. Only fall back to the legacy single slot when it belongs to + // the SAME org; otherwise surface the error instead of silently acting + // as a different organization (the legacy mirror may have drifted). + if legacy, lerr := LoadTokenDataKeychain(); lerr == nil && legacy != nil && + strings.TrimSpace(legacy.CorpID) == strings.TrimSpace(selected.CorpID) { + return legacy, nil + } + return nil, err } if TokenDataExistsKeychain() { return LoadTokenDataKeychain() From e88a7c8502d805866457074597aae95fd781f827 Mon Sep 17 00:00:00 2001 From: audanye-sudo Date: Mon, 29 Jun 2026 17:33:29 +0800 Subject: [PATCH 22/22] 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. --- internal/app/p1_shared_install_test.go | 83 +++++++++++++++++++++++++ internal/app/skill_setup.go | 31 ++++++++- skills/mono/SKILL.md | 27 ++++++++ skills/multi/dingtalk-aisearch/SKILL.md | 2 + skills/multi/dingtalk-chat/SKILL.md | 2 + skills/multi/dingtalk-contact/SKILL.md | 2 + skills/multi/dingtalk-profile/SKILL.md | 48 ++++++++++++++ skills/multi/dws-shared/SKILL.md | 39 ++++++++++++ 8 files changed, 233 insertions(+), 1 deletion(-) create mode 100644 internal/app/p1_shared_install_test.go create mode 100644 skills/multi/dingtalk-profile/SKILL.md create mode 100644 skills/multi/dws-shared/SKILL.md diff --git a/internal/app/p1_shared_install_test.go b/internal/app/p1_shared_install_test.go new file mode 100644 index 00000000..226fd586 --- /dev/null +++ b/internal/app/p1_shared_install_test.go @@ -0,0 +1,83 @@ +package app + +import ( + "bytes" + "os" + "path/filepath" + "testing" +) + +// writeMultiSkillSrc creates a fake multi skill source tree with the given +// subdir names, each containing a minimal SKILL.md. +func writeMultiSkillSrc(t *testing.T, names ...string) string { + t.Helper() + src := t.TempDir() + for _, n := range names { + dir := filepath.Join(src, n) + if err := os.MkdirAll(dir, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(dir, "SKILL.md"), []byte("# "+n+"\n"), 0o644); err != nil { + t.Fatal(err) + } + } + return src +} + +func contains(ss []string, want string) bool { + for _, s := range ss { + if s == want { + return true + } + } + return false +} + +// dws-shared must ship even when --skill narrows the set to a single product. +func TestP1SharedAlwaysIncludedWithSkillFilter(t *testing.T) { + src := writeMultiSkillSrc(t, "dws-shared", "dingtalk-aitable", "dingtalk-calendar") + all, err := listMultiSkillNames(src) + if err != nil { + t.Fatal(err) + } + if !contains(all, "dws-shared") { + t.Fatalf("listMultiSkillNames did not enumerate dws-shared: %v", all) + } + filtered, err := filterMultiSkillNames(all, []string{"aitable"}, nil) + if err != nil { + t.Fatal(err) + } + if contains(filtered, "dws-shared") { + t.Fatalf("precondition: filter should drop dws-shared for -s aitable: %v", filtered) + } + final := ensureMandatorySharedSkill(filtered, all) + if !contains(final, "dws-shared") { + t.Fatalf("ensureMandatorySharedSkill must re-add dws-shared: %v", final) + } + + // Actually install with the filtered+mandatory set and assert dws-shared landed. + dest := t.TempDir() + var out, errOut bytes.Buffer + if _, _, err := installMultiSkillToHomes(src, final, []string{dest}, &out, &errOut); err != nil { + t.Fatalf("install: %v (%s)", err, errOut.String()) + } + if _, err := os.Stat(filepath.Join(dest, "dws-shared", "SKILL.md")); err != nil { + t.Fatalf("dws-shared not installed with -s aitable: %v", err) + } + if _, err := os.Stat(filepath.Join(dest, "dingtalk-aitable", "SKILL.md")); err != nil { + t.Fatalf("dingtalk-aitable not installed: %v", err) + } +} + +// When the source has no dws-shared (older layout), nothing is forced. +func TestP1SharedNoopWhenAbsent(t *testing.T) { + src := writeMultiSkillSrc(t, "dingtalk-aitable") + all, err := listMultiSkillNames(src) + if err != nil { + t.Fatal(err) + } + final := ensureMandatorySharedSkill([]string{"dingtalk-aitable"}, all) + if contains(final, "dws-shared") { + t.Fatalf("must not invent dws-shared when source lacks it: %v", final) + } +} diff --git a/internal/app/skill_setup.go b/internal/app/skill_setup.go index a47a5340..c07424e3 100644 --- a/internal/app/skill_setup.go +++ b/internal/app/skill_setup.go @@ -123,7 +123,9 @@ func runSkillSetup(cmd *cobra.Command, _ []string) error { if filterErr != nil { return filterErr } - multiSkillNames = filtered + // dws-shared carries the global rules every product skill declares as a + // PREREQUISITE; it must ship even when --skill / --exclude narrows the set. + multiSkillNames = ensureMandatorySharedSkill(filtered, allMultiSkillNames) } if !autoYes { @@ -160,6 +162,33 @@ func runSkillSetup(cmd *cobra.Command, _ []string) error { // bundle in skills/multi/ (e.g. dingtalk-aitable, dingtalk-calendar). const multiSkillPrefix = "dingtalk-" +// multiSharedSkill is the shared, non-product skill that every per-product +// skill declares as a PREREQUISITE. It must always be installed in multi mode +// regardless of --skill / --exclude, otherwise the product skills reference a +// dws-shared that was never installed. +const multiSharedSkill = "dws-shared" + +// ensureMandatorySharedSkill guarantees the shared dependency skill is included +// whenever it exists in the source, even if --skill / --exclude narrowed it out. +func ensureMandatorySharedSkill(selected, all []string) []string { + hasShared := false + for _, n := range all { + if n == multiSharedSkill { + hasShared = true + break + } + } + if !hasShared { + return selected + } + for _, n := range selected { + if n == multiSharedSkill { + return selected + } + } + return append([]string{multiSharedSkill}, selected...) +} + // normalizeMultiSkillName accepts either the short form (aitable) or the // full form (dingtalk-aitable) and returns the canonical full form. // Empty input returns "". Comparison is case-insensitive. diff --git a/skills/mono/SKILL.md b/skills/mono/SKILL.md index 192ec8fe..18923fe6 100644 --- a/skills/mono/SKILL.md +++ b/skills/mono/SKILL.md @@ -27,6 +27,7 @@ cli_version: ">=1.0.15" - **脚本优先**:[scripts/](./scripts/) 下的 `python scripts/.py` 已封装翻页/轮询/批量逻辑,遇到对应场景(如 AI 表格批量导入导出、AI 应用创建轮询、文档创建后写内容、钉盘目录树等)**优先调用脚本**而非手写多步命令。脚本均支持 `--dry-run` 预览、`--format json` 输出,失败时回退到手动步骤 - **业务域最佳实践优先**:文档类多步任务先读 [04-document.md](./references/best_practices/04-document.md);AI 表格读取/统计/写入/导入导出先读 [06-data-analytics.md](./references/best_practices/06-data-analytics.md)。本仓库只迁入这些业务域 best practices,不引入其它产品行动指南。 - 知识库容器只用 `dws wiki space/member`;知识库内文件/文档的浏览、搜索、读取、创建、移动、复制统一切到 `dws doc`。`workspaceId` 只能传给 `wiki --workspace`、`doc --workspace` 或 `doc search --workspace-ids`,禁止传给 `doc list --folder`,也不要使用不存在的 `--space-id`。 +- 找群 / 找人 / 找数据在当前组织没命中、且 `dws profile list` 显示 ≥2 个组织时,对每个组织带一次性 `--profile ` 各搜一遍;命中即用,全部组织都没有才追问用户。禁止在当前组织搜不到就判定「不存在」或直接甩给用户选。 ## 开放平台文档 RAG / 错误码排查 @@ -72,6 +73,30 @@ cli_version: ">=1.0.15" 4. **Fallback 单产品路由**:仅当行动指南未命中,且用户意图明确是单一产品单步操作时,才按「产品总览」和「意图判断决策树」选择产品,并读取对应 `references/products/*.md`。 5. **追问**:以上步骤都无法判断时,主动追问用户澄清,严禁猜测命令、flag、URL、ID 或字段名。 +## 多组织处理 +dws 可同时登录多个钉钉组织,一个 profile = 一个已登录组织(corp)。当前 profile 决定本次命令用哪个组织的身份(corpId / userId 按当前 profile 自动注入,不是只支持单组织)。 + +**触发条件(命中任一即进入本节)**: +- 显式:用户提到 切换 / 换 / 跨组织、另一个钉钉、别的公司、看登录了哪些组织、当前是哪个组织、某人 / 某群 / 某数据在别的组织 +- 隐式(最常见、易漏):在当前组织读 / 搜没找到目标(群 / 人 / 数据),且 `dws profile list` 显示已登录 ≥2 个组织 —— 别急着判「不存在」,按下方跨组织铁律去其他组织找 +- 需要跨多个组织汇总 / 对比数据 +- 用户问认证状态 / 登录了哪些组织 / 主组织是哪个 + +**不触发**:只登录 1 个组织时,按当前组织正常处理,不带 `--profile`,不进本节。 + +命令: +- `dws profile list` — 列出已登录组织(主 / 当前标记、状态、有效期),只读元数据 +- `dws profile switch <名称|corpId|->` — 持久切换当前组织;`-` 切回上一个;无参数在交互终端弹选择器(非交互须显式传参)。`dws profile use` 是其别名 +- 全局 `--profile <名称|corpId>` — 单次指定本命令用哪个组织,一次性、不改当前组织 +- `dws auth login` — 再登一个组织即新增 profile(自动从授权账号取 corpId / corpName);同组织重复 login = 刷新 +- `dws auth status [--profile <名称>]` — 查看认证状态 + +多组织数据聚合步骤:`dws profile list` 拿到所有已登录组织,对每个组织带 `--profile ` 各取一次数,合并并标注来源组织;某组织失败则标「该组织暂不可用」并继续返回其余。 +安全护栏: +- 只有 `dws profile list` 显示 ≥2 个组织才启用上面的跨组织逻辑;单组织直接按当前组织走,不带 `--profile`。 +- 自动跨组织只对「读 / 搜」。写 / 发 / 删 / 撤回等操作默认只在当前组织做;确需带 `--profile` 跨组织写时,必须先与用户确认目标组织。 +- 持久切换 `dws profile switch`(改默认组织)按写操作对待:未经用户明确要求不得执行。跨组织找数一律用一次性 `--profile`,不改当前组织。 + ## 行动指南(优先匹配) > 将用户意图与下表做**语义比对**,不要求字面包含关键词。命中后必须读取该行动指南文件,并按其中固定路线执行;多个场景同时命中时,按下方「消歧规则」选择。 @@ -112,6 +137,7 @@ cli_version: ">=1.0.15" 用户提到"在线电子表格/钉钉表格/axls/工作表/单元格读写/合并单元格/筛选视图/导出 xlsx" → `sheet` 用户提到"待办/TODO/任务提醒/循环待办" → `todo` 用户提到"创建知识库/知识库列表/搜索知识库空间/wiki/团队空间/知识库成员管理/我的文档个人空间" → `wiki` +用户提到"切换组织/换组织/跨组织/另一个钉钉/别的公司/多组织/看所有组织/profile/登录了哪些组织" → `profile`(见「多组织 / profile」节) 关键区分: **dev(创建/配置/建联机器人)** vs **chat(查询/发消息已有机器人)**。`dws chat bot search/find` 只查询机器人;**建号**(创建钉钉智能体机器人)走 `dws dev app robot submit`;**建联**(把机器人接到本地 agent 的 Stream)走 `dws dev connect`。凡是"创建机器人""建机器人""接入 agent""建联"一律路由到 `dev`,禁止走 `chat`。 关键区分: aitable(数据表格) vs todo(待办任务) @@ -149,6 +175,7 @@ cli_version: ">=1.0.15" | `oa` | `approval reject` | 拒绝待审批(需加明确理由) | | `todo` | `task delete` | 删除待办 | | `minutes` | `replace-text` | 全文批量替换转写与摘要 | +| `auth` | `logout` | **默认退出所有已登录组织**;只退一个加 `--profile <名称\|corpId>`。注意:退主组织不会被拦,会静默把「主」改选为剩下第一个组织,退主前必须向用户确认 | ### 确认流程 ``` diff --git a/skills/multi/dingtalk-aisearch/SKILL.md b/skills/multi/dingtalk-aisearch/SKILL.md index d5bef865..557db423 100644 --- a/skills/multi/dingtalk-aisearch/SKILL.md +++ b/skills/multi/dingtalk-aisearch/SKILL.md @@ -23,6 +23,8 @@ metadata: > 命令参考:[aisearch.md](references/aisearch.md)。 +> 跨组织:当前组织搜不到人时,别判定「查无此人」——先 `dws profile list` 看有哪些已登录组织,再对每个组织带 `--profile ` 各搜一遍,全无才追问用户。详见 `dingtalk-profile` skill。 + ## 开放平台文档 RAG / 错误码排查 - 任何产品执行中,只要用户问开放平台 API、接口参数、字段含义、权限点、回调、SDK、配额、错误码,或命令返回上游 OpenAPI/SDK 错误,必须先用 `dws devdoc article search --query "<关键词>" --format json` 做官方文档 RAG。 diff --git a/skills/multi/dingtalk-chat/SKILL.md b/skills/multi/dingtalk-chat/SKILL.md index eaf7581c..3fe07faa 100644 --- a/skills/multi/dingtalk-chat/SKILL.md +++ b/skills/multi/dingtalk-chat/SKILL.md @@ -23,6 +23,8 @@ metadata: > 命令参考:[chat.md](references/chat.md);表情:[chat-emoji-list.md](references/chat-emoji-list.md);剧本:[01-messaging.md](references/01-messaging.md)。 +> 跨组织:当前组织搜不到群 / 单聊时,别判定「不存在」——先 `dws profile list` 看有哪些已登录组织,再对每个组织带 `--profile ` 各搜一遍,全无才追问用户。详见 `dingtalk-profile` skill。 + ## 开放平台文档 RAG / 错误码排查 - 任何产品执行中,只要用户问开放平台 API、接口参数、字段含义、权限点、回调、SDK、配额、错误码,或命令返回上游 OpenAPI/SDK 错误,必须先用 `dws devdoc article search --query "<关键词>" --format json` 做官方文档 RAG。 diff --git a/skills/multi/dingtalk-contact/SKILL.md b/skills/multi/dingtalk-contact/SKILL.md index 467cc1b5..5e8ad609 100644 --- a/skills/multi/dingtalk-contact/SKILL.md +++ b/skills/multi/dingtalk-contact/SKILL.md @@ -23,6 +23,8 @@ metadata: > 命令参考:[contact.md](references/contact.md);剧本:[08-directory.md](references/08-directory.md)。 +> 跨组织:当前组织查不到人时,别判定「查无此人」——先 `dws profile list` 看有哪些已登录组织,再对每个组织带 `--profile ` 各查一遍,全无才追问用户。详见 `dingtalk-profile` skill。 + ## 开放平台文档 RAG / 错误码排查 - 任何产品执行中,只要用户问开放平台 API、接口参数、字段含义、权限点、回调、SDK、配额、错误码,或命令返回上游 OpenAPI/SDK 错误,必须先用 `dws devdoc article search --query "<关键词>" --format json` 做官方文档 RAG。 diff --git a/skills/multi/dingtalk-profile/SKILL.md b/skills/multi/dingtalk-profile/SKILL.md new file mode 100644 index 00000000..af01f957 --- /dev/null +++ b/skills/multi/dingtalk-profile/SKILL.md @@ -0,0 +1,48 @@ +--- +name: dingtalk-profile +description: 钉钉多组织 / profile 管理与跨组织取数。Use when 用户说 切换组织/换组织/跨组织/另一个钉钉/别的公司/多组织/看登录了哪些组织/profile,或在当前组织找不到群/人/数据需要去其他组织找。命令前缀:dws profile / dws auth / 全局 --profile。 +cli_version: ">=1.0.40" +metadata: + category: product + stability: experimental + requires: + bins: + - dws +--- + +# 钉钉多组织 / profile Skill + +> 🧪 **EXPERIMENTAL · 试验版 / Preview** — multi 模式当前未达 stable 标准;接口、命名、跨 skill 引用后续可能调整。生产 / 共享环境请优先使用 mono 模式(`dws skill setup --mode mono`)。 + +> **PREREQUISITE:** Read the `dws-shared` skill first for auth, global flags, product routing, URL preflight, error codes, and safety rules. The `dws` binary must be on PATH. + + + +dws 可同时登录多个钉钉组织,一个 profile = 一个已登录组织(corp)。当前 profile 决定本次命令用哪个组织的身份(corpId / userId 按当前 profile 自动注入)。 + +## 触发条件(命中任一即用本 skill) +- 显式:用户提到 切换 / 换 / 跨组织、另一个钉钉、别的公司、看登录了哪些组织、当前是哪个组织、某人 / 某群 / 某数据在别的组织 +- 隐式(最常见、易漏):在当前组织读 / 搜没找到目标(群 / 人 / 数据),且 `dws profile list` 显示已登录 ≥2 个组织 —— 别急着判「不存在」,按下方跨组织铁律去其他组织找 +- 需要跨多个组织汇总 / 对比数据 +- 用户问认证状态 / 登录了哪些组织 / 主组织是哪个 + +**不触发**:只登录 1 个组织时,按当前组织正常处理,不带 `--profile`。 + +## 命令 +- `dws profile list` — 列出已登录组织(主 / 当前标记、状态、有效期),只读元数据 +- `dws profile switch <名称|corpId|->` — 持久切换当前组织;`-` 切回上一个;无参数在交互终端弹选择器(非交互须显式传参)。`dws profile use` 是别名 +- 全局 `--profile <名称|corpId>` — 单次指定本命令用哪个组织,一次性、不改当前组织 +- `dws auth login` — 再登一个组织即新增 profile(自动从授权账号取 corpId / corpName);同组织重复 login = 刷新 +- `dws auth status [--profile <名称>]` — 查看认证状态 + +## 跨组织铁律(必须执行,不得跳过) +「找群 / 找人 / 找数据」(chat search、aisearch / contact、doc / wiki 搜索等读 / 搜场景)在当前组织没命中、且 `dws profile list` 显示 ≥2 个组织时,对每个组织带一次性 `--profile ` 各搜一遍;命中即用,全部组织都没有才追问用户。禁止在当前组织搜不到就判定「不存在」或直接甩给用户选。 + +## 跨组织聚合(agent 编排,无内置 --all-orgs) +① `dws profile list` 拿到所有已登录组织 → ② 对每个组织带 `--profile ` 各取一次数 → ③ 合并并标注来源组织;某组织失败则标「该组织暂不可用」并继续返回其余。 + +## 安全护栏(务必遵守) +- 只有 `dws profile list` 显示 ≥2 个组织才启用跨组织逻辑;单组织直接按当前组织走,不带 `--profile`。 +- 自动跨组织只对「读 / 搜」。写 / 发 / 删 / 撤回等操作默认只在当前组织做;确需带 `--profile` 跨组织写时,必须先与用户确认目标组织。 +- 持久切换 `dws profile switch`(改默认组织)按写操作对待:未经用户明确要求不得执行。跨组织找数一律用一次性 `--profile`,不改当前组织。 +- `dws auth logout` 默认退出所有已登录组织;只退一个加 `--profile <名称|corpId>`。退主组织不会被拦截,会静默改选新主,执行前必须向用户确认。 diff --git a/skills/multi/dws-shared/SKILL.md b/skills/multi/dws-shared/SKILL.md new file mode 100644 index 00000000..f48e1563 --- /dev/null +++ b/skills/multi/dws-shared/SKILL.md @@ -0,0 +1,39 @@ +--- +name: dws-shared +description: dws 多 skill 模式的公共参考——认证、全局参数、多组织 / --profile 规则、安全底线。所有 dingtalk-* 子 skill 执行前先读本 skill。命令前缀:dws。 +cli_version: ">=1.0.40" +metadata: + category: productivity + stability: experimental + requires: + bins: + - dws +--- + +# DWS 公共参考(dws-shared) + +> 🧪 **EXPERIMENTAL · 试验版 / Preview** — multi 模式当前未达 stable 标准;生产 / 共享环境请优先使用 mono 模式(`dws skill setup --mode mono`)。 + +每个 dingtalk-* 子 skill 都把本 skill 列为 PREREQUISITE:执行任何产品命令前先读这里的认证、全局参数与多组织规则。`dws` 必须在 PATH 上。 + +## 认证 +- `dws auth login`(新登一个组织即新增 profile);`--device` 无头 / SSH 登录;`--recommend` 无交互批量授权 +- `dws auth status [--profile <名称|corpId>]` 查看认证状态 + +## 全局参数 +- 所有命令加 `--format json` 取可解析输出 +- 全局 `--profile <名称|corpId>`:单次指定本命令用哪个组织,一次性、不改默认组织 +- 危险 / 写 / 删操作执行前先向用户确认 + +## 多组织 / --profile(关键规则) +dws 可同时登录多个钉钉组织,一个 profile = 一个已登录组织(corp)。当前 profile 决定本次命令用哪个组织的身份(corpId / userId 自动注入)。 + +- **跨组织铁律**:任何「找群 / 找人 / 找数据」(如 chat / aisearch / contact / doc / wiki / aitable / sheet / minutes / mail / report / todo / calendar / oa 的搜索、列表、查询)在当前组织没命中、且 `dws profile list` 显示 ≥2 个组织时,对每个组织带一次性 `--profile ` 各搜一遍;命中即用,全部组织都没有才追问用户。禁止在当前组织搜不到就判定「不存在」或直接甩给用户选。 +- **单组织**:`dws profile list` 只有 1 个组织时,按当前组织正常处理,不带 `--profile`。 +- **安全护栏**:自动跨组织只对「读 / 搜」;写 / 发 / 删 / 撤回等操作默认只在当前组织做,确需带 `--profile` 跨组织写时先与用户确认目标组织;持久切换 `dws profile switch`(改默认组织)属写操作,未经用户明确要求不得执行。 +- 完整命令与跨组织聚合见 `dingtalk-profile` skill。 + +## 错误处理 +- `unknown command` / `unknown flag`:先跑 `dws --help` 查证再修正一次,别把自然语言当命令 / flag +- 认证失败 / token 过期:提示用户 `dws auth login` 重新登录 +- 业务错误码 / 接口语义:用 `dws devdoc article search --query "<关键词>" --format json` 查官方文档,不编造原因