mirror of
https://gitea.com/gitea/tea.git
synced 2026-10-26 19:11:25 +01:00
feat(auth): guide login, readable status and switch without --host
tea auth login without a method flag asks on a terminal for the instance (prefilled from the repository, default_host or the only instance), how to log in (token, browser, SSH key), the pasted token without echo with a link to the applications page, where to keep it and whether git should use tea for the instance. Bare hosts are accepted. Logging in to an account tea already has replaces its credential after a confirmation, or with --yes; a named account is confirmed before the token is asked for. Every login ends with "Logged in to HOST as USER (token, keyring)". OAuth requests write scopes for everything but administration by default, and --scopes chooses others; --web is an alias of --oauth. auth status prints a block per instance with the active account, how each account authenticates, its expiry, verification and token scopes, whether git uses tea, and the GITEA_TOKEN of the environment. Piped it prints tab-separated fields. It fails with exit code 4 when no account or no usable credential exists instead of printing nothing. auth switch takes the instance of the repository, --host, default_host or the sole instance instead of requiring --host, toggles between two accounts, asks on a terminal with more, and accepts --user. auth logout takes the account as an argument, names it in its errors, and asks git's other credential helpers to forget what they stored for the account. auth refresh points token accounts to tea auth login --with-token, and the new auth token prints the token of an account for scripts. Every auth command reports what it did on a terminal and has a long help text and examples. Signed-off-by: bircni <bircni@icloud.com>
This commit is contained in:
13 files changed
+1421
-327
No files matched your search
@@ -0,0 +1,121 @@
|
||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package terminal
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"charm.land/huh/v2"
|
||||
)
|
||||
|
||||
// Login methods that an interactive login offers.
|
||||
const (
|
||||
LoginToken = "token"
|
||||
LoginOAuth = "oauth"
|
||||
LoginSSHKey = "ssh-key"
|
||||
)
|
||||
|
||||
// LoginAnswers holds the choices of an interactive login. Callers prefill the defaults.
|
||||
type LoginAnswers struct {
|
||||
// Host is the instance URL; Method one of LoginToken, LoginOAuth and LoginSSHKey.
|
||||
Host, Method string
|
||||
// Token is the pasted access token, and SSHKey the private key path, for their methods.
|
||||
Token, SSHKey string
|
||||
// Store is the credential store of a token: keyring or file.
|
||||
Store string
|
||||
// SetupGit configures Git to use tea for the instance.
|
||||
SetupGit bool
|
||||
}
|
||||
|
||||
// LoginQuestions selects what an interactive login asks. The token of the token method and the key
|
||||
// of the SSH key method are always asked; Store and SetupGit only for tokens.
|
||||
type LoginQuestions struct {
|
||||
Host, Method, Store, SetupGit bool
|
||||
// ResolveHost normalizes an entered instance, or explains why it is not one.
|
||||
ResolveHost func(string) (string, error)
|
||||
// TokenHint tells where to create an access token on an instance.
|
||||
TokenHint func(host string) string
|
||||
}
|
||||
|
||||
// LoginForm collects how to log in to a Gitea instance.
|
||||
type LoginForm interface {
|
||||
Login(context.Context, LoginQuestions, *LoginAnswers) error
|
||||
}
|
||||
|
||||
// Login asks the selected questions on stderr, one at a time, so that later questions can depend
|
||||
// on earlier answers.
|
||||
func (p *FormPrompter) Login(ctx context.Context, q LoginQuestions, a *LoginAnswers) error {
|
||||
if q.Host {
|
||||
input := huh.NewInput().Title("Gitea instance").Description("A URL or host name, such as gitea.com").Value(&a.Host).Validate(func(s string) error {
|
||||
_, err := q.ResolveHost(strings.TrimSpace(s))
|
||||
return err
|
||||
})
|
||||
if err := p.runForm(ctx, huh.NewForm(huh.NewGroup(input))); err != nil {
|
||||
return err
|
||||
}
|
||||
host, err := q.ResolveHost(strings.TrimSpace(a.Host))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
a.Host = host
|
||||
}
|
||||
if q.Method {
|
||||
methods := huh.NewSelect[string]().Title("How would you like to log in?").Value(&a.Method).Options(
|
||||
huh.NewOption("Paste an access token", LoginToken),
|
||||
huh.NewOption("Log in with a web browser (OAuth)", LoginOAuth),
|
||||
huh.NewOption("Sign requests with an SSH key", LoginSSHKey),
|
||||
)
|
||||
if err := p.runForm(ctx, huh.NewForm(huh.NewGroup(methods))); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
var fields []huh.Field
|
||||
switch a.Method {
|
||||
case LoginToken:
|
||||
fields = append(fields, huh.NewInput().Title("Paste an access token").Description(q.TokenHint(a.Host)).EchoMode(huh.EchoModePassword).Value(&a.Token).Validate(func(s string) error {
|
||||
if strings.TrimSpace(s) == "" {
|
||||
return errors.New("paste the token")
|
||||
}
|
||||
return nil
|
||||
}))
|
||||
case LoginSSHKey:
|
||||
fields = append(fields, huh.NewInput().Title("SSH private key").Value(&a.SSHKey).Validate(func(s string) error {
|
||||
_, err := os.Stat(ExpandHome(strings.TrimSpace(s)))
|
||||
return err
|
||||
}))
|
||||
}
|
||||
if a.Method != LoginSSHKey && q.Store {
|
||||
fields = append(fields, huh.NewSelect[string]().Title("Where should tea keep the token?").Value(&a.Store).Options(
|
||||
huh.NewOption("System keyring", "keyring"),
|
||||
huh.NewOption("Private file in tea's configuration directory", "file"),
|
||||
))
|
||||
}
|
||||
if a.Method != LoginSSHKey && q.SetupGit {
|
||||
fields = append(fields, huh.NewConfirm().Title("Configure git to use tea for "+a.Host+"?").Description("Git then authenticates HTTPS pushes and fetches with this account").Value(&a.SetupGit))
|
||||
}
|
||||
if len(fields) > 0 {
|
||||
if err := p.runForm(ctx, huh.NewForm(huh.NewGroup(fields...))); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
a.Token, a.SSHKey = strings.TrimSpace(a.Token), ExpandHome(strings.TrimSpace(a.SSHKey))
|
||||
return nil
|
||||
}
|
||||
|
||||
// ExpandHome replaces a leading ~/ with the home directory, as a shell would.
|
||||
func ExpandHome(path string) string {
|
||||
rest, ok := strings.CutPrefix(path, "~/")
|
||||
if !ok {
|
||||
return path
|
||||
}
|
||||
home, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
return path
|
||||
}
|
||||
return filepath.Join(home, rest)
|
||||
}
|
||||
Reference in new issue
Block a user