890 lines
27 KiB
Go
890 lines
27 KiB
Go
// Copyright 2026 Alibaba Group
|
||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||
// you may not use this file except in compliance with the License.
|
||
// You may obtain a copy of the License at
|
||
//
|
||
// http://www.apache.org/licenses/LICENSE-2.0
|
||
//
|
||
// Unless required by applicable law or agreed to in writing, software
|
||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||
// See the License for the specific language governing permissions and
|
||
// limitations under the License.
|
||
|
||
package app
|
||
|
||
import (
|
||
"encoding/json"
|
||
"errors"
|
||
"fmt"
|
||
"io"
|
||
"strings"
|
||
"time"
|
||
|
||
authpkg "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/auth"
|
||
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
|
||
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 管理",
|
||
Long: `管理本机已登录的钉钉账号 profile。
|
||
|
||
每个 profile 由 corpId + userId 唯一确定,同一组织可保存多个账号。业务命令可通过
|
||
全局 --profile 临时指定组织或账号,profile switch/use 才会持久修改默认账号。`,
|
||
Example: ` dws profile list
|
||
dws profile switch
|
||
dws profile switch <corpId>
|
||
dws profile switch <corpId>:<userId>
|
||
dws profile switch "<corpName>:<userName>"
|
||
dws profile switch -
|
||
dws --profile <corpId>:<userId> contact user get-self`,
|
||
Args: cobra.NoArgs,
|
||
TraverseChildren: true,
|
||
DisableAutoGenTag: true,
|
||
RunE: func(cmd *cobra.Command, args []string) error {
|
||
return cmd.Help()
|
||
},
|
||
}
|
||
cmd.AddCommand(newProfileListCommand(), newProfileSwitchCommand(), newProfileUseCommand())
|
||
return cmd
|
||
}
|
||
|
||
func newProfileListCommand() *cobra.Command {
|
||
return &cobra.Command{
|
||
Use: "list",
|
||
Aliases: []string{"ls"},
|
||
Short: "列出全部已登录账号 profile",
|
||
Long: "列出本机全部已登录账号。状态和到期时间直接读取各身份 Token,列表本身不会刷新 Token。",
|
||
Example: ` dws profile list
|
||
dws profile list --format json`,
|
||
Args: cobra.NoArgs,
|
||
DisableAutoGenTag: true,
|
||
RunE: func(cmd *cobra.Command, args []string) error {
|
||
configDir := defaultConfigDir()
|
||
if err := profileEnsureProfilesMigration(configDir); err != nil {
|
||
return apperrors.NewInternal(fmt.Sprintf("failed to migrate profiles: %v", err))
|
||
}
|
||
cfg, err := profileLoadProfiles(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(), configDir, cfg)
|
||
}
|
||
writeProfileListTable(cmd.OutOrStdout(), configDir, cfg)
|
||
return nil
|
||
},
|
||
}
|
||
}
|
||
|
||
func newProfileUseCommand() *cobra.Command {
|
||
cmd := &cobra.Command{
|
||
Use: "use [profile-selector|-]",
|
||
Short: "切换当前账号 profile(兼容 profile switch)",
|
||
Long: "兼容命令,语义等同于 dws profile switch。选择器支持组织 ID/名称、账号 ID/名称组合或本地 profile 名;- 切回上一个账号。",
|
||
Example: ` dws profile use <corpId>
|
||
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 {
|
||
cmd := &cobra.Command{
|
||
Use: "switch [profile-selector|-]",
|
||
Short: "切换当前账号 profile",
|
||
Long: `切换默认账号 profile,并记录 previousProfile 以支持 dws profile switch - 快速切回。
|
||
|
||
选择器支持 corpId:userId、corpId:userName、corpName:userId、corpName:userName,
|
||
也兼容单独的 corpId、唯一 corpName 和本地 profile 名。组织或账号名称重名时会报错,
|
||
要求改用稳定的 corpId:userId。不带参数时交互选择;单次执行请使用全局 --profile。`,
|
||
Example: ` dws profile switch
|
||
dws profile switch <corpId>
|
||
dws profile switch <corpId>:<userId>
|
||
dws profile switch "<corpName>:<userName>"
|
||
dws profile switch --corpId <corpId>
|
||
dws profile switch --name "钉钉"
|
||
dws profile switch -
|
||
dws --profile <corpId>:<userId> 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 (
|
||
profileSwitchSelector = selectProfileSwitchProfile
|
||
profileSwitchInteractiveTerminal = isInteractiveTerminal
|
||
profileSwitchTUIRunner = runProfileSwitchTUI
|
||
profileEnsureProfilesMigration = authpkg.EnsureProfilesMigration
|
||
profileLoadProfiles = authpkg.LoadProfiles
|
||
profileLoadTokenData = authpkg.LoadTokenDataForProfile
|
||
profileUsePrevious = authpkg.UsePreviousProfile
|
||
profileSetCurrent = authpkg.SetCurrentProfile
|
||
profileRunTeaProgram = (*tea.Program).Run
|
||
)
|
||
|
||
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, err := profileSwitchSelectorFromCommand(cmd, args)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
usedTUI := false
|
||
if selector == "" {
|
||
selector, err = profileSwitchSelector(cmd, configDir)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
usedTUI = true
|
||
}
|
||
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
|
||
err error
|
||
)
|
||
if strings.TrimSpace(selector) == "-" {
|
||
profile, err = profileUsePrevious(configDir)
|
||
} else {
|
||
profile, err = profileSetCurrent(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 := profileLoadProfiles(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 profile switch <corpId|corpId:userId|corpName:userName>")
|
||
}
|
||
if err := profileEnsureProfilesMigration(configDir); err != nil {
|
||
return "", apperrors.NewInternal(fmt.Sprintf("failed to migrate profiles: %v", err))
|
||
}
|
||
cfg, err := profileLoadProfiles(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 = authpkg.ProfileSelectionSelector(cfg.Profiles[0], cfg)
|
||
}
|
||
return profileSwitchTUIRunner(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 := profileRunTeaProgram(program)
|
||
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))
|
||
}
|
||
final, ok := finalModel.(profileSwitchTUIModel)
|
||
if !ok || final.aborted || !final.submitted {
|
||
return "", apperrors.NewValidation("组织选择中止: user aborted")
|
||
}
|
||
return final.selectedCorpID(), 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)
|
||
}
|
||
model.selected = profileSwitchProfileIndex(model.profiles, selectedCorpID, cfg)
|
||
if model.selected < 0 {
|
||
model.selected = 0
|
||
}
|
||
model.ensureSelectedVisible()
|
||
return model
|
||
}
|
||
|
||
func profileSwitchSortedProfiles(profiles []authpkg.Profile) []authpkg.Profile {
|
||
return append([]authpkg.Profile(nil), profiles...)
|
||
}
|
||
|
||
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()))
|
||
}
|
||
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 ""
|
||
}
|
||
selected := m.profiles[m.selected]
|
||
return authpkg.ProfileSelectionSelector(selected, &authpkg.ProfilesConfig{Profiles: m.profiles})
|
||
}
|
||
|
||
func profileSwitchProfileIndex(profiles []authpkg.Profile, selector string, cfg *authpkg.ProfilesConfig) int {
|
||
selector = strings.TrimSpace(selector)
|
||
for i, profile := range profiles {
|
||
if authpkg.ProfileSelectionSelector(profile, cfg) == selector {
|
||
return i
|
||
}
|
||
}
|
||
// Accept an old current/previous pointer long enough for the migration path
|
||
// to canonicalize it. Only an unresolved profile in a multi-account
|
||
// organization qualifies, so ordinary exact account names cannot capture an
|
||
// identity selector that contains ':'.
|
||
legacyBlank := -1
|
||
for i, profile := range profiles {
|
||
if strings.TrimSpace(profile.UserID) != "" || strings.TrimSpace(profile.Name) != selector ||
|
||
profileCountForCorp(cfg, profile.CorpID) <= 1 {
|
||
continue
|
||
}
|
||
if legacyBlank >= 0 {
|
||
legacyBlank = -1
|
||
break
|
||
}
|
||
legacyBlank = i
|
||
}
|
||
if legacyBlank >= 0 {
|
||
return legacyBlank
|
||
}
|
||
if corpID, userID, exact := authpkg.ParseIdentitySelector(selector); exact {
|
||
for i, p := range profiles {
|
||
if strings.TrimSpace(p.CorpID) == corpID && strings.TrimSpace(p.UserID) == userID {
|
||
return i
|
||
}
|
||
}
|
||
return -1
|
||
}
|
||
fallback := -1
|
||
for i, p := range profiles {
|
||
if strings.TrimSpace(p.UserID) == "" && strings.TrimSpace(p.Name) == selector {
|
||
return i
|
||
}
|
||
if strings.TrimSpace(p.CorpID) == selector {
|
||
if fallback < 0 {
|
||
fallback = i
|
||
}
|
||
if profileIsOrgCurrent(p, cfg) {
|
||
return i
|
||
}
|
||
}
|
||
}
|
||
return fallback
|
||
}
|
||
|
||
func profileSwitchOptionLabel(p authpkg.Profile, cfg *authpkg.ProfilesConfig) string {
|
||
org, status := profileSwitchProfileCells(p, cfg)
|
||
if status == "" {
|
||
return org
|
||
}
|
||
return strings.Join([]string{org, status}, " | ")
|
||
}
|
||
|
||
func profileSwitchProfileCells(p authpkg.Profile, cfg *authpkg.ProfilesConfig) (string, string) {
|
||
orgName := profileOrgName(p)
|
||
if cfg != nil {
|
||
sameCorp := 0
|
||
for _, candidate := range cfg.Profiles {
|
||
if candidate.CorpID == p.CorpID {
|
||
sameCorp++
|
||
}
|
||
}
|
||
if sameCorp > 1 {
|
||
user := strings.TrimSpace(p.UserName)
|
||
if user == "" {
|
||
user = strings.TrimSpace(p.UserID)
|
||
}
|
||
if user != "" {
|
||
orgName += " / " + user
|
||
}
|
||
}
|
||
}
|
||
return orgName, profileSwitchProfileStatus(p, cfg)
|
||
}
|
||
|
||
func profileSwitchProfileStatus(p authpkg.Profile, cfg *authpkg.ProfilesConfig) string {
|
||
if cfg != nil && profileSelectorSelectsProfile(cfg.CurrentProfile, p, cfg, profileIsOrgCurrent(p, cfg), profileCountForCorp(cfg, p.CorpID) <= 1) {
|
||
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),
|
||
}
|
||
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)),
|
||
}
|
||
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 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 {
|
||
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 {
|
||
Profile string `json:"profile"`
|
||
CorpID string `json:"corpId"`
|
||
CorpName string `json:"corpName"`
|
||
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"`
|
||
IsOrgCurrent bool `json:"isOrgCurrent"`
|
||
}
|
||
|
||
func writeProfileListJSON(w io.Writer, configDir string, cfg *authpkg.ProfilesConfig) error {
|
||
resp := profileListResponse{
|
||
Success: true,
|
||
PrimaryProfile: cfg.PrimaryProfile,
|
||
CurrentProfile: cfg.CurrentProfile,
|
||
PreviousProfile: cfg.PreviousProfile,
|
||
Profiles: profileViews(configDir, cfg),
|
||
}
|
||
enc := json.NewEncoder(w)
|
||
enc.SetIndent("", " ")
|
||
return enc.Encode(resp)
|
||
}
|
||
|
||
func writeProfileUseJSON(w io.Writer, profile *authpkg.Profile, cfg *authpkg.ProfilesConfig) error {
|
||
resp := profileUseResponse{Success: true}
|
||
if profile != nil {
|
||
primaryProfile := ""
|
||
currentProfile := ""
|
||
if cfg != nil {
|
||
primaryProfile = cfg.PrimaryProfile
|
||
currentProfile = cfg.CurrentProfile
|
||
}
|
||
resp.Profile = profileViewFromProfile(
|
||
*profile,
|
||
cfg,
|
||
primaryProfile,
|
||
currentProfile,
|
||
profileCountForCorp(cfg, profile.CorpID) <= 1,
|
||
nil,
|
||
)
|
||
}
|
||
enc := json.NewEncoder(w)
|
||
enc.SetIndent("", " ")
|
||
return enc.Encode(resp)
|
||
}
|
||
|
||
func writeProfileListTable(w io.Writer, configDir string, cfg *authpkg.ProfilesConfig) {
|
||
if cfg == nil || len(cfg.Profiles) == 0 {
|
||
fmt.Fprintln(w, "未找到已登录 profile")
|
||
return
|
||
}
|
||
fmt.Fprintf(w, "%-3s %-28s %-34s %-10s %s\n", "CUR", "ORG_NAME", "CORP_ID", "STATUS", "USER")
|
||
for _, p := range cfg.Profiles {
|
||
selector := profileCLISelector(p, cfg)
|
||
view := profileViewFromProfile(
|
||
p,
|
||
cfg,
|
||
cfg.PrimaryProfile,
|
||
cfg.CurrentProfile,
|
||
profileCountForCorp(cfg, p.CorpID) == 1,
|
||
loadProfileTokenState(configDir, p, selector),
|
||
)
|
||
current := ""
|
||
if view.IsCurrent {
|
||
current = "*"
|
||
}
|
||
user := p.UserName
|
||
if user == "" {
|
||
user = p.UserID
|
||
}
|
||
fmt.Fprintf(
|
||
w,
|
||
"%-3s %-28s %-34s %-10s %s\n",
|
||
current,
|
||
clipProfileCell(profileOrgName(p), 28),
|
||
clipProfileCell(p.CorpID, 34),
|
||
view.Status,
|
||
user,
|
||
)
|
||
}
|
||
}
|
||
|
||
func profileUseMessage(profile *authpkg.Profile) string {
|
||
if profile == nil {
|
||
return "[OK] 当前 profile 已切换"
|
||
}
|
||
corpID := strings.TrimSpace(profile.CorpID)
|
||
orgName := strings.TrimSpace(profile.CorpName)
|
||
if orgName == "" {
|
||
orgName = profileOrgName(*profile)
|
||
}
|
||
return fmt.Sprintf("[OK] 当前组织: %s (%s)", 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)
|
||
}
|
||
|
||
type profileTokenState struct {
|
||
Status string
|
||
ExpiresAt string
|
||
RefreshExpAt string
|
||
}
|
||
|
||
func profileViews(configDir string, cfg *authpkg.ProfilesConfig) []profileView {
|
||
if cfg == nil {
|
||
return nil
|
||
}
|
||
views := make([]profileView, 0, len(cfg.Profiles))
|
||
for _, p := range cfg.Profiles {
|
||
selector := profileCLISelector(p, cfg)
|
||
views = append(views, profileViewFromProfile(
|
||
p,
|
||
cfg,
|
||
cfg.PrimaryProfile,
|
||
cfg.CurrentProfile,
|
||
profileCountForCorp(cfg, p.CorpID) == 1,
|
||
loadProfileTokenState(configDir, p, selector),
|
||
))
|
||
}
|
||
return views
|
||
}
|
||
|
||
func profileViewFromProfile(
|
||
p authpkg.Profile,
|
||
cfg *authpkg.ProfilesConfig,
|
||
primaryProfile, currentProfile string,
|
||
onlyAccountInOrg bool,
|
||
tokenState *profileTokenState,
|
||
) profileView {
|
||
isOrgCurrent := profileIsOrgCurrent(p, cfg)
|
||
view := profileView{
|
||
Profile: profileCLISelector(p, cfg),
|
||
CorpID: p.CorpID,
|
||
CorpName: profileOrgName(p),
|
||
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: profileSelectorSelectsProfile(primaryProfile, p, cfg, isOrgCurrent, onlyAccountInOrg),
|
||
IsCurrent: profileSelectorSelectsProfile(currentProfile, p, cfg, isOrgCurrent, onlyAccountInOrg),
|
||
IsOrgCurrent: isOrgCurrent,
|
||
}
|
||
if tokenState != nil {
|
||
view.Status = tokenState.Status
|
||
view.ExpiresAt = tokenState.ExpiresAt
|
||
view.RefreshExpAt = tokenState.RefreshExpAt
|
||
}
|
||
return view
|
||
}
|
||
|
||
func loadProfileTokenState(configDir string, profile authpkg.Profile, selectors ...string) *profileTokenState {
|
||
selector := authpkg.ProfileSelector(profile)
|
||
if len(selectors) > 0 && strings.TrimSpace(selectors[0]) != "" {
|
||
selector = strings.TrimSpace(selectors[0])
|
||
}
|
||
data, err := profileLoadTokenData(configDir, selector)
|
||
if errors.Is(err, authpkg.ErrTokenDataNotFound) || (err == nil && data == nil) {
|
||
return &profileTokenState{Status: authpkg.ProfileStatusRevoked}
|
||
}
|
||
if err != nil {
|
||
return &profileTokenState{Status: authpkg.ProfileStatusUnavailable}
|
||
}
|
||
status := authpkg.ProfileStatusExpired
|
||
if data.IsAccessTokenValid() {
|
||
status = authpkg.ProfileStatusActive
|
||
}
|
||
return &profileTokenState{
|
||
Status: status,
|
||
ExpiresAt: profileTokenTime(data.ExpiresAt),
|
||
RefreshExpAt: profileTokenTime(data.RefreshExpAt),
|
||
}
|
||
}
|
||
|
||
func profileCLISelector(profile authpkg.Profile, cfg *authpkg.ProfilesConfig) string {
|
||
return authpkg.ProfileSelectionSelector(profile, cfg)
|
||
}
|
||
|
||
func profileTokenTime(value time.Time) string {
|
||
if value.IsZero() {
|
||
return ""
|
||
}
|
||
return value.Format(time.RFC3339)
|
||
}
|
||
|
||
func profileSelectorSelectsProfile(selector string, profile authpkg.Profile, cfg *authpkg.ProfilesConfig, isOrgCurrent, onlyAccountInOrg bool) bool {
|
||
selector = strings.TrimSpace(selector)
|
||
if selector == authpkg.ProfileSelectionSelector(profile, cfg) {
|
||
return true
|
||
}
|
||
if corpID, userID, exact := authpkg.ParseIdentitySelector(selector); exact {
|
||
return corpID == strings.TrimSpace(profile.CorpID) && userID == strings.TrimSpace(profile.UserID)
|
||
}
|
||
return selector == strings.TrimSpace(profile.CorpID) && (isOrgCurrent || onlyAccountInOrg)
|
||
}
|
||
|
||
func profileCountForCorp(cfg *authpkg.ProfilesConfig, corpID string) int {
|
||
if cfg == nil {
|
||
return 0
|
||
}
|
||
count := 0
|
||
for _, profile := range cfg.Profiles {
|
||
if strings.TrimSpace(profile.CorpID) == strings.TrimSpace(corpID) {
|
||
count++
|
||
}
|
||
}
|
||
return count
|
||
}
|
||
|
||
func profileIsOrgCurrent(profile authpkg.Profile, cfg *authpkg.ProfilesConfig) bool {
|
||
if cfg == nil {
|
||
return false
|
||
}
|
||
selector := strings.TrimSpace(cfg.OrgCurrentProfiles[strings.TrimSpace(profile.CorpID)])
|
||
if corpID, userID, exact := authpkg.ParseIdentitySelector(selector); exact {
|
||
return corpID == strings.TrimSpace(profile.CorpID) && userID == strings.TrimSpace(profile.UserID)
|
||
}
|
||
return profileCountForCorp(cfg, profile.CorpID) == 1
|
||
}
|
||
|
||
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]) + "..."
|
||
}
|
||
|
||
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 b.String() + "..."
|
||
}
|