Skip to content

Commit 2f563ba

Browse files
author
Nate Smith
authored
Merge pull request cli#2990 from cli/ssh-key-commands
Add `ssh-key add` command and publish `ssh-key`
2 parents e91b97b + 1a9e42e commit 2f563ba

File tree

6 files changed

+204
-61
lines changed

6 files changed

+204
-61
lines changed

pkg/cmd/auth/shared/ssh_keys.go

Lines changed: 2 additions & 54 deletions
Original file line numberDiff line numberDiff line change
@@ -1,23 +1,17 @@
11
package shared
22

33
import (
4-
"bytes"
5-
"encoding/json"
6-
"errors"
74
"fmt"
8-
"io"
9-
"io/ioutil"
105
"net/http"
116
"os"
127
"os/exec"
138
"path/filepath"
149
"runtime"
1510

1611
"github.com/AlecAivazis/survey/v2"
17-
"github.com/cli/cli/api"
1812
"github.com/cli/cli/internal/config"
19-
"github.com/cli/cli/internal/ghinstance"
2013
"github.com/cli/cli/internal/run"
14+
"github.com/cli/cli/pkg/cmd/ssh-key/add"
2115
"github.com/cli/cli/pkg/prompt"
2216
"github.com/cli/safeexec"
2317
)
@@ -115,57 +109,11 @@ func (c *sshContext) generateSSHKey() (string, error) {
115109
}
116110

117111
func sshKeyUpload(httpClient *http.Client, hostname, keyFile string) error {
118-
url := ghinstance.RESTPrefix(hostname) + "user/keys"
119-
120112
f, err := os.Open(keyFile)
121113
if err != nil {
122114
return err
123115
}
124116
defer f.Close()
125-
keyBytes, err := ioutil.ReadAll(f)
126-
if err != nil {
127-
return err
128-
}
129-
130-
payload := map[string]string{
131-
"title": "GitHub CLI",
132-
"key": string(keyBytes),
133-
}
134-
135-
payloadBytes, err := json.Marshal(payload)
136-
if err != nil {
137-
return err
138-
}
139-
140-
req, err := http.NewRequest("POST", url, bytes.NewBuffer(payloadBytes))
141-
if err != nil {
142-
return err
143-
}
144-
145-
resp, err := httpClient.Do(req)
146-
if err != nil {
147-
return err
148-
}
149-
defer resp.Body.Close()
150-
151-
if resp.StatusCode > 299 {
152-
var httpError api.HTTPError
153-
err := api.HandleHTTPError(resp)
154-
if errors.As(err, &httpError) && isDuplicateError(&httpError) {
155-
return nil
156-
}
157-
return err
158-
}
159-
160-
_, err = io.Copy(ioutil.Discard, resp.Body)
161-
if err != nil {
162-
return err
163-
}
164-
165-
return nil
166-
}
167117

168-
func isDuplicateError(err *api.HTTPError) bool {
169-
return err.StatusCode == 422 && len(err.Errors) == 1 &&
170-
err.Errors[0].Field == "key" && err.Errors[0].Message == "key is already in use"
118+
return add.SSHKeyUpload(httpClient, hostname, f, "GitHub CLI")
171119
}

