實現 login 指令(#14):tea 相容組態管理
- internal/gitea/config.go:tea 組態的行級編輯引擎(新增/更新/ 設預設/移除),未知欄位與 preferences 區段逐字保留;原子寫入 (暫存檔 + rename,0600)。 - internal/cli/login.go:teai login list/add/default/remove。add 先以 token 呼叫 GET /user 驗證,成功才寫入並記下帳號;未給 --token 時 自 stdin 讀一行;401 結束碼 3。未給 --name 時同站既有項目就地更新。 - 輸出欄位 name/url/user/default,永不輸出 token。 - 測試:編輯引擎 12 項、指令層 11 項(httptest 假 API),並以真 tea 驗證 teai 寫出的組態可互通(tea whoami 讀出同帳號)。 Closes #14
This commit is contained in:
@@ -0,0 +1,421 @@
|
||||
// config.go — tea 相容登入組態的讀取與行級編輯。
|
||||
//
|
||||
// teai 與 tea 共用同一份組態檔(README 設計原則 5),因此寫入採「行級編輯」:
|
||||
// 只修改目標登入項目的所屬行,其餘內容(未知欄位、註解、preferences 區段、
|
||||
// 空行與縮排)逐字保留,避免改壞 tea 也在使用的檔案。新增項目依 tea 實際
|
||||
// 寫出的欄位順序輸出,讓檔案外觀與 tea 一致。
|
||||
//
|
||||
// token 值只會進出這個套件的資料結構與組態檔本身,永遠不出現在錯誤訊息。
|
||||
package gitea
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"slices"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// LoginEntry 是組態檔中的一筆登入資料(編輯用,含 tea 的 user 欄位)。
|
||||
type LoginEntry struct {
|
||||
Name string
|
||||
URL string
|
||||
Token string
|
||||
Default bool
|
||||
User string
|
||||
}
|
||||
|
||||
// loginItemSpan 標記 logins 清單中一個項目在檔案中的行範圍 [start, end)。
|
||||
type loginItemSpan struct {
|
||||
name string
|
||||
start int
|
||||
end int
|
||||
}
|
||||
|
||||
// configDoc 是解析後的組態檔:原始行與 logins 項目的位置資訊。
|
||||
type configDoc struct {
|
||||
lines []string
|
||||
items []loginItemSpan
|
||||
loginsHeader int // 「logins:」所在行;-1 表示沒有
|
||||
itemIndent string // 項目行的縮排(取第一個項目);預設四格空白
|
||||
}
|
||||
|
||||
// parseConfigDoc 掃描組態內容,定位 logins 清單與各項目的行範圍。
|
||||
// 只認識 tea 實際寫出的格式:頂層 `logins:` 加縮排的 `- key: value` 項目;
|
||||
// 其餘行原樣保留在 lines 中。
|
||||
func parseConfigDoc(data []byte) configDoc {
|
||||
doc := configDoc{lines: splitLines(data), loginsHeader: -1, itemIndent: " "}
|
||||
inLogins := false
|
||||
cur := -1
|
||||
for i, raw := range doc.lines {
|
||||
line := raw
|
||||
trimmed := strings.TrimSpace(line)
|
||||
if trimmed == "" || strings.HasPrefix(trimmed, "#") {
|
||||
continue
|
||||
}
|
||||
indent := line[:len(line)-len(strings.TrimLeft(line, " "))]
|
||||
if indent == "" {
|
||||
if inLogins && doc.loginsHeader >= 0 {
|
||||
// 離開 logins 區段;listEnd 由最後一個項目的 end 決定,這裡不用記。
|
||||
}
|
||||
inLogins = strings.HasPrefix(trimmed, "logins:")
|
||||
if inLogins && doc.loginsHeader < 0 {
|
||||
doc.loginsHeader = i
|
||||
}
|
||||
cur = -1
|
||||
continue
|
||||
}
|
||||
if !inLogins {
|
||||
continue
|
||||
}
|
||||
if strings.HasPrefix(trimmed, "- ") || trimmed == "-" {
|
||||
if cur >= 0 {
|
||||
doc.items[cur].end = i
|
||||
}
|
||||
doc.items = append(doc.items, loginItemSpan{start: i, end: i + 1})
|
||||
cur = len(doc.items) - 1
|
||||
if len(doc.items) == 1 {
|
||||
doc.itemIndent = indent
|
||||
}
|
||||
if rest := strings.TrimSpace(strings.TrimPrefix(trimmed, "-")); rest != "" {
|
||||
if key, value, ok := strings.Cut(rest, ":"); ok && strings.TrimSpace(key) == "name" {
|
||||
doc.items[cur].name = unquoteYAML(strings.TrimSpace(value))
|
||||
}
|
||||
}
|
||||
continue
|
||||
}
|
||||
if cur >= 0 {
|
||||
doc.items[cur].end = i + 1
|
||||
}
|
||||
}
|
||||
return doc
|
||||
}
|
||||
|
||||
// findItem 依名稱找項目;找不到回 -1。
|
||||
func (d configDoc) findItem(name string) int {
|
||||
for i, it := range d.items {
|
||||
if it.name == name {
|
||||
return i
|
||||
}
|
||||
}
|
||||
return -1
|
||||
}
|
||||
|
||||
// fieldIndent 回傳項目內屬性行的縮排(項目縮排 + 兩格,tea 的格式)。
|
||||
func (d configDoc) fieldIndent() string {
|
||||
return d.itemIndent + " "
|
||||
}
|
||||
|
||||
// entryAt 取某個項目的完整欄位值。
|
||||
func (d configDoc) entryAt(it loginItemSpan) LoginEntry {
|
||||
var e LoginEntry
|
||||
apply := func(kv string) {
|
||||
key, value, ok := strings.Cut(kv, ":")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
key = strings.TrimSpace(key)
|
||||
value = unquoteYAML(strings.TrimSpace(value))
|
||||
switch key {
|
||||
case "name":
|
||||
e.Name = value
|
||||
case "url":
|
||||
e.URL = value
|
||||
case "token":
|
||||
e.Token = value
|
||||
case "default":
|
||||
e.Default = value == "true"
|
||||
case "user":
|
||||
e.User = value
|
||||
}
|
||||
}
|
||||
first := strings.TrimSpace(d.lines[it.start])
|
||||
if rest := strings.TrimSpace(strings.TrimPrefix(first, "-")); rest != "" {
|
||||
apply(rest)
|
||||
}
|
||||
for i := it.start + 1; i < it.end; i++ {
|
||||
if kv := strings.TrimSpace(d.lines[i]); kv != "" && !strings.HasPrefix(kv, "#") {
|
||||
apply(kv)
|
||||
}
|
||||
}
|
||||
return e
|
||||
}
|
||||
|
||||
// ListLogins 回傳組態內容中的所有登入(依檔案順序)。
|
||||
func ListLogins(data []byte) []LoginEntry {
|
||||
doc := parseConfigDoc(data)
|
||||
out := make([]LoginEntry, 0, len(doc.items))
|
||||
for _, it := range doc.items {
|
||||
out = append(out, doc.entryAt(it))
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// ListLoginsFile 讀取組態檔並列出登入;檔案不存在視為空清單(非錯誤)。
|
||||
func ListLoginsFile(path string) ([]LoginEntry, error) {
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
return ListLogins(data), nil
|
||||
}
|
||||
|
||||
// UpsertLogin 新增或更新名為 e.Name 的登入。
|
||||
//
|
||||
// 已存在則就地更新 url/token/user/default(保留其他欄位與行);不存在則
|
||||
// 依 tea 的欄位順序插入新項目。makeDefault 為 true 時將其設為預設並取消
|
||||
// 其他登入的預設旗標;新增後若成為唯一登入也自動成為預設。
|
||||
// 回傳新內容與最終生效的項目值。
|
||||
func UpsertLogin(data []byte, e LoginEntry, makeDefault bool) ([]byte, LoginEntry, error) {
|
||||
if strings.TrimSpace(e.Name) == "" {
|
||||
return nil, LoginEntry{}, fmt.Errorf("gitea: login name must not be empty")
|
||||
}
|
||||
if strings.TrimSpace(e.URL) == "" {
|
||||
return nil, LoginEntry{}, fmt.Errorf("gitea: login url must not be empty")
|
||||
}
|
||||
if e.Token == "" {
|
||||
return nil, LoginEntry{}, fmt.Errorf("gitea: login token must not be empty")
|
||||
}
|
||||
doc := parseConfigDoc(data)
|
||||
idx := doc.findItem(e.Name)
|
||||
if idx >= 0 {
|
||||
existing := doc.entryAt(doc.items[idx])
|
||||
wantDefault := existing.Default || makeDefault
|
||||
it := doc.items[idx]
|
||||
lines := doc.lines
|
||||
// 就地替換各欄位;缺少的欄位補在項目結尾。
|
||||
for _, kv := range []struct{ key, value string }{
|
||||
{"name", yamlScalar(e.Name)},
|
||||
{"url", yamlScalar(e.URL)},
|
||||
{"token", yamlScalar(e.Token)},
|
||||
{"user", yamlScalar(e.User)},
|
||||
{"default", strconv.FormatBool(wantDefault)},
|
||||
} {
|
||||
lines = setFieldInItem(lines, it, kv.key, kv.value, doc.fieldIndent())
|
||||
}
|
||||
out := joinLines(lines)
|
||||
if wantDefault {
|
||||
var err error
|
||||
out, err = SetDefaultLogin(out, e.Name)
|
||||
if err != nil {
|
||||
return nil, LoginEntry{}, err
|
||||
}
|
||||
}
|
||||
final := existing
|
||||
final.URL, final.Token, final.User = e.URL, e.Token, e.User
|
||||
final.Default = wantDefault
|
||||
return out, final, nil
|
||||
}
|
||||
|
||||
// 新增項目。
|
||||
wantDefault := makeDefault || len(doc.items) == 0
|
||||
block := newLoginBlock(doc.itemIndent, e.Name, e.URL, e.Token, wantDefault, e.User)
|
||||
var lines []string
|
||||
var insertAt int
|
||||
if doc.loginsHeader < 0 {
|
||||
if len(doc.lines) == 0 {
|
||||
lines = []string{"logins:"}
|
||||
} else {
|
||||
lines = append(slices.Clone(doc.lines), "", "logins:")
|
||||
}
|
||||
insertAt = len(lines)
|
||||
} else {
|
||||
lines = slices.Clone(doc.lines)
|
||||
// 「logins: []」是空清單的串流寫法,直接接項目會變無效 YAML;改回區段寫法。
|
||||
if len(doc.items) == 0 && strings.TrimSpace(lines[doc.loginsHeader]) == "logins: []" {
|
||||
lines[doc.loginsHeader] = "logins:"
|
||||
}
|
||||
if len(doc.items) > 0 {
|
||||
insertAt = doc.items[len(doc.items)-1].end
|
||||
} else {
|
||||
insertAt = doc.loginsHeader + 1
|
||||
}
|
||||
}
|
||||
out := append(slices.Clone(lines[:insertAt]), block...)
|
||||
out = append(out, lines[insertAt:]...)
|
||||
data2 := joinLines(out)
|
||||
if wantDefault {
|
||||
var err error
|
||||
data2, err = SetDefaultLogin(data2, e.Name)
|
||||
if err != nil {
|
||||
return nil, LoginEntry{}, err
|
||||
}
|
||||
}
|
||||
return data2, LoginEntry{
|
||||
Name: e.Name,
|
||||
URL: e.URL,
|
||||
Token: e.Token,
|
||||
Default: wantDefault,
|
||||
User: e.User,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// SetDefaultLogin 把名為 name 的登入設為預設,其餘一律取消預設。
|
||||
func SetDefaultLogin(data []byte, name string) ([]byte, error) {
|
||||
doc := parseConfigDoc(data)
|
||||
idx := doc.findItem(name)
|
||||
if idx < 0 {
|
||||
return nil, fmt.Errorf("gitea: login %q not found", name)
|
||||
}
|
||||
lines := doc.lines
|
||||
// 反序處理:前面的插入不影響後面已算好的行範圍,這裡的反序則保證
|
||||
// 後方項目的插入/修改不會推移前方項目的範圍。
|
||||
for i := len(doc.items) - 1; i >= 0; i-- {
|
||||
want := "false"
|
||||
if i == idx {
|
||||
want = "true"
|
||||
}
|
||||
lines = setFieldInItem(lines, doc.items[i], "default", want, doc.fieldIndent())
|
||||
}
|
||||
return joinLines(lines), nil
|
||||
}
|
||||
|
||||
// RemoveLogin 移除名為 name 的登入。若被移除者是預設且還有其他登入,
|
||||
// 剩下的第一筆自動遞補為預設。回傳新內容與遞補後的預設名稱(無則空字串)。
|
||||
func RemoveLogin(data []byte, name string) ([]byte, string, error) {
|
||||
doc := parseConfigDoc(data)
|
||||
idx := doc.findItem(name)
|
||||
if idx < 0 {
|
||||
return nil, "", fmt.Errorf("gitea: login %q not found", name)
|
||||
}
|
||||
removed := doc.entryAt(doc.items[idx])
|
||||
it := doc.items[idx]
|
||||
lines := slices.Delete(slices.Clone(doc.lines), it.start, it.end)
|
||||
out := joinLines(lines)
|
||||
promoted := ""
|
||||
if removed.Default {
|
||||
rest := ListLogins(out)
|
||||
if len(rest) > 0 {
|
||||
promoted = rest[0].Name
|
||||
var err error
|
||||
out, err = SetDefaultLogin(out, promoted)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
}
|
||||
}
|
||||
return out, promoted, nil
|
||||
}
|
||||
|
||||
// setFieldInItem 把項目內 key 的值換成 value,保留該行的原始縮排;
|
||||
// key 不存在時以 fieldIndent 插在項目結尾。回傳新的行切片。
|
||||
func setFieldInItem(lines []string, it loginItemSpan, key, value, fieldIndent string) []string {
|
||||
first := lines[it.start]
|
||||
trimmed := strings.TrimSpace(first)
|
||||
if rest := strings.TrimSpace(strings.TrimPrefix(trimmed, "-")); rest != "" {
|
||||
if k, _, ok := strings.Cut(rest, ":"); ok && strings.TrimSpace(k) == key {
|
||||
prefix := first[:strings.Index(first, "-")] + "- "
|
||||
lines[it.start] = prefix + key + ": " + value
|
||||
return lines
|
||||
}
|
||||
}
|
||||
for i := it.start + 1; i < it.end; i++ {
|
||||
t := strings.TrimSpace(lines[i])
|
||||
if k, _, ok := strings.Cut(t, ":"); ok && strings.TrimSpace(k) == key {
|
||||
indent := lines[i][:len(lines[i])-len(strings.TrimLeft(lines[i], " "))]
|
||||
lines[i] = indent + key + ": " + value
|
||||
return lines
|
||||
}
|
||||
}
|
||||
out := append(slices.Clone(lines[:it.end]), fieldIndent+key+": "+value)
|
||||
return append(out, lines[it.end:]...)
|
||||
}
|
||||
|
||||
// newLoginBlock 產生 tea 格式的新登入項目(欄位順序與 tea 實際寫出的一致)。
|
||||
func newLoginBlock(itemIndent, name, url, token string, isDefault bool, user string) []string {
|
||||
f := itemIndent + " "
|
||||
return []string{
|
||||
itemIndent + "- name: " + yamlScalar(name),
|
||||
f + "url: " + yamlScalar(url),
|
||||
f + "token: " + yamlScalar(token),
|
||||
f + "default: " + strconv.FormatBool(isDefault),
|
||||
f + `ssh_host: ""`,
|
||||
f + `ssh_key: ""`,
|
||||
f + "insecure: false",
|
||||
f + `ssh_certificate_principal: ""`,
|
||||
f + "ssh_agent: false",
|
||||
f + `ssh_key_agent_pub: ""`,
|
||||
f + "version_check: false",
|
||||
f + "user: " + yamlScalar(user),
|
||||
f + "created: 0",
|
||||
}
|
||||
}
|
||||
|
||||
// plainScalar 是可以不加引號直接寫出的 YAML 純量。
|
||||
var plainScalar = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._:/~+=@-]*$`)
|
||||
|
||||
// yamlScalar 把值寫成 YAML 純量:安全字元集直接輸出,其餘加雙引號。
|
||||
func yamlScalar(s string) string {
|
||||
if plainScalar.MatchString(s) {
|
||||
return s
|
||||
}
|
||||
return strconv.Quote(s)
|
||||
}
|
||||
|
||||
// HostOf 取 URL 的主機部分(連埠,小寫);解析失敗回空字串。
|
||||
// 供比對「同一站點」的登入時使用。
|
||||
func HostOf(rawURL string) string { return hostOf(rawURL) }
|
||||
|
||||
// splitLines 把內容拆成行(去掉行尾換行與 CR)。
|
||||
func splitLines(data []byte) []string {
|
||||
if len(data) == 0 {
|
||||
return nil
|
||||
}
|
||||
s := strings.TrimSuffix(string(data), "\n")
|
||||
if s == "" {
|
||||
return []string{""}
|
||||
}
|
||||
return strings.Split(s, "\n")
|
||||
}
|
||||
|
||||
// joinLines 把行組回內容,保證以單一換行結尾。
|
||||
func joinLines(lines []string) []byte {
|
||||
if len(lines) == 0 {
|
||||
return nil
|
||||
}
|
||||
s := strings.Join(lines, "\n")
|
||||
if !strings.HasSuffix(s, "\n") {
|
||||
s += "\n"
|
||||
}
|
||||
return []byte(s)
|
||||
}
|
||||
|
||||
// SaveTeaConfigFile 把組態內容原子寫入 path:先寫同目錄暫存檔(0600),
|
||||
// 再 rename 取代,避免半寫入狀態。必要時建立上層目錄(0700)。
|
||||
func SaveTeaConfigFile(path string, data []byte) error {
|
||||
dir := filepath.Dir(path)
|
||||
if err := os.MkdirAll(dir, 0o700); err != nil {
|
||||
return fmt.Errorf("gitea: create config dir: %w", err)
|
||||
}
|
||||
tmp, err := os.CreateTemp(dir, ".config-*.yml")
|
||||
if err != nil {
|
||||
return fmt.Errorf("gitea: create temp file: %w", err)
|
||||
}
|
||||
tmpName := tmp.Name()
|
||||
defer func() {
|
||||
if tmpName != "" {
|
||||
_ = os.Remove(tmpName)
|
||||
}
|
||||
}()
|
||||
if _, err := tmp.Write(data); err != nil {
|
||||
tmp.Close()
|
||||
return fmt.Errorf("gitea: write temp file: %w", err)
|
||||
}
|
||||
if err := tmp.Chmod(0o600); err != nil {
|
||||
tmp.Close()
|
||||
return fmt.Errorf("gitea: chmod temp file: %w", err)
|
||||
}
|
||||
if err := tmp.Close(); err != nil {
|
||||
return fmt.Errorf("gitea: close temp file: %w", err)
|
||||
}
|
||||
if err := os.Rename(tmpName, path); err != nil {
|
||||
return fmt.Errorf("gitea: rename config: %w", err)
|
||||
}
|
||||
tmpName = "" // 已改名成功,不必清理。
|
||||
return nil
|
||||
}
|
||||
Reference in New Issue
Block a user