Merge pull request #2990 from cli/ssh-key-commands

Add `ssh-key add` command and publish `ssh-key`
This commit is contained in:
Nate Smith 2021-02-17 14:30:37 -06:00 committed by GitHub
commit 2f563babbf
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
6 changed files with 204 additions and 61 deletions

View file

@ -1,12 +1,7 @@
package shared
import (
"bytes"
"encoding/json"
"errors"
"fmt"
"io"
"io/ioutil"
"net/http"
"os"
"os/exec"
@ -14,10 +9,9 @@ import (
"runtime"
"github.com/AlecAivazis/survey/v2"
"github.com/cli/cli/api"
"github.com/cli/cli/internal/config"
"github.com/cli/cli/internal/ghinstance"
"github.com/cli/cli/internal/run"
"github.com/cli/cli/pkg/cmd/ssh-key/add"
"github.com/cli/cli/pkg/prompt"
"github.com/cli/safeexec"
)
@ -115,57 +109,11 @@ func (c *sshContext) generateSSHKey() (string, error) {
}
func sshKeyUpload(httpClient *http.Client, hostname, keyFile string) error {
url := ghinstance.RESTPrefix(hostname) + "user/keys"
f, err := os.Open(keyFile)
if err != nil {
return err
}
defer f.Close()
keyBytes, err := ioutil.ReadAll(f)
if err != nil {
return err
}
payload := map[string]string{
"title": "GitHub CLI",
"key": string(keyBytes),
}
payloadBytes, err := json.Marshal(payload)
if err != nil {
return err
}
req, err := http.NewRequest("POST", url, bytes.NewBuffer(payloadBytes))
if err != nil {
return err
}
resp, err := httpClient.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode > 299 {
var httpError api.HTTPError
err := api.HandleHTTPError(resp)
if errors.As(err, &httpError) && isDuplicateError(&httpError) {
return nil
}
return err
}
_, err = io.Copy(ioutil.Discard, resp.Body)
if err != nil {
return err
}
return nil
}
func isDuplicateError(err *api.HTTPError) bool {
return err.StatusCode == 422 && len(err.Errors) == 1 &&
err.Errors[0].Field == "key" && err.Errors[0].Message == "key is already in use"
return add.SSHKeyUpload(httpClient, hostname, f, "GitHub CLI")
}

View file

@ -0,0 +1,91 @@
package add
import (
"errors"
"fmt"
"io"
"net/http"
"os"
"github.com/cli/cli/internal/ghinstance"
"github.com/cli/cli/pkg/cmdutil"
"github.com/cli/cli/pkg/iostreams"
"github.com/spf13/cobra"
)
type AddOptions struct {
IO *iostreams.IOStreams
HTTPClient func() (*http.Client, error)
KeyFile string
Title string
}
func NewCmdAdd(f *cmdutil.Factory, runF func(*AddOptions) error) *cobra.Command {
opts := &AddOptions{
HTTPClient: f.HttpClient,
IO: f.IOStreams,
}
cmd := &cobra.Command{
Use: "add [<key-file>]",
Short: "Add an SSH key to your GitHub account",
Args: cobra.MaximumNArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
if len(args) == 0 {
if opts.IO.IsStdoutTTY() && opts.IO.IsStdinTTY() {
return &cmdutil.FlagError{Err: errors.New("public key file missing")}
}
opts.KeyFile = "-"
} else {
opts.KeyFile = args[0]
}
if runF != nil {
return runF(opts)
}
return runAdd(opts)
},
}
cmd.Flags().StringVarP(&opts.Title, "title", "t", "", "Title for the new key")
return cmd
}
func runAdd(opts *AddOptions) error {
httpClient, err := opts.HTTPClient()
if err != nil {
return err
}
var keyReader io.Reader
if opts.KeyFile == "-" {
keyReader = opts.IO.In
defer opts.IO.In.Close()
} else {
f, err := os.Open(opts.KeyFile)
if err != nil {
return err
}
defer f.Close()
keyReader = f
}
hostname := ghinstance.OverridableDefault()
err = SSHKeyUpload(httpClient, hostname, keyReader, opts.Title)
if err != nil {
if errors.Is(err, scopesError) {
cs := opts.IO.ColorScheme()
fmt.Fprint(opts.IO.ErrOut, "Error: insufficient OAuth scopes to list SSH keys\n")
fmt.Fprintf(opts.IO.ErrOut, "Run the following to grant scopes: %s\n", cs.Bold("gh auth refresh -s write:public_key"))
return cmdutil.SilentError
}
return err
}
if opts.IO.IsStdoutTTY() {
cs := opts.IO.ColorScheme()
fmt.Fprintf(opts.IO.ErrOut, "%s Public key added to your account\n", cs.SuccessIcon())
}
return nil
}

