Files
gitea-tea/internal/app/ssh_keys.go
T
bircni 1f4b5f868f refactor(prompts): fold Asker into Prompter and share delete confirmations
FormPrompter is the only production prompter and implements the rich
prompt API, so Ask, AskText and ConfirmDefault become the Prompter
interface and the plain Input/Confirm fallbacks go away.

Confirmations that default to no derive their non-terminal wording
from the prompt, and single deletes share deleteConfirmed for the
prompt, the request and the done line.

Signed-off-by: bircni <bircni@icloud.com>
2026-10-12 10:47:54 +02:00

230 lines
8.1 KiB
Go

// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package app
import (
"bytes"
"context"
"errors"
"fmt"
"os"
"strconv"
"strings"
"gitea.dev/tea/internal/clierr"
"gitea.dev/tea/internal/services"
"gitea.dev/tea/internal/terminal"
"github.com/spf13/cobra"
"golang.org/x/crypto/ssh"
)
func (a *application) sshKeyCommand() *cobra.Command {
group := &cobra.Command{Use: "ssh-key", Short: "Manage SSH keys", Long: "Manage the SSH public keys of your account, which authenticate Git operations over SSH."}
group.AddCommand(a.sshKeyListCommand(), a.sshKeyViewCommand(), a.sshKeyCreateCommand(), a.sshKeyDeleteCommand())
return group
}
// sshKeyLabel names a key in messages: `SSH key "laptop" (SHA256:…)`.
func sshKeyLabel(key services.SSHKey) string {
return fmt.Sprintf("SSH key %q (%s)", key.Title, key.Fingerprint)
}
// completeSSHKeys completes the key IDs of the account with their titles.
func (a *application) completeSSHKeys() cobra.CompletionFunc {
return a.apiCompletion(false, func(ctx context.Context, t target, _ string) ([]cobra.Completion, error) {
keys, err := (services.SSHKeys{Client: t.client}).List(ctx, completionLimit, 1)
return describe(keys.Items, func(key services.SSHKey) (string, string) {
return strconv.FormatInt(key.ID, 10), key.Title + " (" + key.Fingerprint + ")"
}), err
})
}
func (a *application) sshKeyListCommand() *cobra.Command {
var limit, page int
cmd := &cobra.Command{
Use: "list", Short: "List SSH keys", Args: argsExact(0),
Example: `$ tea ssh-key list
$ tea ssh-key list --json title,fingerprint`,
RunE: func(cmd *cobra.Command, _ []string) error {
target, err := a.resolve(cmd.Context(), false)
if err != nil {
return err
}
keys, err := (services.SSHKeys{Client: target.client}).List(cmd.Context(), limit, page)
if err != nil {
return err
}
account := target.login()
return writeTable(a, cmd, keys.Items, listSpec[services.SSHKey]{
Columns: []terminal.Column{{Header: "ID"}, {Header: "Title", Flex: true}, {Header: "Fingerprint"}, {Header: "Added"}},
Row: func(key services.SSHKey) []terminal.Cell {
return []terminal.Cell{{Text: strconv.FormatInt(key.ID, 10), Link: webURL(target, "user", "settings", "keys")}, {Text: key.Title, Bold: true}, {Text: key.Fingerprint, Muted: true}, {Time: terminal.ParseTime(key.CreatedAt)}}
},
Context: "SSH keys of " + account,
Empty: "no SSH keys for " + account + "; add one with: tea ssh-key add ~/.ssh/id_ed25519.pub",
}, servicePage(keys, page))
},
}
pageFlags(cmd, &limit, &page)
return cmd
}
func (a *application) sshKeyViewCommand() *cobra.Command {
return &cobra.Command{
Use: "view <id>", Short: "View an SSH key", Args: argsExact(1), ValidArgsFunction: a.completeSSHKeys(),
Example: `$ tea ssh-key view 2
# print the public key
$ tea ssh-key view 2 --jq .key`,
RunE: func(cmd *cobra.Command, args []string) error {
id, err := number(args[0])
if err != nil {
return err
}
target, err := a.resolve(cmd.Context(), false)
if err != nil {
return err
}
key, err := (services.SSHKeys{Client: target.client}).View(cmd.Context(), id)
if err != nil {
return err
}
d := a.display()
return writeView(a, cmd, key, func(key services.SSHKey) terminal.View {
added := timeField("Added", key.CreatedAt, d)
meta := ""
if added.Value != "" {
meta = "added " + added.Value
}
added.PipedOnly = true
algorithm, _, _ := strings.Cut(key.Key, " ")
return terminal.View{
Title: key.Title, ID: fmt.Sprintf("(ID %d)", key.ID), Meta: meta,
Fields: []terminal.Field{{Label: "Fingerprint", Value: key.Fingerprint}, {Label: "Type", Value: algorithm}, added, {Label: "Key", Value: key.Key}},
Bodyless: true, Kind: "SSH key", URL: webURL(target, "user", "settings", "keys"),
}
})
},
}
}
// publicKey reads one SSH public key from data, which came from source, and returns it with its
// comment. It recognizes a private key and points to its public half.
func publicKey(data []byte, source string) (ssh.PublicKey, string, error) {
if bytes.Contains(data, []byte("PRIVATE KEY-----")) {
hint := "pipe the public key (.pub) instead"
if source != "-" {
hint = "add its public key instead: tea ssh-key add " + source + ".pub"
}
return nil, "", clierr.Wrap(clierr.Usage, fmt.Errorf("%s holds a private key; %s", sshKeySource(source), hint))
}
key, comment, _, rest, err := ssh.ParseAuthorizedKey(data)
if err != nil || len(bytes.TrimSpace(rest)) != 0 {
return nil, "", clierr.Wrap(clierr.Usage, fmt.Errorf("%s must hold exactly one valid SSH public key", sshKeySource(source)))
}
return key, comment, nil
}
func sshKeySource(source string) string {
if source == "-" {
return "stdin"
}
return source
}
func (a *application) sshKeyCreateCommand() *cobra.Command {
var title string
cmd := &cobra.Command{
Use: "create [<file>]", Aliases: []string{"add"}, Short: "Add an SSH public key from a file or stdin", Args: usageArgs(cobra.MaximumNArgs(1)),
Long: `Add an SSH public key to your account. Without a file, or with -, the key is read from stdin.
The title defaults to the key's comment, such as alice@laptop, or else to this computer's name.`,
Example: `$ tea ssh-key add ~/.ssh/id_ed25519.pub
$ tea ssh-key add ~/.ssh/id_ed25519.pub --title "Work laptop"
$ cat key.pub | tea ssh-key add`,
RunE: func(cmd *cobra.Command, args []string) error {
source := "-"
if len(args) > 0 {
source = args[0]
}
if source == "-" && a.deps.Streams.InTTY {
return clierr.Wrap(clierr.Usage, errors.New("supply a public key file, such as ~/.ssh/id_ed25519.pub, or pipe a key to stdin"))
}
data, err := readFileOrStdin(a.deps.Streams.In, source)
if err != nil {
return fmt.Errorf("read public key: %w", err)
}
key, comment, err := publicKey(data, source)
if err != nil {
return err
}
if !cmd.Flags().Changed("title") {
title = comment
if title == "" {
title, _ = os.Hostname()
}
}
if strings.TrimSpace(title) == "" {
return clierr.Wrap(clierr.Usage, errors.New("key title cannot be empty; pass --title"))
}
target, err := a.resolve(cmd.Context(), false)
if err != nil {
return err
}
service := services.SSHKeys{Client: target.client}
created, err := service.Create(cmd.Context(), title, strings.TrimSpace(string(data)))
if err != nil {
return a.registeredKey(cmd.Context(), service, key, err)
}
a.done(cmd, "Added %s", sshKeyLabel(created))
return a.jsonResult(cmd, created)
},
}
cmd.Flags().StringVarP(&title, "title", "t", "", "Key title (defaults to the key comment, then the computer's name)")
return cmd
}
// registeredKey names the key of the account that already holds key when Gitea refuses to add it.
func (a *application) registeredKey(ctx context.Context, service services.SSHKeys, key ssh.PublicKey, err error) error {
conflict, ok := errors.AsType[*services.ConflictError](err)
if !ok || conflict.Reason != services.ReasonKeyInUse {
return err
}
keys, listErr := service.List(ctx, maxLimit, 1)
if listErr != nil {
return conflict
}
fingerprint := ssh.FingerprintSHA256(key)
for _, registered := range keys.Items {
if registered.Fingerprint == fingerprint {
conflict.Hint = fmt.Sprintf("it is your SSH key %q (ID %d)", registered.Title, registered.ID)
}
}
return conflict
}
func (a *application) sshKeyDeleteCommand() *cobra.Command {
return &cobra.Command{
Use: "delete <id>", Short: "Delete an SSH key", Args: argsExact(1), ValidArgsFunction: a.completeSSHKeys(),
Example: `$ tea ssh-key delete 2
$ tea ssh-key delete 2 --yes`,
Annotations: map[string]string{"tea.text": "SSH key deletion does not return a resource", "tea.confirm": "true"},
RunE: func(cmd *cobra.Command, args []string) error {
id, err := number(args[0])
if err != nil {
return err
}
target, err := a.resolve(cmd.Context(), false)
if err != nil {
return err
}
service := services.SSHKeys{Client: target.client}
key, err := service.View(cmd.Context(), id)
if err != nil {
return err
}
return a.deleteConfirmed(cmd, sshKeyLabel(key), func() error { return service.Delete(cmd.Context(), id) })
},
}
}