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:
bircni committed 2026-10-11 17:06:38 +00:00
1 parent bcd62a1fb2
commit 2b218a5bae
29 files changed
+2541 -77

No files matched your search

+397
View File
@@ -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)
}