View file

@ -0,0 +1,39 @@
package add
import (
"net/http"
"testing"
"github.com/cli/cli/pkg/httpmock"
"github.com/cli/cli/pkg/iostreams"
"github.com/stretchr/testify/assert"
)
func Test_runAdd(t *testing.T) {
io, stdin, stdout, stderr := iostreams.Test()
io.SetStdinTTY(false)
io.SetStdoutTTY(true)
io.SetStderrTTY(true)
stdin.WriteString("PUBKEY")
tr := httpmock.Registry{}
defer tr.Verify(t)
tr.Register(
httpmock.REST("POST", "user/keys"),
httpmock.StringResponse(`{}`))
err := runAdd(&AddOptions{
IO: io,
HTTPClient: func() (*http.Client, error) {
return &http.Client{Transport: &tr}, nil
},
KeyFile: "-",
Title: "my sacred key",
})
assert.NoError(t, err)
assert.Equal(t, "", stdout.String())
assert.Equal(t, "✓ Public key added to your account\n", stderr.String())
}

View file

@ -0,0 +1,68 @@
package add
import (
"bytes"
"encoding/json"
"errors"
"io"
"io/ioutil"
"net/http"
"github.com/cli/cli/api"
"github.com/cli/cli/internal/ghinstance"
)
var scopesError = errors.New("insufficient OAuth scopes")
func SSHKeyUpload(httpClient *http.Client, hostname string, keyFile io.Reader, title string) error {
url := ghinstance.RESTPrefix(hostname) + "user/keys"
keyBytes, err := ioutil.ReadAll(keyFile)
if err != nil {
return err
}
payload := map[string]string{
"title": title,
"key": string(keyBytes),
}
payloadBytes, err := json.Marshal(payload)
if err != nil {
return err
}
req, err := http.NewRequest("POST", url, bytes.NewBuffer(payloadBytes))
if err != nil {
return err
}
resp, err := httpClient.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode == 404 {
return scopesError
} else if resp.StatusCode > 299 {
var httpError api.HTTPError
err := api.HandleHTTPError(resp)
if errors.As(err, &httpError) && isDuplicateError(&httpError) {
return nil
}
return err
}
_, err = io.Copy(ioutil.Discard, resp.Body)
if err != nil {
return err
}
return nil
}
func isDuplicateError(err *api.HTTPError) bool {
return err.StatusCode == 422 && len(err.Errors) == 1 &&
err.Errors[0].Field == "key" && err.Errors[0].Message == "key is already in use"
}

View file

@ -12,13 +12,11 @@ import (
"github.com/spf13/cobra"
)
// ListOptions struct for list command
type ListOptions struct {
IO *iostreams.IOStreams
HTTPClient func() (*http.Client, error)
}
// NewCmdList creates a command for list all SSH Keys
func NewCmdList(f *cmdutil.Factory, runF func(*ListOptions) error) *cobra.Command {
opts := &ListOptions{
HTTPClient: f.HttpClient,
@ -27,7 +25,7 @@ func NewCmdList(f *cmdutil.Factory, runF func(*ListOptions) error) *cobra.Comman
cmd := &cobra.Command{
Use: "list",
Short: "Lists SSH keys in a GitHub account",
Short: "Lists SSH keys in your GitHub account",
Args: cobra.ExactArgs(0),
RunE: func(cmd *cobra.Command, args []string) error {
if runF != nil {

View file

@ -1,22 +1,21 @@
package key
import (
cmdAdd "github.com/cli/cli/pkg/cmd/ssh-key/add"
cmdList "github.com/cli/cli/pkg/cmd/ssh-key/list"
"github.com/cli/cli/pkg/cmdutil"
"github.com/spf13/cobra"
)
// NewCmdSSHKey creates a command for manage SSH Keys
func NewCmdSSHKey(f *cmdutil.Factory) *cobra.Command {
cmd := &cobra.Command{
Use: "ssh-key <command>",
Short: "Manage SSH keys",
Long: "Work with GitHub SSH keys",
Hidden: true,
Long: "Manage SSH keys registered with your GitHub account",
}
cmd.AddCommand(cmdList.NewCmdList(f, nil))
cmd.AddCommand(cmdAdd.NewCmdAdd(f, nil))
return cmd
}