Files
gitea-tea/internal/app/config.go
T
bircni f02245449b feat!: rebuild tea on Cobra with explicit noun/verb commands (#1126)
- **switch tea to the Cobra command tree:** `internal/app` wires the packages from parts 1–4 into explicit noun/verb commands with `--repo`, `--json` / `--jq`, `--sudo` and `--debug`; `main.go` points at it. Build, release and docs tooling (`tools/cli-docs`, Makefile, goreleaser, snap, CI) and README / CONTRIBUTING are updated.
- **remove the urfave/cli command tree:** deletes `cmd/`, `modules/`, the old integration tests, contrib completion scripts and the generated `docs/CLI.md`, and drops their dependencies. This commit only deletes.

## Breaking changes

This is a breaking release. It requires Gitea 28 or newer; configuration moves to separate `config.yml` and `hosts.yml` files and requires fresh authentication, and command names, aliases, flags and output formats change. `docs/migration-1.0.md` maps every old command, flag and environment variable to its replacement.

## Issues

fixes #1118
fixes #1116
fixes #1115
fixes #1094
fixes #1091
fixes #1073
fixes #1052
fixes #1032
fixes #1023
fixes #966
fixes #908
fixes #833
fixes #831
fixes #817
fixes #804
fixes #779
fixes #770
fixes #737
fixes #697
fixes #726
fixes #677
fixes #672
fixes #633
fixes #632
fixes #560
fixes #556
fixes #523
fixes #516
fixes #510
fixes #504
fixes #500
fixes #482
fixes #406
fixes #403
fixes #373
fixes #351
fixes #344
fixes #326
fixes #325
fixes #323
fixes #246
fixes #240
fixes #232
fixes #197
fixes #172
fixes #141
fixes #69
fixes #1110
fixes #569
fixes #480
fixes #666
fixes #670
fixes #457
fixes #643
fixes #498
fixes #490
fixes #1069
fixes #834
fixes #602
fixes #328
fixes #417
fixes #571
fixes #1029
fixes #489
fixes #1050
fixes #162
fixes #430
fixes #1087
fixes #683

Reviewed-on: https://gitea.com/gitea/tea/pulls/1126
Reviewed-by: TheFox0x7 <95654+thefox0x7@noreply.gitea.com>
2026-10-11 17:23:06 +00:00

354 lines
14 KiB
Go

// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package app
import (
"errors"
"fmt"
"path/filepath"
"sort"
"strings"
"gitea.dev/tea/internal/aliases"
"gitea.dev/tea/internal/clierr"
"gitea.dev/tea/internal/settings"
"gitea.dev/tea/internal/terminal"
"github.com/spf13/cobra"
)
func (a *application) effectivePreferences(snapshot settings.Snapshot) (settings.Preferences, map[string]string, error) {
preferences := snapshot.Preferences
sources := map[string]string{}
for key, configured := range snapshot.Configured {
if configured {
sources[key] = "config.yml"
}
}
host, source := preferences.DefaultHost, sources["default_host"]
if a.options.host != "" {
host, source = a.options.host, "--host"
} else {
for _, key := range []string{"TEA_HOST", "GITEA_INSTANCE_URL"} {
if value := a.deps.Getenv(key); value != "" {
host, source = value, key
break
}
}
}
if host == "" && len(snapshot.Hosts.Hosts) == 1 {
for name := range snapshot.Hosts.Hosts {
host, source = name, "sole configured instance"
}
}
if host != "" {
normalized, err := settings.NormalizeHost(host)
if err != nil {
return preferences, nil, clierr.Wrap(clierr.Usage, err)
}
preferences.DefaultHost = normalized
sources["default_host"] = source
if protocol := snapshot.Hosts.Hosts[normalized].GitProtocol; protocol != "" {
preferences.GitProtocol = protocol
sources["git_protocol"] = "hosts.yml " + normalized
}
}
if a.options.remote != "" {
preferences.Remote = a.options.remote
sources["remote"] = "--remote"
}
if value := strings.TrimSpace(a.deps.Getenv("TEA_PAGER")); value != "" {
preferences.Pager = value
sources["pager"] = "TEA_PAGER"
} else if value := strings.TrimSpace(a.deps.Getenv("PAGER")); value != "" && sources["pager"] == "" {
preferences.Pager = value
sources["pager"] = "PAGER"
}
if value := strings.TrimSpace(a.deps.Getenv("TEA_COLOR")); value != "" {
preferences.Color = value
sources["color"] = "TEA_COLOR"
}
if value := a.deps.Getenv("TEA_EDITOR"); value != "" {
preferences.Editor = value
sources["editor"] = "TEA_EDITOR"
} else if value := a.deps.Getenv("VISUAL"); value != "" && sources["editor"] == "" {
preferences.Editor = value
sources["editor"] = "VISUAL"
} else if value := a.deps.Getenv("EDITOR"); value != "" && sources["editor"] == "" {
preferences.Editor = value
sources["editor"] = "EDITOR"
}
if value := a.deps.Getenv("BROWSER"); value != "" && sources["browser"] == "" {
preferences.Browser = value
sources["browser"] = "BROWSER"
}
return preferences, sources, nil
}
func (a *application) configCommand() *cobra.Command {
group := &cobra.Command{Use: "config", Short: "Manage preferences", Long: `Manage preferences.
Settings live in config.yml and hosts.yml in the configuration directory
($TEA_CONFIG_DIR, or tea under the XDG config directory); tea config path prints both.
config.yml preferences (tea config set <key> <value>):
default_host instance URL used when no --host, environment or repository selects one
git_protocol https or ssh for clone and checkout remotes
editor command used to edit bodies
prompt enabled or disabled
pager command used to page human output
browser command used to open web pages; never disables opening
color auto, always or never
remote Git remote used for repository discovery
aliases command aliases; manage them with tea alias
hosts.yml per-instance settings (tea config set --host <url> <key> <value>):
user active account, changed only by tea auth switch
ssh_host SSH host[:port] when it differs from the web host
insecure true skips TLS certificate verification
git_protocol https or ssh, overriding config.yml for this instance
oauth_client_id OAuth application used by tea auth login
Per-account SSH settings (tea config set --host <url> --user <name> <key> <value>):
ssh_key, ssh_agent, ssh_certificate_principal, ssh_key_fingerprint
Environment overrides:
TEA_CONFIG_DIR configuration directory
TEA_HOST, GITEA_INSTANCE_URL instance, overriding default_host
GITEA_TOKEN access token for that instance instead of stored credentials
GITEA_INSTANCE_INSECURE with GITEA_TOKEN, skip TLS verification when true
GITEA_INSTANCE_SSH_HOST with GITEA_TOKEN, SSH host that identifies the instance's remotes
TEA_USER account on the selected instance
TEA_REPO repository as owner/name or URL
TEA_EDITOR editor, overriding config.yml
TEA_PAGER pager, overriding config.yml
TEA_COLOR color preference; NO_COLOR disables automatic color
VISUAL, EDITOR, PAGER, BROWSER editor (VISUAL first), pager and browser when config.yml sets none`}
group.AddCommand(&cobra.Command{Use: "path", Short: "Show configuration file paths", Annotations: textOutput("config path prints text"), Args: argsExact(0), RunE: func(cmd *cobra.Command, _ []string) error {
_, err := fmt.Fprintf(cmd.OutOrStdout(), "config\t%s\nhosts\t%s\n", filepath.Join(a.deps.Settings.Dir, "config.yml"), filepath.Join(a.deps.Settings.Dir, "hosts.yml"))
return err
}})
for _, name := range []string{"get", "list"} {
var source bool
command := &cobra.Command{Use: name, Short: "Show effective preferences"}
command.Flags().BoolVar(&source, "show-source", false, "Show the source of each effective value")
if name == "get" {
command.Use = "get <key>"
command.Args = argsExact(1)
} else {
command.Args = argsExact(0)
}
command.RunE = func(cmd *cobra.Command, args []string) error {
if a.options.user != "" && a.options.host == "" {
return clierr.Wrap(clierr.Usage, errors.New("account settings require both --host and --user"))
}
snapshot, err := a.deps.Settings.Load()
if err != nil {
return err
}
preferences, sources, err := a.effectivePreferences(snapshot)
if err != nil {
return err
}
if _, exists := snapshot.Hosts.Hosts[preferences.DefaultHost]; a.options.user != "" && !exists {
return clierr.Wrap(clierr.Usage, errors.New("instance is not configured; run tea auth login first"))
}
values := map[string]any{
"version": preferences.Version, "default_host": preferences.DefaultHost,
"git_protocol": preferences.GitProtocol, "editor": preferences.Editor,
"prompt": preferences.Prompt, "pager": preferences.Pager,
"browser": preferences.Browser, "color": preferences.Color,
"remote": preferences.Remote,
}
if host, exists := snapshot.Hosts.Hosts[preferences.DefaultHost]; a.options.host != "" && exists {
for key, value := range map[string]any{"ssh_host": host.SSHHost, "insecure": host.Insecure, "oauth_client_id": host.OAuthClientID} {
values[key] = value
sources[key] = "hosts.yml " + preferences.DefaultHost
}
if a.options.user != "" {
user, exists := matchAccount(host.Users, a.options.user)
if !exists {
return clierr.Wrap(clierr.Usage, errors.New("account is not configured on the selected instance"))
}
account := host.Users[user]
for key, value := range map[string]any{"ssh_key": account.SSHKey, "ssh_agent": account.SSHAgent, "ssh_certificate_principal": account.SSHCertPrincipal, "ssh_key_fingerprint": account.SSHKeyFingerprint} {
values[key] = value
sources[key] = "hosts.yml " + preferences.DefaultHost + " user " + user
}
}
}
if len(args) > 0 {
value, ok := values[args[0]]
if !ok {
return clierr.Wrap(clierr.Usage, fmt.Errorf("unknown preference %q", args[0]))
}
values = map[string]any{args[0]: value}
}
if a.options.json != "" {
if source {
results := map[string]any{}
for key, value := range values {
origin := sources[key]
if origin == "" {
origin = "default"
}
results[key] = struct {
Value any `json:"value"`
Source string `json:"source"`
}{value, origin}
}
return a.writeJSON(cmd, results)
}
return a.writeJSON(cmd, values)
}
keys := make([]string, 0, len(values))
for key := range values {
keys = append(keys, key)
}
sort.Strings(keys)
for _, key := range keys {
suffix := ""
if source {
origin := "default"
if sources[key] != "" {
origin = sources[key]
}
suffix = "\t" + origin
}
if _, err := fmt.Fprintf(cmd.OutOrStdout(), "%s\t%v%s\n", key, values[key], suffix); err != nil {
return err
}
}
return nil
}
group.AddCommand(command)
}
group.AddCommand(&cobra.Command{Use: "set <key> <value>", Short: "Set a preference, or an instance setting with --host", Annotations: textOutput("config set does not return a resource"), Args: argsExact(2), RunE: func(_ *cobra.Command, args []string) error {
if a.options.user != "" && a.options.host == "" {
return clierr.Wrap(clierr.Usage, errors.New("account settings require both --host and --user"))
}
if a.options.host != "" {
instance, err := settings.NormalizeHost(a.options.host)
if err != nil {
return clierr.Wrap(clierr.Usage, err)
}
return a.deps.Settings.Update(settings.TargetHosts, func(s *settings.Snapshot) error {
host, exists := s.Hosts.Hosts[instance]
if !exists {
return clierr.Wrap(clierr.Usage, errors.New("instance is not configured; run tea auth login first"))
}
if a.options.user != "" {
user, exists := matchAccount(host.Users, a.options.user)
if !exists {
return clierr.Wrap(clierr.Usage, errors.New("account is not configured on the selected instance"))
}
account := host.Users[user]
switch args[0] {
case "ssh_key":
account.SSHKey = args[1]
case "ssh_agent":
if args[1] != "true" && args[1] != "false" {
return clierr.Wrap(clierr.Usage, errors.New("ssh_agent must be true or false"))
}
account.SSHAgent = args[1] == "true"
case "ssh_certificate_principal":
account.SSHCertPrincipal = args[1]
case "ssh_key_fingerprint":
account.SSHKeyFingerprint = args[1]
default:
return clierr.Wrap(clierr.Usage, errors.New("supported account settings: ssh_key, ssh_agent, ssh_certificate_principal, ssh_key_fingerprint"))
}
host.Users[user] = account
s.Hosts.Hosts[instance] = host
return clierr.Wrap(clierr.Usage, s.Validate())
}
switch args[0] {
case "git_protocol":
host.GitProtocol = args[1]
case "ssh_host":
host.SSHHost = args[1]
case "insecure":
if args[1] != "true" && args[1] != "false" {
return clierr.Wrap(clierr.Usage, errors.New("insecure must be true or false"))
}
host.Insecure = args[1] == "true"
case "oauth_client_id":
host.OAuthClientID = args[1]
default:
return clierr.Wrap(clierr.Usage, fmt.Errorf("preference %q cannot be set per instance; supported keys: git_protocol, ssh_host, insecure, oauth_client_id", args[0]))
}
s.Hosts.Hosts[instance] = host
return clierr.Wrap(clierr.Usage, s.Validate())
})
}
return a.deps.Settings.Update(settings.TargetPreferences, func(s *settings.Snapshot) error {
p := &s.Preferences
switch args[0] {
case "default_host":
if args[1] == "" {
p.DefaultHost = ""
break
}
normalized, err := settings.NormalizeHost(args[1])
if err != nil {
return clierr.Wrap(clierr.Usage, err)
}
p.DefaultHost = normalized
case "git_protocol":
p.GitProtocol = args[1]
case "editor":
p.Editor = args[1]
case "prompt":
p.Prompt = args[1]
case "pager":
p.Pager = args[1]
case "browser":
p.Browser = args[1]
case "color":
p.Color = args[1]
case "remote":
p.Remote = args[1]
default:
return clierr.Wrap(clierr.Usage, fmt.Errorf("unknown or immutable preference %q", args[0]))
}
return clierr.Wrap(clierr.Usage, s.Validate())
})
}})
return group
}
func (a *application) aliasCommand() *cobra.Command {
group := &cobra.Command{Use: "alias", Short: "Manage shortcuts to built-in commands"}
group.AddCommand(&cobra.Command{Use: "set <name> <expansion>", Short: "Define a command alias", Annotations: textOutput("alias set does not return a resource"), Args: argsExact(2), RunE: func(cmd *cobra.Command, args []string) error {
return a.deps.Settings.Update(settings.TargetPreferences, func(s *settings.Snapshot) error {
s.Preferences.Aliases[args[0]] = args[1]
return clierr.Wrap(clierr.Usage, aliases.Check(args[0], s.Preferences.Aliases, builtins(cmd.Root())))
})
}})
group.AddCommand(&cobra.Command{Use: "delete <name>", Short: "Remove a command alias", Annotations: textOutput("alias deletion does not return a resource"), Args: argsExact(1), RunE: func(cmd *cobra.Command, args []string) error {
return a.deps.Settings.Update(settings.TargetPreferences, func(s *settings.Snapshot) error {
if _, ok := s.Preferences.Aliases[args[0]]; !ok {
return clierr.Wrap(clierr.Usage, errors.New("alias does not exist"))
}
delete(s.Preferences.Aliases, args[0])
return nil
})
}})
group.AddCommand(&cobra.Command{Use: "list", Short: "List command aliases", Args: argsExact(0), RunE: func(cmd *cobra.Command, _ []string) error {
s, err := a.deps.Settings.Load()
if err != nil {
return err
}
if a.options.json != "" {
return a.writeJSON(cmd, s.Preferences.Aliases)
}
names := make([]string, 0, len(s.Preferences.Aliases))
for name := range s.Preferences.Aliases {
names = append(names, name)
}
sort.Strings(names)
return a.writeHuman(cmd, table(names, func(name string) []terminal.Cell { return terminal.Row(name, s.Preferences.Aliases[name]) }, a.colorEnabled()))
}})
return group
}