Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Support SSH endpoints and basic auth for HTTP endpoints #98

Merged
merged 2 commits into from
Feb 7, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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,
},
})
}
Comment on lines +57 to +67
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I must have deleted this comment on accident, whoops!


I'm wondering if this should be configuration driven in additon to being interactive? Interactive is nice, but if you're pulling down a few it would be nice to able to define these in the conf file

Here's what that could look like

auth:
  - match: ^https://github.com.*
    basic:
      username: hay-kot 
      password: my-password
  - match: ^https://mylocalgit.com.*
    basic:
      username: ${MY_LOCAL_GIT_USERNAME}
      password: ${MY_LOCAL_GIT_PASSWORD}

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Oh very nice, yeah I think this would be great. Would you be alright with merging this as a first pass, and then adding config support as a fast follow? I'm happy to take a stab at the config work, too! But I'd also like to get this in :D

Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sounds good!

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)
}
}
Loading