pkg/cmd/ssh-key/add/add.go

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
package add
2+
3+
import (
4+
"errors"
5+
"fmt"
6+
"io"
7+
"net/http"
8+
"os"
9+
10+
"github.com/cli/cli/internal/ghinstance"
11+
"github.com/cli/cli/pkg/cmdutil"
12+
"github.com/cli/cli/pkg/iostreams"
13+
"github.com/spf13/cobra"
14+
)
15+
16+
type AddOptions struct {
17+
IO *iostreams.IOStreams
18+
HTTPClient func() (*http.Client, error)
19+
20+
KeyFile string
21+
Title string
22+
}
23+
24+
func NewCmdAdd(f *cmdutil.Factory, runF func(*AddOptions) error) *cobra.Command {
25+
opts := &AddOptions{
26+
HTTPClient: f.HttpClient,
27+
IO: f.IOStreams,
28+
}
29+
30+
cmd := &cobra.Command{
31+
Use: "add [<key-file>]",
32+
Short: "Add an SSH key to your GitHub account",
33+
Args: cobra.MaximumNArgs(1),
34+
RunE: func(cmd *cobra.Command, args []string) error {
35+
if len(args) == 0 {
36+
if opts.IO.IsStdoutTTY() && opts.IO.IsStdinTTY() {
37+
return &cmdutil.FlagError{Err: errors.New("public key file missing")}
38+
}
39+
opts.KeyFile = "-"
40+
} else {
41+
opts.KeyFile = args[0]
42+
}
43+
44+
if runF != nil {
45+
return runF(opts)
46+
}
47+
return runAdd(opts)
48+
},
49+
}
50+
51+
cmd.Flags().StringVarP(&opts.Title, "title", "t", "", "Title for the new key")
52+
return cmd
53+
}
54+
55+
func runAdd(opts *AddOptions) error {
56+
httpClient, err := opts.HTTPClient()
57+
if err != nil {
58+
return err
59+
}
60+
61+
var keyReader io.Reader
62+
if opts.KeyFile == "-" {
63+
keyReader = opts.IO.In
64+
defer opts.IO.In.Close()
65+
} else {
66+
f, err := os.Open(opts.KeyFile)
67+
if err != nil {
68+
return err
69+
}
70+
defer f.Close()
71+
keyReader = f
72+
}
73+
74+
hostname := ghinstance.OverridableDefault()
75+
err = SSHKeyUpload(httpClient, hostname, keyReader, opts.Title)
76+
if err != nil {
77+
if errors.Is(err, scopesError) {
78+
cs := opts.IO.ColorScheme()
79+
fmt.Fprint(opts.IO.ErrOut, "Error: insufficient OAuth scopes to list SSH keys\n")
80+
fmt.Fprintf(opts.IO.ErrOut, "Run the following to grant scopes: %s\n", cs.Bold("gh auth refresh -s write:public_key"))
81+
return cmdutil.SilentError
82+
}
83+
return err
84+
}
85+
86+
if opts.IO.IsStdoutTTY() {
87+
cs := opts.IO.ColorScheme()
88+
fmt.Fprintf(opts.IO.ErrOut, "%s Public key added to your account\n", cs.SuccessIcon())
89+
}
90+
return nil
91+
}

pkg/cmd/ssh-key/add/add_test.go

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
package add
2+
3+
import (
4+
"net/http"
5+
"testing"
6+
7+
"github.com/cli/cli/pkg/httpmock"
8+
"github.com/cli/cli/pkg/iostreams"
9+
"github.com/stretchr/testify/assert"
10+
)
11+
12+
func Test_runAdd(t *testing.T) {
13+
io, stdin, stdout, stderr := iostreams.Test()
14+
io.SetStdinTTY(false)
15+
io.SetStdoutTTY(true)
16+
io.SetStderrTTY(true)
17+
18+
stdin.WriteString("PUBKEY")
19+
20+
tr := httpmock.Registry{}
21+
defer tr.Verify(t)
22+
23+
tr.Register(
24+
httpmock.REST("POST", "user/keys"),
25+
httpmock.StringResponse(`{}`))
26+
27+
err := runAdd(&AddOptions{
28+
IO: io,
29+
HTTPClient: func() (*http.Client, error) {
30+
return &http.Client{Transport: &tr}, nil
31+
},
32+
KeyFile: "-",
33+
Title: "my sacred key",
34+
})
35+
assert.NoError(t, err)
36+
37+
assert.Equal(t, "", stdout.String())
38+
assert.Equal(t, "✓ Public key added to your account\n", stderr.String())
39+
}

pkg/cmd/ssh-key/add/http.go

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
package add
2+
3+
import (
4+
"bytes"
5+
"encoding/json"
6+
"errors"
7+
"io"
8+
"io/ioutil"
9+
"net/http"
10+
11+
"github.com/cli/cli/api"
12+
"github.com/cli/cli/internal/ghinstance"
13+
)
14+
15+
var scopesError = errors.New("insufficient OAuth scopes")
16+
17+
func SSHKeyUpload(httpClient *http.Client, hostname string, keyFile io.Reader, title string) error {
18+
url := ghinstance.RESTPrefix(hostname) + "user/keys"
19+
20+
keyBytes, err := ioutil.ReadAll(keyFile)
21+
if err != nil {
22+
return err
23+
}
24+
25+
payload := map[string]string{
26+
"title": title,
27+
"key": string(keyBytes),
28+
}
29+
30+
payloadBytes, err := json.Marshal(payload)
31+
if err != nil {
32+
return err
33+
}
34+
35+
req, err := http.NewRequest("POST", url, bytes.NewBuffer(payloadBytes))
36+
if err != nil {
37+
return err
38+
}
39+
40+
resp, err := httpClient.Do(req)
41+
if err != nil {
42+
return err
43+
}
44+
defer resp.Body.Close()
45+
46+
if resp.StatusCode == 404 {
47+
return scopesError
48+
} else if resp.StatusCode > 299 {
49+
var httpError api.HTTPError
50+
err := api.HandleHTTPError(resp)
51+
if errors.As(err, &httpError) && isDuplicateError(&httpError) {
52+
return nil
53+
}
54+
return err
55+
}
56+
57+
_, err = io.Copy(ioutil.Discard, resp.Body)
58+
if err != nil {
59+
return err
60+
}
61+
62+
return nil
63+
}
64+
65+
func isDuplicateError(err *api.HTTPError) bool {
66+
return err.StatusCode == 422 && len(err.Errors) == 1 &&
67+
err.Errors[0].Field == "key" && err.Errors[0].Message == "key is already in use"
68+
}

pkg/cmd/ssh-key/list/list.go

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -12,13 +12,11 @@ import (
1212
"github.com/spf13/cobra"
1313
)
1414

15-
// ListOptions struct for list command
1615
type ListOptions struct {
1716
IO *iostreams.IOStreams
1817
HTTPClient func() (*http.Client, error)
1918
}
2019

21-
// NewCmdList creates a command for list all SSH Keys
2220
func NewCmdList(f *cmdutil.Factory, runF func(*ListOptions) error) *cobra.Command {
2321
opts := &ListOptions{
2422
HTTPClient: f.HttpClient,
@@ -27,7 +25,7 @@ func NewCmdList(f *cmdutil.Factory, runF func(*ListOptions) error) *cobra.Comman
2725

2826
cmd := &cobra.Command{
2927
Use: "list",
30-
Short: "Lists SSH keys in a GitHub account",
28+
Short: "Lists SSH keys in your GitHub account",
3129
Args: cobra.ExactArgs(0),
3230
RunE: func(cmd *cobra.Command, args []string) error {
3331
if runF != nil {

pkg/cmd/ssh-key/ssh-key.go

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,22 +1,21 @@
11
package key
22

33
import (
4+
cmdAdd "github.com/cli/cli/pkg/cmd/ssh-key/add"
45
cmdList "github.com/cli/cli/pkg/cmd/ssh-key/list"
56
"github.com/cli/cli/pkg/cmdutil"
67
"github.com/spf13/cobra"
78
)
89

9-
// NewCmdSSHKey creates a command for manage SSH Keys
1010
func NewCmdSSHKey(f *cmdutil.Factory) *cobra.Command {
1111
cmd := &cobra.Command{
1212
Use: "ssh-key <command>",
1313
Short: "Manage SSH keys",
14-
Long: "Work with GitHub SSH keys",
15-
16-
Hidden: true,
14+
Long: "Manage SSH keys registered with your GitHub account",
1715
}
1816

1917
cmd.AddCommand(cmdList.NewCmdList(f, nil))
18+
cmd.AddCommand(cmdAdd.NewCmdAdd(f, nil))
2019

2120
return cmd
2221
}

0 commit comments

Comments
 (0)