// login.go — teai login 指令:管理 tea 相容的登入組態。 // // teai 與 tea 共用同一份組態檔(README 設計原則 5),login 的職責是: // 以 token 向站點驗證(GET /user),成功後才寫入組態並記下帳號。 // token 只走記憶體與組態檔,不進輸出、日誌或錯誤訊息。 // // 子命令:list/add/default/remove。 package cli import ( "bufio" "context" "errors" "flag" "fmt" "io" "net/url" "os" "path/filepath" "strings" "gitea.alterminal.com/alterminal/teai/internal/gitea" ) // loginStdin 是讀 token 與確認的輸入(測試可替換)。 var loginStdin io.Reader = os.Stdin // loginConfigPath 回傳組態檔路徑:--config/TEA_CONFIG 優先,其次 // ~/.config/tea/config.yml。找不到可用路徑回空字串。 func loginConfigPath(g Globals) string { if p := strings.TrimSpace(g.ConfigPath); p != "" { return expandHome(p) } if p := os.Getenv("TEA_CONFIG"); strings.TrimSpace(p) != "" { return expandHome(strings.TrimSpace(p)) } home, err := os.UserHomeDir() if err != nil || home == "" { return "" } return filepath.Join(home, ".config", "tea", "config.yml") } // expandHome 展開路徑開頭的 ~。 func expandHome(p string) string { if p == "~" || strings.HasPrefix(p, "~/") { if home, err := os.UserHomeDir(); err == nil { return filepath.Join(home, strings.TrimPrefix(p, "~")) } } return p } // runLogin 是 teai login 的進入點:分派子命令。 func runLogin(env *Env, args []string) error { if len(args) == 0 { return &ErrUsage{Msg: "login requires a subcommand: list, add, default, remove"} } sub, rest := args[0], args[1:] switch sub { case "list": return runLoginList(env, rest) case "add": return runLoginAdd(env, rest) case "default": return runLoginDefault(env, rest) case "remove": return runLoginRemove(env, rest) default: return &ErrUsage{Msg: fmt.Sprintf("unknown login subcommand %q (want list, add, default, remove)", sub)} } } // loginFields 是輸出欄位(不含 token)。 var loginFields = []gitea.Field{ {Name: "name"}, {Name: "url"}, {Name: "user"}, {Name: "default"}, } // printLoginList 依格式輸出登入清單。 func printLoginList(env *Env, logins []gitea.LoginEntry) error { p := gitea.Printer{Format: env.Globals.Output, Out: env.Out} rows := make([]gitea.Values, 0, len(logins)) for _, l := range logins { rows = append(rows, gitea.Values{l.Name, l.URL, l.User, l.Default}) } return p.PrintList(loginFields, rows) } // runLoginList 列出組態中的登入(一律不含 token)。 func runLoginList(env *Env, args []string) error { fs := flag.NewFlagSet("teai login list", flag.ContinueOnError) fs.SetOutput(env.Err) if err := parseFlags(fs, args); err != nil { return err } if fs.NArg() > 0 { return &ErrUsage{Msg: fmt.Sprintf("unexpected argument %q", fs.Arg(0))} } path := loginConfigPath(env.Globals) if path == "" { return fmt.Errorf("cannot determine config path") } logins, err := gitea.ListLoginsFile(path) if err != nil { return fmt.Errorf("read config %s: %w", path, err) } return printLoginList(env, logins) } // runLoginAdd 新增或更新登入:先驗證 token(GET /user),成功才寫入。 // // token 來源:--token 旗標,或 stdin(預設未給旗標時讀一行, // 避免 token 進 shell 歷史)。 func runLoginAdd(env *Env, args []string) error { fs := flag.NewFlagSet("teai login add", flag.ContinueOnError) fs.SetOutput(env.Err) urlFlag := fs.String("url", env.Globals.URL, "Gitea 站點網址") nameFlag := fs.String("name", "", "登入名稱(省略時取 URL 主機)") tokenFlag := fs.String("token", "", "API token(省略時自 stdin 讀一行)") defaultFlag := fs.Bool("default", true, "設為預設登入") if err := parseFlags(fs, args); err != nil { return err } if fs.NArg() > 0 { return &ErrUsage{Msg: fmt.Sprintf("unexpected argument %q", fs.Arg(0))} } siteURL := strings.TrimSuffix(strings.TrimSpace(*urlFlag), "/") if siteURL == "" { return &ErrUsage{Msg: "--url must not be empty"} } token := strings.TrimSpace(*tokenFlag) if token == "" { t, err := readLineFrom(loginStdin) if err != nil { return fmt.Errorf("read token from stdin: %w", err) } token = t } if token == "" { return &ErrUsage{Msg: "token must not be empty"} } path := loginConfigPath(env.Globals) if path == "" { return fmt.Errorf("cannot determine config path") } data, err := os.ReadFile(path) if err != nil && !os.IsNotExist(err) { return fmt.Errorf("read config %s: %w", path, err) } name := strings.TrimSpace(*nameFlag) if name == "" { // 未指定名稱:同站(主機相同)已有登入就就地更新,避免重複項目。 if existing, ok := findLoginByHost(data, siteURL); ok { name = existing.Name } else { name = defaultLoginName(siteURL) } } // 先驗證再寫入:token 無效時不動組態檔。 user, err := verifyToken(env, siteURL, token) if err != nil { return err } out, final, err := gitea.UpsertLogin(data, gitea.LoginEntry{ Name: name, URL: siteURL, Token: token, Default: *defaultFlag, User: user, }, *defaultFlag) if err != nil { return err } if err := gitea.SaveTeaConfigFile(path, out); err != nil { return err } p := gitea.Printer{Format: env.Globals.Output, Out: env.Out} return p.PrintOne(loginFields, gitea.Values{final.Name, final.URL, final.User, final.Default}) } // readLineFrom 讀一行並去除頭尾空白;EOF 且無內容回空字串。 func readLineFrom(r io.Reader) (string, error) { line, err := bufio.NewReader(r).ReadString('\n') if err != nil && err != io.EOF { return "", err } return strings.TrimSpace(line), nil } // verifyToken 以 token 呼叫 GET /user,回傳帳號名稱。 // 401 特別標明是 token 遭拒,其餘依 *gitea.ErrAPI 對應結束碼 3。 func verifyToken(env *Env, siteURL, token string) (string, error) { c, err := gitea.New(siteURL, token, env.Globals.Timeout) if err != nil { return "", &ErrUsage{Msg: err.Error()} } ctx, cancel := context.WithTimeout(context.Background(), env.Globals.Timeout) defer cancel() var out struct { Login string `json:"login"` } if err := c.GetJSON(ctx, "/user", &out); err != nil { var api *gitea.ErrAPI if errors.As(err, &api) && api.StatusCode == 401 { // 包 %w 保留 *ErrAPI:結束碼對應 3(API 錯誤)。 return "", fmt.Errorf("token rejected by %s: %w", siteURL, api) } return "", err } if out.Login == "" { return "", fmt.Errorf("empty account name returned by %s", siteURL) } return out.Login, nil } // runLoginDefault 設定預設登入。 func runLoginDefault(env *Env, args []string) error { fs := flag.NewFlagSet("teai login default", flag.ContinueOnError) fs.SetOutput(env.Err) if err := parseFlags(fs, args); err != nil { return err } if fs.NArg() != 1 { return &ErrUsage{Msg: "login default requires exactly one login name"} } name := fs.Arg(0) data, err := readLoginConfig(env, "login default") if err != nil { return err } entry, found := findLogin(data, name) if !found { return fmt.Errorf("gitea: login %q not found", name) } out, err := gitea.SetDefaultLogin(data, name) if err != nil { return err } if err := saveLoginConfig(env, out); err != nil { return err } p := gitea.Printer{Format: env.Globals.Output, Out: env.Out} return p.PrintOne(loginFields, gitea.Values{entry.Name, entry.URL, entry.User, true}) } // runLoginRemove 移除登入;未加 --yes 時先確認。 func runLoginRemove(env *Env, args []string) error { fs := flag.NewFlagSet("teai login remove", flag.ContinueOnError) fs.SetOutput(env.Err) yesFlag := fs.Bool("yes", false, "跳過確認") if err := parseFlags(fs, args); err != nil { return err } if fs.NArg() != 1 { return &ErrUsage{Msg: "login remove requires exactly one login name"} } name := fs.Arg(0) data, err := readLoginConfig(env, "login remove") if err != nil { return err } entry, found := findLogin(data, name) if !found { return fmt.Errorf("gitea: login %q not found", name) } if !*yesFlag { confirmed, err := confirmRemove(env, entry) if err != nil { return err } if !confirmed { fmt.Fprintln(env.Out, "cancelled") return nil } } out, promoted, err := gitea.RemoveLogin(data, name) if err != nil { return err } if err := saveLoginConfig(env, out); err != nil { return err } if promoted != "" { fmt.Fprintf(env.Err, "default login is now %q\n", promoted) } p := gitea.Printer{Format: env.Globals.Output, Out: env.Out} return p.PrintOne(loginFields, gitea.Values{entry.Name, entry.URL, entry.User, false}) } // confirmRemove 印出待移除項目(不含 token)並讀 y/yes 確認; // 其他輸入(含 EOF)一律視為取消。 func confirmRemove(env *Env, entry gitea.LoginEntry) (bool, error) { fmt.Fprintf(env.Err, "remove login %q (url %s, user %s)? [y/N] ", entry.Name, entry.URL, entry.User) line, err := readLineFrom(loginStdin) if err != nil { return false, fmt.Errorf("read confirmation: %w", err) } switch strings.ToLower(line) { case "y", "yes": return true, nil default: return false, nil } } // readLoginConfig 讀組態檔內容;路徑無法判定或讀取失敗時回錯。 func readLoginConfig(env *Env, op string) ([]byte, error) { path := loginConfigPath(env.Globals) if path == "" { return nil, fmt.Errorf("cannot determine config path") } data, err := os.ReadFile(path) if err != nil { return nil, fmt.Errorf("%s: read config %s: %w", op, path, err) } return data, nil } // saveLoginConfig 原子寫回組態檔。 func saveLoginConfig(env *Env, data []byte) error { path := loginConfigPath(env.Globals) if path == "" { return fmt.Errorf("cannot determine config path") } return gitea.SaveTeaConfigFile(path, data) } // findLoginByHost 在組態內容中找 URL 主機與 siteURL 相同的登入。 func findLoginByHost(data []byte, siteURL string) (gitea.LoginEntry, bool) { for _, l := range gitea.ListLogins(data) { if gitea.HostOf(l.URL) == gitea.HostOf(siteURL) { return l, true } } return gitea.LoginEntry{}, false } // findLogin 在組態內容中找指定名稱的登入。 func findLogin(data []byte, name string) (gitea.LoginEntry, bool) { for _, l := range gitea.ListLogins(data) { if l.Name == name { return l, true } } return gitea.LoginEntry{}, false } // defaultLoginName 以 URL 主機作為登入名稱(同 tea 的習慣)。 func defaultLoginName(rawURL string) string { s := rawURL if !strings.Contains(s, "://") { s = "https://" + s } if u, err := url.Parse(s); err == nil && u.Host != "" { return u.Host } return rawURL }