Skip to content

Commit

Permalink
feat: Support SSH endpoints and basic auth for HTTP endpoints (#98)
Browse files Browse the repository at this point in the history
* Add support for cloning over SSH

* Add interactive prompt for HTTP auth when cloning a private repo
  • Loading branch information
smoores-dev authored Feb 7, 2024
1 parent 571e366 commit 5f2ed78
Show file tree
Hide file tree
Showing 4 changed files with 238 additions and 10 deletions.
51 changes: 42 additions & 9 deletions app/scaffold/pkgs/pkgs.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,10 +11,12 @@ import (
"path/filepath"
"strings"

pkgurl "github.com/hay-kot/scaffold/app/scaffold/pkgs/url"

"github.com/go-git/go-git/v5"
)

// ParseRemote parses a URL and returns a filesystem path representing the
// ParseRemote parses a remote endpoint and returns a filesystem path representing the
// repository.
//
// Examples:
Expand All @@ -25,9 +27,32 @@ import (
// └── scaffold-go-cli
// └── repository files
func ParseRemote(urlStr string) (string, error) {
var host string
var user string
var repo string
var err error

if pkgurl.MatchesScheme(urlStr) {
host, user, repo, err = parseRemoteURL(urlStr)
} else if pkgurl.MatchesScpLike(urlStr) {
host, user, repo, err = parseRemoteScpLike(urlStr)
} else {
return "", fmt.Errorf("failed to parse url: matches neither scheme nor scp-like url structure")
}

if err != nil {
return "", err
}

return filepath.Join(host, user, repo), nil
}

// Parses a remote URL endpoint into its host, user, and repo name
// parts
func parseRemoteURL(urlStr string) (string, string, string, error) {
url, err := url.ParseRequestURI(urlStr)
if err != nil {
return "", fmt.Errorf("failed to parse url: %w", err)
return "", "", "", fmt.Errorf("failed to parse url: %w", err)
}

host := url.Host
Expand All @@ -37,13 +62,21 @@ func ParseRemote(urlStr string) (string, error) {
split[len(split)-1] = strings.Replace(split[len(split)-1], ".git", "", 1)

if len(split) < 3 {
return "", fmt.Errorf("invalid url")
return "", "", "", fmt.Errorf("invalid url")
}

user := split[1]
repo := split[2]

return filepath.Join(host, user, repo), nil
return host, user, repo, nil
}

// Parses a remote SCP-like endpoint into its host, user, and repo name
// parts
func parseRemoteScpLike(urlStr string) (string, string, string, error) {
user, host, _, path := pkgurl.FindScpLikeComponents(urlStr)

return host, user, strings.TrimSuffix(path, ".git"), nil
}

// IsRemote checks if the string is a remote url or an alias for a remote url
Expand All @@ -54,10 +87,6 @@ func ParseRemote(urlStr string) (string, error) {
//
// isRemote(gh:foo/bar) -> https://github.com/foo/bar, true
func IsRemote(str string, shorts map[string]string) (expanded string, ok bool) {
if strings.HasPrefix(str, "http") {
return str, true
}

split := strings.Split(str, ":")

if len(split) == 2 {
Expand All @@ -70,11 +99,15 @@ func IsRemote(str string, shorts map[string]string) (expanded string, ok bool) {
return "", false
}

return out, true
return out, pkgurl.IsRemoteEndpoint(out)
}
}
}

if pkgurl.IsRemoteEndpoint(str) {
return str, true
}

return "", false
}

Expand Down
51 changes: 50 additions & 1 deletion app/scaffold/pkgs/resolver.go
Original file line number Diff line number Diff line change
@@ -1,12 +1,16 @@
package pkgs

import (
"errors"
"fmt"
"os"
"path/filepath"
"strings"

"github.com/AlecAivazis/survey/v2"
"github.com/go-git/go-git/v5"
"github.com/go-git/go-git/v5/plumbing/transport"
"github.com/go-git/go-git/v5/plumbing/transport/http"
)

var ErrNoMatchingScaffold = fmt.Errorf("no matching scaffold")
Expand Down Expand Up @@ -49,7 +53,24 @@ func (r *Resolver) Resolve(arg string, checkDirs []string) (path string, err err
Progress: os.Stdout,
})
if err != nil {
return "", fmt.Errorf("failed to clone repository: %w", err)
if errors.Is(err, transport.ErrAuthenticationRequired) {
username, password, err := promptHTTPAuth()
if err == nil {
r, err = git.PlainClone(dir, false, &git.CloneOptions{
URL: remoteRef,
Progress: os.Stdout,
Auth: &http.BasicAuth{
Username: username,
Password: password,
},
})
}
if err != nil {
return "", fmt.Errorf("failed to clone repository: %w", err)
}
} else {
return "", fmt.Errorf("failed to clone repository: %w", err)
}
}

// Get cloned repository path
Expand Down Expand Up @@ -91,3 +112,31 @@ func (r *Resolver) Resolve(arg string, checkDirs []string) (path string, err err

return path, nil
}

var qs = []*survey.Question{
{
Name: "username",
Prompt: &survey.Input{Message: "Username:"},
Validate: survey.Required,
},
{
Name: "password",
Prompt: &survey.Password{
Message: "Password/personal access token:",
},
},
}

func promptHTTPAuth() (string, string, error) {
answers := struct {
Username string
Password string
}{}

err := survey.Ask(qs, &answers)
if err != nil {
return "", "", fmt.Errorf("failed to parse http auth input: %w", err)
}

return answers.Username, answers.Password, nil
}
48 changes: 48 additions & 0 deletions app/scaffold/pkgs/url/url.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
// Package url contains functions for parsing remote urls
package url

import (
"regexp"
)

var (
isSchemeRegExp = regexp.MustCompile(`^[^:]+://`)

// Ref: https://github.com/git/git/blob/master/Documentation/urls.txt#L37
scpLikeURLRegExp = regexp.MustCompile(`^(?:(?P<user>[^@]+)@)?(?P<host>[^:\s]+):(?:(?P<port>[0-9]{1,5}):)?(?P<path>[^\\].*)$`)
)

// MatchesScheme returns true if the given string matches a URL-like
// format scheme.
func MatchesScheme(url string) bool {
return isSchemeRegExp.MatchString(url)
}

// MatchesScpLike returns true if the given string matches an SCP-like
// format scheme.
func MatchesScpLike(url string) bool {
return scpLikeURLRegExp.MatchString(url)
}

// IsRemoteEndpoint returns true if the giver URL string specifies
// a remote endpoint. For example, on a Linux machine,
// `https://github.com/src-d/go-git` would match as a remote
// endpoint, but `/home/user/src/go-git` would not.
func IsRemoteEndpoint(url string) bool {
return MatchesScheme(url) || MatchesScpLike(url)
}

// FindScpLikeComponents returns the user, host, port and path of the
// given SCP-like URL.
func FindScpLikeComponents(url string) (user, host, port, path string) {
m := scpLikeURLRegExp.FindStringSubmatch(url)
return m[1], m[2], m[3], m[4]
}

// IsLocalEndpoint returns true if the given URL string specifies a
// local file endpoint. For example, on a Linux machine,
// `/home/user/src/go-git` would match as a local endpoint, but
// `https://github.com/src-d/go-git` would not.
func IsLocalEndpoint(url string) bool {
return !IsRemoteEndpoint(url)
}
98 changes: 98 additions & 0 deletions app/scaffold/pkgs/url/url_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
package url

import (
"testing"

"github.com/stretchr/testify/assert"
)

func TestMatchesScpLike(t *testing.T) {
// See https://github.com/git/git/blob/master/Documentation/urls.txt#L37
examples := []string{
// Most-extended case
"[email protected]:james/bond",
// Most-extended case with port
"[email protected]:22:james/bond",
// Most-extended case with numeric path
"[email protected]:007/bond",
// Most-extended case with port and numeric "username"
"[email protected]:22:007/bond",
// Single repo path
"[email protected]:bond",
// Single repo path with port
"[email protected]:22:bond",
// Single repo path with port and numeric repo
"[email protected]:22:007",
// Repo path ending with .git and starting with _
"[email protected]:22:_007.git",
"[email protected]:_007.git",
"[email protected]:_james.git",
"[email protected]:_james/bond.git",
}

for _, url := range examples {
t.Run(url, func(t *testing.T) {
assert.Equal(t, true, MatchesScpLike(url))
})
}
}

func TestFindScpLikeComponents(t *testing.T) {
testCases := []struct {
url, user, host, port, path string
}{
{
// Most-extended case
url: "[email protected]:james/bond", user: "git", host: "github.com", port: "", path: "james/bond",
},
{
// Most-extended case with port
url: "[email protected]:22:james/bond", user: "git", host: "github.com", port: "22", path: "james/bond",
},
{
// Most-extended case with numeric path
url: "[email protected]:007/bond", user: "git", host: "github.com", port: "", path: "007/bond",
},
{
// Most-extended case with port and numeric path
url: "[email protected]:22:007/bond", user: "git", host: "github.com", port: "22", path: "007/bond",
},
{
// Single repo path
url: "[email protected]:bond", user: "git", host: "github.com", port: "", path: "bond",
},
{
// Single repo path with port
url: "[email protected]:22:bond", user: "git", host: "github.com", port: "22", path: "bond",
},
{
// Single repo path with port and numeric path
url: "[email protected]:22:007", user: "git", host: "github.com", port: "22", path: "007",
},
{
// Repo path ending with .git and starting with _
url: "[email protected]:22:_007.git", user: "git", host: "github.com", port: "22", path: "_007.git",
},
{
// Repo path ending with .git and starting with _
url: "[email protected]:_007.git", user: "git", host: "github.com", port: "", path: "_007.git",
},
{
// Repo path ending with .git and starting with _
url: "[email protected]:_james.git", user: "git", host: "github.com", port: "", path: "_james.git",
},
{
// Repo path ending with .git and starting with _
url: "[email protected]:_james/bond.git", user: "git", host: "github.com", port: "", path: "_james/bond.git",
},
}

for _, tc := range testCases {
user, host, port, path := FindScpLikeComponents(tc.url)

assert.Equal(t, tc.user, user)
assert.Equal(t, tc.host, host)
assert.Equal(t, tc.port, port)
assert.Equal(t, tc.path, path)
}
}

0 comments on commit 5f2ed78

Please sign in to comment.