mirror of
https://gitea.com/gitea/tea.git
synced 2026-10-26 19:11:25 +01:00
refactor: add settings, transport and alias packages (#1122)
Part 1/5 of the tea 1.0 rewrite. **Stack** (split from #1121; each PR targets `main` and builds, tests and lints on its own — the shipped CLI only changes in part 5): 1. #1122 Toolchain and foundation packages ← this PR 2. #1123 Credentials and Git 3. #1124 Terminal 4. #1125 Services 5. #1126 CLI wiring, docs, removal of the old tree ## Changes - **build:** require Go 1.27; bump gofumpt and golangci-lint to releases that can read Go 1.27 export data (own commit). - `internal/clierr`: typed errors and executable exit codes. - `internal/version`: build version and tags. - `internal/filelock`: moved unchanged from `modules/filelock`. - `internal/aliases`: user-defined command aliases. - `internal/transport`: the HTTP client the Gitea SDK runs on (part 4): token, Sudo and SSH-signature auth, retries for safe reads, connect/TLS/response-header timeouts, typed API errors that keep Gitea's message, and a redacted request log for `--debug`; plus raw requests for `tea api`, signed downloads that follow object-storage redirects, and log streaming. - `internal/settings`: split `config.yml` / `hosts.yml` with locked transactions, plus remote URL parsing. Nothing imports these packages yet. Note: `flake.nix` now asks for `go_1_27`; the pinned nixpkgs in `flake.lock` predates Go 1.27 and still needs a `nix flake update nixpkgs`. Reviewed-on: https://gitea.com/gitea/tea/pulls/1122 Reviewed-by: TheFox0x7 <95654+thefox0x7@noreply.gitea.com>
This commit is contained in:
1 parent
bcd62a1fb2
commit
2b218a5bae
29 files changed
+2541
-77
No files matched your search
@@ -0,0 +1,397 @@
|
||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
// Package settings persists preferences and instance metadata, never credentials.
|
||||
package settings
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/url"
|
||||
"os"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"gitea.dev/tea/internal/filelock"
|
||||
"github.com/adrg/xdg"
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
// Version identifies the split configuration schema.
|
||||
const Version = "1"
|
||||
|
||||
// LockTimeout bounds the wait for another process's settings update or token refresh.
|
||||
const LockTimeout = 30 * time.Second
|
||||
|
||||
// ErrLegacy prevents overwriting the old combined login configuration.
|
||||
var ErrLegacy = errors.New("legacy tea configuration: archive the old config.yml or select a separate TEA_CONFIG_DIR, then run tea auth login; existing files have not been changed")
|
||||
|
||||
// Authentication methods and credential stores recorded for accounts.
|
||||
const (
|
||||
AuthToken = "token"
|
||||
AuthOAuth = "oauth"
|
||||
AuthSSH = "ssh"
|
||||
AuthHTTPSign = "httpsign"
|
||||
|
||||
StoreKeyring = "keyring"
|
||||
StoreFile = "file"
|
||||
)
|
||||
|
||||
// Preferences contains global, non-secret command defaults.
|
||||
type Preferences struct {
|
||||
Version string `yaml:"version" json:"version"`
|
||||
DefaultHost string `yaml:"default_host,omitempty" json:"default_host"`
|
||||
GitProtocol string `yaml:"git_protocol,omitempty" json:"git_protocol"`
|
||||
Editor string `yaml:"editor,omitempty" json:"editor"`
|
||||
Prompt string `yaml:"prompt,omitempty" json:"prompt"`
|
||||
Pager string `yaml:"pager,omitempty" json:"pager"`
|
||||
Browser string `yaml:"browser,omitempty" json:"browser"`
|
||||
Color string `yaml:"color,omitempty" json:"color"`
|
||||
Remote string `yaml:"remote,omitempty" json:"remote"`
|
||||
Aliases map[string]string `yaml:"aliases,omitempty" json:"aliases"`
|
||||
}
|
||||
|
||||
// Account contains account-specific authentication metadata, never token values.
|
||||
type Account struct {
|
||||
AuthMethod string `yaml:"auth_method" json:"auth_method"`
|
||||
CredentialStore string `yaml:"credential_store,omitempty" json:"credential_store,omitempty"`
|
||||
SSHKey string `yaml:"ssh_key,omitempty" json:"ssh_key,omitempty"`
|
||||
SSHAgent bool `yaml:"ssh_agent,omitempty" json:"ssh_agent,omitempty"`
|
||||
SSHCertPrincipal string `yaml:"ssh_certificate_principal,omitempty" json:"ssh_certificate_principal,omitempty"`
|
||||
SSHKeyFingerprint string `yaml:"ssh_key_fingerprint,omitempty" json:"ssh_key_fingerprint,omitempty"`
|
||||
}
|
||||
|
||||
// Tokenless reports whether the account signs requests instead of storing a token.
|
||||
func (a Account) Tokenless() bool {
|
||||
return a.AuthMethod == AuthSSH || a.AuthMethod == AuthHTTPSign
|
||||
}
|
||||
|
||||
// Host contains shared connection settings and the active account.
|
||||
type Host struct {
|
||||
User string `yaml:"user,omitempty" json:"user"`
|
||||
SSHHost string `yaml:"ssh_host,omitempty" json:"ssh_host,omitempty"`
|
||||
Insecure bool `yaml:"insecure,omitempty" json:"insecure"`
|
||||
GitProtocol string `yaml:"git_protocol,omitempty" json:"git_protocol,omitempty"`
|
||||
OAuthClientID string `yaml:"oauth_client_id,omitempty" json:"oauth_client_id,omitempty"`
|
||||
Users map[string]Account `yaml:"users" json:"users"`
|
||||
}
|
||||
|
||||
// Hosts stores instances keyed by their normalized installation URLs.
|
||||
type Hosts struct {
|
||||
Version string `yaml:"version" json:"version"`
|
||||
Hosts map[string]Host `yaml:"hosts" json:"hosts"`
|
||||
}
|
||||
|
||||
// Snapshot is one independently loaded configuration generation.
|
||||
type Snapshot struct {
|
||||
Preferences Preferences
|
||||
Hosts Hosts
|
||||
// Configured distinguishes explicitly stored settings from built-in defaults.
|
||||
Configured map[string]bool
|
||||
}
|
||||
|
||||
// Store coordinates configuration reads and locked atomic updates.
|
||||
type Store struct {
|
||||
Dir string
|
||||
LegacyPath string
|
||||
mu sync.Mutex
|
||||
}
|
||||
|
||||
// New selects an explicit directory or the platform configuration directory.
|
||||
func New(dir string) *Store {
|
||||
legacy := ""
|
||||
if dir == "" {
|
||||
dir = filepath.Join(xdg.ConfigHome, "tea")
|
||||
legacy = filepath.Join(xdg.Home, ".tea", "tea.yml")
|
||||
}
|
||||
return &Store{Dir: dir, LegacyPath: legacy}
|
||||
}
|
||||
|
||||
// Defaults returns a fresh preferences value with built-in settings.
|
||||
func Defaults() Preferences {
|
||||
return Preferences{Version: Version, GitProtocol: "https", Prompt: "enabled", Color: "auto", Aliases: map[string]string{}}
|
||||
}
|
||||
|
||||
// stored omits built-in defaults so that only explicit settings are persisted.
|
||||
func (p Preferences) stored() Preferences {
|
||||
defaults := Defaults()
|
||||
if p.GitProtocol == defaults.GitProtocol {
|
||||
p.GitProtocol = ""
|
||||
}
|
||||
if p.Prompt == defaults.Prompt {
|
||||
p.Prompt = ""
|
||||
}
|
||||
if p.Color == defaults.Color {
|
||||
p.Color = ""
|
||||
}
|
||||
return p
|
||||
}
|
||||
|
||||
// NormalizeHost retains ports and installation paths as part of instance identity.
|
||||
func NormalizeHost(raw string) (string, error) {
|
||||
u, err := url.Parse(raw)
|
||||
if err != nil || u.Hostname() == "" || (u.Scheme != "https" && u.Scheme != "http") || u.User != nil || u.RawQuery != "" || u.Fragment != "" || u.Opaque != "" {
|
||||
return "", fmt.Errorf("invalid instance URL %q: use http(s)://host[:port][/base-path]", raw)
|
||||
}
|
||||
u.Scheme = strings.ToLower(u.Scheme)
|
||||
u.Host = strings.ToLower(u.Host)
|
||||
if (u.Scheme == "https" && u.Port() == "443") || (u.Scheme == "http" && u.Port() == "80") {
|
||||
u.Host = u.Hostname()
|
||||
if strings.Contains(u.Host, ":") {
|
||||
u.Host = "[" + u.Host + "]"
|
||||
}
|
||||
}
|
||||
// Encoded separators cannot identify an installation unambiguously.
|
||||
if strings.Contains(strings.ToLower(u.EscapedPath()), "%2f") || strings.Contains(strings.ToLower(u.EscapedPath()), "%5c") || strings.Contains(u.Path, "\\") {
|
||||
return "", fmt.Errorf("invalid instance path in %q", raw)
|
||||
}
|
||||
u.Path = strings.TrimSuffix(path.Clean("/"+u.Path), "/")
|
||||
u.RawPath = ""
|
||||
return u.String(), nil
|
||||
}
|
||||
|
||||
func decode(file string, data []byte, target any) error {
|
||||
d := yaml.NewDecoder(bytes.NewReader(data))
|
||||
d.KnownFields(true)
|
||||
if err := d.Decode(target); err != nil {
|
||||
return fmt.Errorf("parse %s: %w", file, err)
|
||||
}
|
||||
var extra any
|
||||
if err := d.Decode(&extra); err != io.EOF {
|
||||
return fmt.Errorf("%s must contain exactly one YAML document", file)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Load reads both configuration files without creating paths or mutating globals.
|
||||
func (s *Store) Load() (Snapshot, error) {
|
||||
snapshot := Snapshot{Preferences: Defaults(), Hosts: Hosts{Version: Version, Hosts: map[string]Host{}}, Configured: map[string]bool{}}
|
||||
configPath := filepath.Join(s.Dir, "config.yml")
|
||||
data, err := os.ReadFile(configPath)
|
||||
if err != nil && !errors.Is(err, os.ErrNotExist) {
|
||||
return snapshot, err
|
||||
}
|
||||
if errors.Is(err, os.ErrNotExist) && s.LegacyPath != "" {
|
||||
if _, legacyErr := os.Stat(s.LegacyPath); legacyErr == nil {
|
||||
return snapshot, ErrLegacy
|
||||
} else if !errors.Is(legacyErr, os.ErrNotExist) {
|
||||
return snapshot, legacyErr
|
||||
}
|
||||
}
|
||||
if err == nil {
|
||||
var keys map[string]yaml.Node
|
||||
if err := yaml.Unmarshal(data, &keys); err != nil {
|
||||
return snapshot, fmt.Errorf("parse %s: %w", configPath, err)
|
||||
}
|
||||
if _, ok := keys["logins"]; ok {
|
||||
return snapshot, ErrLegacy
|
||||
}
|
||||
if _, ok := keys["preferences"]; ok {
|
||||
return snapshot, ErrLegacy
|
||||
}
|
||||
version, ok := keys["version"]
|
||||
if !ok || version.Value != Version {
|
||||
return snapshot, fmt.Errorf("unsupported configuration version in %s", configPath)
|
||||
}
|
||||
for key := range keys {
|
||||
snapshot.Configured[key] = true
|
||||
}
|
||||
if err := decode(configPath, data, &snapshot.Preferences); err != nil {
|
||||
return snapshot, err
|
||||
}
|
||||
}
|
||||
hostsPath := filepath.Join(s.Dir, "hosts.yml")
|
||||
hostData, err := os.ReadFile(hostsPath)
|
||||
if err != nil && !errors.Is(err, os.ErrNotExist) {
|
||||
return snapshot, fmt.Errorf("read %s: %w", hostsPath, err)
|
||||
}
|
||||
if err == nil {
|
||||
if err := decode(hostsPath, hostData, &snapshot.Hosts); err != nil {
|
||||
return snapshot, err
|
||||
}
|
||||
var keys map[string]yaml.Node
|
||||
if err := yaml.Unmarshal(hostData, &keys); err != nil {
|
||||
return snapshot, err
|
||||
}
|
||||
version, ok := keys["version"]
|
||||
if !ok || version.Value != Version {
|
||||
return snapshot, errors.New("unsupported hosts.yml version")
|
||||
}
|
||||
}
|
||||
if snapshot.Hosts.Hosts == nil {
|
||||
snapshot.Hosts.Hosts = map[string]Host{}
|
||||
}
|
||||
if snapshot.Preferences.Aliases == nil {
|
||||
snapshot.Preferences.Aliases = map[string]string{}
|
||||
}
|
||||
return snapshot, snapshot.Validate()
|
||||
}
|
||||
|
||||
// Validate rejects unsupported versions and inconsistent instance/account settings.
|
||||
func (s Snapshot) Validate() error {
|
||||
p := s.Preferences
|
||||
if p.Version != Version || s.Hosts.Version != Version {
|
||||
return errors.New("unsupported settings version")
|
||||
}
|
||||
if p.GitProtocol != "https" && p.GitProtocol != "ssh" {
|
||||
return errors.New("git_protocol must be https or ssh")
|
||||
}
|
||||
if p.Prompt != "enabled" && p.Prompt != "disabled" {
|
||||
return errors.New("prompt must be enabled or disabled")
|
||||
}
|
||||
if p.Color != "auto" && p.Color != "always" && p.Color != "never" {
|
||||
return errors.New("color must be auto, always, or never")
|
||||
}
|
||||
if p.DefaultHost != "" {
|
||||
normalized, err := NormalizeHost(p.DefaultHost)
|
||||
if err != nil || normalized != p.DefaultHost {
|
||||
return errors.New("default_host must be a normalized instance URL")
|
||||
}
|
||||
}
|
||||
for instance, host := range s.Hosts.Hosts {
|
||||
normalized, err := NormalizeHost(instance)
|
||||
if err != nil || normalized != instance {
|
||||
return fmt.Errorf("instance %q must be a normalized URL", instance)
|
||||
}
|
||||
if host.SSHHost != "" {
|
||||
if _, err := SSHEndpoint(host.SSHHost); err != nil {
|
||||
return fmt.Errorf("invalid ssh_host for %s: %w", instance, err)
|
||||
}
|
||||
}
|
||||
if host.GitProtocol != "" && host.GitProtocol != "https" && host.GitProtocol != "ssh" {
|
||||
return fmt.Errorf("invalid git_protocol for %s", instance)
|
||||
}
|
||||
if host.User != "" {
|
||||
if _, exists := host.Users[host.User]; !exists {
|
||||
return fmt.Errorf("active user %q is missing on %s", host.User, instance)
|
||||
}
|
||||
}
|
||||
for name, account := range host.Users {
|
||||
if strings.ContainsAny(account.SSHKey+account.SSHCertPrincipal+account.SSHKeyFingerprint, "\x00\r\n") {
|
||||
return errors.New("account SSH settings cannot contain NUL or line breaks")
|
||||
}
|
||||
if strings.TrimSpace(name) == "" {
|
||||
return errors.New("account name cannot be empty")
|
||||
}
|
||||
if account.AuthMethod != AuthToken && account.AuthMethod != AuthOAuth && !account.Tokenless() {
|
||||
return fmt.Errorf("invalid authentication method for %s on %s", name, instance)
|
||||
}
|
||||
if account.Tokenless() {
|
||||
if account.CredentialStore != "" {
|
||||
return fmt.Errorf("SSH and HTTP signature accounts cannot use a credential store for %s on %s", name, instance)
|
||||
}
|
||||
} else if account.CredentialStore != StoreKeyring && account.CredentialStore != StoreFile {
|
||||
return fmt.Errorf("invalid credential store for %s on %s", name, instance)
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Target selects which settings file an update publishes.
|
||||
type Target int
|
||||
|
||||
const (
|
||||
// TargetPreferences publishes config.yml, the global non-secret defaults.
|
||||
TargetPreferences Target = iota
|
||||
// TargetHosts publishes hosts.yml, the instance and account metadata.
|
||||
TargetHosts
|
||||
)
|
||||
|
||||
// Update serializes writers, reloads current settings, and changes exactly the target file.
|
||||
// Cross-file transactions are not exposed.
|
||||
func (s *Store) Update(target Target, update func(*Snapshot) error) error {
|
||||
return s.Transaction(target, func(snapshot *Snapshot) (func() error, error) {
|
||||
return nil, update(snapshot)
|
||||
})
|
||||
}
|
||||
|
||||
// Transaction runs a compensating action under the lock if publishing the target file fails.
|
||||
// It is used to keep credential storage and account metadata consistent.
|
||||
func (s *Store) Transaction(target Target, update func(*Snapshot) (func() error, error)) error {
|
||||
return s.Locked(func(snapshot Snapshot) error {
|
||||
unpublished := func() ([]byte, error) {
|
||||
if target == TargetHosts {
|
||||
return yaml.Marshal(snapshot.Preferences)
|
||||
}
|
||||
return yaml.Marshal(snapshot.Hosts)
|
||||
}
|
||||
before, err := unpublished()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
rollback, err := update(&snapshot)
|
||||
undo := func(cause error) error {
|
||||
if cause != nil && rollback != nil {
|
||||
return errors.Join(cause, rollback())
|
||||
}
|
||||
return cause
|
||||
}
|
||||
if err != nil {
|
||||
return undo(err)
|
||||
}
|
||||
if after, err := unpublished(); err != nil || !bytes.Equal(before, after) {
|
||||
return undo(errors.Join(errors.New("settings update changed a file it does not write"), err))
|
||||
}
|
||||
if err := snapshot.Validate(); err != nil {
|
||||
return undo(err)
|
||||
}
|
||||
filename := "config.yml"
|
||||
var value any = snapshot.Preferences.stored()
|
||||
if target == TargetHosts {
|
||||
filename = "hosts.yml"
|
||||
value = snapshot.Hosts
|
||||
}
|
||||
data, err := yaml.Marshal(value)
|
||||
if err != nil {
|
||||
return undo(err)
|
||||
}
|
||||
return undo(AtomicWrite(filepath.Join(s.Dir, filename), data))
|
||||
})
|
||||
}
|
||||
|
||||
// Locked coordinates account changes and token refresh without rewriting settings.
|
||||
func (s *Store) Locked(fn func(Snapshot) error) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if _, err := s.Load(); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.MkdirAll(s.Dir, 0o700); err != nil {
|
||||
return err
|
||||
}
|
||||
return filelock.New(filepath.Join(s.Dir, "settings.lock"), LockTimeout).WithLock(func() error {
|
||||
snapshot, err := s.Load()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return fn(snapshot)
|
||||
})
|
||||
}
|
||||
|
||||
// AtomicWrite publishes a complete private file after flushing its contents.
|
||||
func AtomicWrite(filename string, data []byte) error {
|
||||
temp, err := os.CreateTemp(filepath.Dir(filename), ".tea-*")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer func() { _ = temp.Close(); _ = os.Remove(temp.Name()) }()
|
||||
if err := temp.Chmod(0o600); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := temp.Write(data); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := temp.Sync(); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := temp.Close(); err != nil {
|
||||
return err
|
||||
}
|
||||
return os.Rename(temp.Name(), filename)
|
||||
}
|
||||
Reference in new issue
Block a user