Merge pull request #2990 from cli/ssh-key-commands
Add `ssh-key add` command and publish `ssh-key`
This commit is contained in:
commit
2f563babbf
6 changed files with 204 additions and 61 deletions
|
|
@ -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")
|
||||
}
|
||||
|
|
|
|||
91
pkg/cmd/ssh-key/add/add.go
Normal file
91
pkg/cmd/ssh-key/add/add.go
Normal 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
|
||||
}
|
||||
39
pkg/cmd/ssh-key/add/add_test.go
Normal file
39
pkg/cmd/ssh-key/add/add_test.go
Normal 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())
|
||||
}
|
||||
68
pkg/cmd/ssh-key/add/http.go
Normal file
68
pkg/cmd/ssh-key/add/http.go
Normal 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"
|
||||
}
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue