Files
gitea-tea/internal/app/ssh_keys.go
T
bircni 9ac5b79e9e refactor(completion): name path arguments explicitly and share firstArg
The six commands that take a file or directory argument set their
completer directly instead of a Use-line parser wrapping every
command, the first-argument-only wrappers become one firstArg helper,
and only the bash completion help keeps a custom text.

Signed-off-by: bircni <bircni@icloud.com>
2026-10-12 10:50:37 +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)), ValidArgsFunction: completeFiles,
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) })
},
}
}