mirror of
https://gitea.com/gitea/tea.git
synced 2026-10-26 19:11:25 +01:00
- **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>
354 lines
14 KiB
Go
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
|
|
}
|