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>
This commit is contained in:
bircni committed 2026-10-11 17:23:06 +00:00
1 parent 12e415a7a7
commit f02245449b
456 files changed
+18892 -34015

No files matched your search

+362
View File
@@ -0,0 +1,362 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package app
import (
"context"
"errors"
"fmt"
"io"
"os"
"sort"
"strings"
"time"
"gitea.dev/tea/internal/auth"
"gitea.dev/tea/internal/clierr"
"gitea.dev/tea/internal/services"
"gitea.dev/tea/internal/settings"
"gitea.dev/tea/internal/terminal"
"github.com/spf13/cobra"
)
// verifyAccount checks that a stored credential still authenticates as its account.
func (a *application) verifyAccount(ctx context.Context, policy terminal.Policy, instance, user string, host settings.Host, account settings.Account, token string) error {
client := a.deps.Client(instance, token, host.Insecure)
if account.Tokenless() {
signer, err := auth.SignerForAccount(account, a.sshPassphrase(ctx, policy, account.SSHKey))
if err != nil {
return err
}
client.Signer = signer
}
ctx, cancel := context.WithTimeout(ctx, 10*time.Second)
defer cancel()
actual, err := services.CurrentAccount(ctx, client)
if err != nil {
return err
}
if !strings.EqualFold(actual, user) {
return errors.New("credential authenticates a different account")
}
return nil
}
// sshPassphrase prompts for the passphrase of an encrypted SSH key, or returns nil when prompting is not allowed.
func (a *application) sshPassphrase(ctx context.Context, policy terminal.Policy, keyPath string) func() ([]byte, error) {
if a.deps.Prompter == nil || !policy.CanPrompt(a.deps.Streams) {
return nil
}
return func() ([]byte, error) {
passphrase, err := a.deps.Prompter.SecretInput(ctx, "Passphrase for "+keyPath)
return []byte(passphrase), err
}
}
// authStatusRow is one configured account reported by auth status.
type authStatusRow struct {
Host string `json:"host"`
User string `json:"user"`
Active bool `json:"active"`
Store string `json:"credential_store"`
Available bool `json:"credential_available"`
ExpiresAt *time.Time `json:"expires_at"`
Verified *bool `json:"verified"`
Error string `json:"error"`
}
func (a *application) authCommand() *cobra.Command {
group := &cobra.Command{Use: "auth", Short: "Manage instance accounts and credentials"}
group.AddCommand(a.gitCredentialCommand(), a.setupGitCommand())
var withToken bool
var backend string
var oauth bool
var clientID string
var sshKey string
var sshAgentPrincipal string
var sshAgentKey string
var insecure bool
login := &cobra.Command{Use: "login", Short: "Authenticate with a token, OAuth, or SSH HTTP signature", Annotations: textOutput("login does not return a resource"), Args: argsExact(0), RunE: func(cmd *cobra.Command, _ []string) error {
methods := 0
for _, selected := range []bool{withToken, oauth, sshKey != "", sshAgentPrincipal != "", sshAgentKey != ""} {
if selected {
methods++
}
}
if methods != 1 {
return clierr.Wrap(clierr.Usage, errors.New("select exactly one of --with-token, --oauth, --ssh-key, --ssh-agent-principal, or --ssh-agent-key"))
}
if clientID != "" && !oauth {
return clierr.Wrap(clierr.Usage, errors.New("--client-id requires --oauth"))
}
snapshot, err := a.deps.Settings.Load()
if err != nil {
return err
}
instance := a.options.host
if instance == "" {
instance = a.deps.Getenv("TEA_HOST")
}
if instance == "" {
instance = a.deps.Getenv("GITEA_INSTANCE_URL")
}
prompted, err := a.require(cmd, &instance, "host")
if err != nil {
return err
}
instance, err = settings.NormalizeHost(instance)
if err != nil {
return clierr.Wrap(clierr.Usage, err)
}
host := snapshot.Hosts.Hosts[instance]
insecure = insecure || host.Insecure
manager, err := auth.NewManager(a.deps.Settings, a.deps.Credentials)
if err != nil {
return err
}
if sshKey != "" || sshAgentPrincipal != "" || sshAgentKey != "" {
account := settings.Account{}
switch {
case sshKey != "":
account.SSHKey = sshKey
if _, err := os.Stat(sshKey + "-cert.pub"); err == nil {
account.AuthMethod = settings.AuthSSH
account.SSHCertPrincipal = "yes"
} else if errors.Is(err, os.ErrNotExist) {
account.AuthMethod = settings.AuthHTTPSign
} else {
return err
}
case sshAgentPrincipal != "":
account.AuthMethod = settings.AuthSSH
account.SSHAgent = true
account.SSHCertPrincipal = sshAgentPrincipal
case sshAgentKey != "":
account.AuthMethod = settings.AuthHTTPSign
account.SSHAgent = true
account.SSHKeyFingerprint = sshAgentKey
}
signer, err := auth.SignerForAccount(account, a.sshPassphrase(cmd.Context(), a.policy(snapshot), account.SSHKey))
if err != nil {
return clierr.Wrap(clierr.Authentication, err)
}
client := a.deps.Client(instance, "", insecure)
client.Signer = signer
user, err := services.CurrentAccount(cmd.Context(), client)
if err != nil {
return err
}
if selectedUser, _ := a.selectedUser(); !services.MatchAccount(selectedUser, user) {
return clierr.Wrap(clierr.Usage, errors.New("authenticated account does not match --user or TEA_USER"))
}
if prompted {
if err := a.confirm(cmd, "Save credentials for "+user+" on "+instance); err != nil {
return err
}
}
return manager.RegisterHTTPSign(instance, user, account, auth.HostOptions{Insecure: insecure})
}
if err := oneOf("credential-store", backend, settings.StoreKeyring, settings.StoreFile); err != nil {
return err
}
credential := auth.Credential{}
method := settings.AuthToken
if oauth {
if a.options.noInput {
return clierr.Wrap(clierr.Usage, errors.New("OAuth authorization requires interaction; use --with-token with --no-input"))
}
if clientID == "" {
clientID = host.OAuthClientID
}
if clientID == "" {
clientID = auth.DefaultClientID
}
credential, err = auth.OAuthLogin(cmd.Context(), auth.OAuthOptions{Instance: instance, ClientID: clientID, HTTP: a.deps.Client(instance, "", insecure).HTTP, Authorize: func(_ context.Context, authorizationURL string) error {
if _, err := fmt.Fprintln(cmd.ErrOrStderr(), "Open this URL to authorize tea:", authorizationURL); err != nil {
return err
}
if err := a.openBrowser(cmd, authorizationURL); err != nil {
_, _ = fmt.Fprintln(cmd.ErrOrStderr(), "Could not open a browser automatically; open the URL above manually.")
}
return nil
}})
if err != nil {
return err
}
method = settings.AuthOAuth
} else {
data, err := io.ReadAll(io.LimitReader(a.deps.Streams.In, 65537))
if err != nil {
return errors.New("cannot read token from stdin")
}
credential.AccessToken = strings.TrimSpace(string(data))
if credential.AccessToken == "" || len(data) > 65536 || strings.ContainsAny(credential.AccessToken, "\r\n") {
return clierr.Wrap(clierr.Usage, errors.New("stdin must contain one nonempty token"))
}
}
token := credential.AccessToken
client := a.deps.Client(instance, token, insecure)
user, err := services.CurrentAccount(cmd.Context(), client)
if err != nil {
return err
}
if selectedUser, _ := a.selectedUser(); !services.MatchAccount(selectedUser, user) {
return clierr.Wrap(clierr.Usage, errors.New("token account does not match --user or TEA_USER"))
}
if prompted {
if err := a.confirm(cmd, "Save credentials for "+user+" on "+instance); err != nil {
return err
}
}
return manager.Register(instance, user, backend, method, auth.HostOptions{OAuthClientID: clientID, Insecure: insecure}, credential)
}}
login.Flags().BoolVar(&oauth, "oauth", false, "Authorize through a browser using OAuth PKCE")
login.Flags().StringVar(&clientID, "client-id", "", "OAuth client ID registered on the instance (default: the instance's stored ID, then tea's)")
login.Flags().BoolVar(&insecure, "insecure", false, "Skip TLS certificate verification for this instance")
login.Flags().BoolVar(&withToken, "with-token", false, "Read a personal access token from stdin")
login.Flags().StringVar(&sshKey, "ssh-key", "", "Authenticate with an SSH private key via HTTP signatures")
login.Flags().StringVar(&sshAgentPrincipal, "ssh-agent-principal", "", "Authenticate with an ssh-agent certificate principal via HTTP signatures")
login.Flags().StringVar(&sshAgentKey, "ssh-agent-key", "", "Authenticate with an ssh-agent public key fingerprint via HTTP signatures")
login.Flags().StringVar(&backend, "credential-store", settings.StoreKeyring, "Credential backend: keyring or explicitly opted-in file")
group.AddCommand(login)
var verify bool
status := &cobra.Command{Use: "status", Short: "Show configured accounts without revealing credentials", Args: argsExact(0), RunE: func(cmd *cobra.Command, _ []string) error {
selectedHost := ""
if a.options.host != "" {
var err error
selectedHost, err = settings.NormalizeHost(a.options.host)
if err != nil {
return clierr.Wrap(clierr.Usage, err)
}
}
s, err := a.deps.Settings.Load()
if err != nil {
return err
}
rows := []authStatusRow{}
for instance, host := range s.Hosts.Hosts {
if selectedHost != "" && instance != selectedHost {
continue
}
for user, account := range host.Users {
if !services.MatchAccount(a.options.user, user) {
continue
}
item := authStatusRow{Host: instance, User: user, Active: host.User == user, Store: account.CredentialStore, Available: account.Tokenless()}
var credential auth.Credential
if !item.Available {
var err error
credential, err = a.deps.Credentials.Load(instance, user, account.CredentialStore)
item.Available = err == nil
if item.Available && account.AuthMethod == settings.AuthOAuth && !credential.ExpiresAt.IsZero() {
item.ExpiresAt = &credential.ExpiresAt
}
}
if verify {
err := errors.New("credential unavailable")
if item.Available {
err = a.verifyAccount(cmd.Context(), a.policy(s), instance, user, host, account, credential.AccessToken)
}
verified := err == nil
item.Verified = &verified
if err != nil {
item.Error = err.Error()
}
}
rows = append(rows, item)
}
}
sort.Slice(rows, func(i, j int) bool {
if rows[i].Host == rows[j].Host {
return rows[i].User < rows[j].User
}
return rows[i].Host < rows[j].Host
})
if a.options.json != "" {
return a.writeJSON(cmd, rows)
}
for _, row := range rows {
line := fmt.Sprintf("%s\t%s\tactive=%t\tcredentials=%t", row.Host, row.User, row.Active, row.Available)
if row.ExpiresAt != nil {
line += "\texpires=" + row.ExpiresAt.Format(time.RFC3339)
}
if row.Verified != nil {
line += fmt.Sprintf("\tverified=%t", *row.Verified)
}
if row.Error != "" {
line += "\terror=" + row.Error
}
if _, err := fmt.Fprintln(cmd.OutOrStdout(), line); err != nil {
return err
}
}
return nil
}}
status.Flags().BoolVar(&verify, "verify", false, "Check each listed credential against its instance")
group.AddCommand(status)
group.AddCommand(&cobra.Command{Use: "switch <user>", Short: "Select the active account for an instance", Annotations: textOutput("switching accounts does not return a resource"), Args: argsExact(1), RunE: func(_ *cobra.Command, args []string) error {
if a.options.host == "" {
return clierr.Wrap(clierr.Usage, errors.New("--host is required"))
}
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"))
}
user, exists := matchAccount(host.Users, args[0])
if !exists {
return clierr.Wrap(clierr.Usage, errors.New("account is not configured on the selected instance; use tea auth status --host URL to list accounts"))
}
host.User = user
s.Hosts.Hosts[instance] = host
return nil
})
}})
group.AddCommand(&cobra.Command{Use: "logout", Short: "Remove one account and its credentials", Annotations: textOutput("logout does not return a resource"), Args: argsExact(0), RunE: func(cmd *cobra.Command, _ []string) error {
selected, err := a.resolveTarget(cmd.Context(), false, false)
if err != nil {
return err
}
instance, user := selected.Host, selected.User
s, err := a.deps.Settings.Load()
if err != nil {
return err
}
if _, exists := s.Hosts.Hosts[instance].Users[user]; !exists {
return clierr.Wrap(clierr.Usage, errors.New("account is not configured on the selected instance; use tea auth status --host URL to list accounts"))
}
if err := a.confirm(cmd, "Remove account "+user+" on "+instance); err != nil {
return err
}
manager, err := auth.NewManager(a.deps.Settings, a.deps.Credentials)
if err != nil {
return err
}
return manager.Logout(instance, user)
}})
group.AddCommand(&cobra.Command{Use: "refresh", Short: "Refresh the selected OAuth account", Annotations: textOutput("refresh does not return a resource"), Args: argsExact(0), RunE: func(cmd *cobra.Command, _ []string) error {
t, err := a.resolveTarget(cmd.Context(), false, false)
if err != nil {
return err
}
account, exists := t.host.Users[t.User]
if !exists {
return clierr.Wrap(clierr.Authentication, errors.New("no selected account on this instance; run tea auth login or tea auth switch"))
}
if account.AuthMethod != settings.AuthOAuth {
return clierr.Wrap(clierr.Usage, errors.New("only OAuth accounts can be refreshed; use tea auth login to replace a token"))
}
manager, err := auth.NewManager(a.deps.Settings, a.deps.Credentials)
if err != nil {
return err
}
_, err = manager.Refresh(cmd.Context(), t.Host, t.User, a.deps.Client(t.Host, "", t.host.Insecure).HTTP, true, a.deps.Now())
return clierr.Wrap(clierr.Authentication, err)
}})
return group
}