From d2db201931c1d59415c0088d5dbb6f885b972d54 Mon Sep 17 00:00:00 2001 From: Shane Friedman Date: Wed, 7 Feb 2024 13:11:01 -0500 Subject: [PATCH 1/2] Add support for cloning over SSH --- app/scaffold/pkgs/pkgs.go | 51 +++++++++++++--- app/scaffold/pkgs/url/url.go | 48 +++++++++++++++ app/scaffold/pkgs/url/url_test.go | 98 +++++++++++++++++++++++++++++++ 3 files changed, 188 insertions(+), 9 deletions(-) create mode 100644 app/scaffold/pkgs/url/url.go create mode 100644 app/scaffold/pkgs/url/url_test.go diff --git a/app/scaffold/pkgs/pkgs.go b/app/scaffold/pkgs/pkgs.go index a34f34b..03abba7 100644 --- a/app/scaffold/pkgs/pkgs.go +++ b/app/scaffold/pkgs/pkgs.go @@ -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: @@ -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 @@ -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 @@ -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 { @@ -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 } diff --git a/app/scaffold/pkgs/url/url.go b/app/scaffold/pkgs/url/url.go new file mode 100644 index 0000000..8fb20af --- /dev/null +++ b/app/scaffold/pkgs/url/url.go @@ -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[^@]+)@)?(?P[^:\s]+):(?:(?P[0-9]{1,5}):)?(?P[^\\].*)$`) +) + +// 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) +} diff --git a/app/scaffold/pkgs/url/url_test.go b/app/scaffold/pkgs/url/url_test.go new file mode 100644 index 0000000..e30cd93 --- /dev/null +++ b/app/scaffold/pkgs/url/url_test.go @@ -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 + "git@github.com:james/bond", + // Most-extended case with port + "git@github.com:22:james/bond", + // Most-extended case with numeric path + "git@github.com:007/bond", + // Most-extended case with port and numeric "username" + "git@github.com:22:007/bond", + // Single repo path + "git@github.com:bond", + // Single repo path with port + "git@github.com:22:bond", + // Single repo path with port and numeric repo + "git@github.com:22:007", + // Repo path ending with .git and starting with _ + "git@github.com:22:_007.git", + "git@github.com:_007.git", + "git@github.com:_james.git", + "git@github.com:_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: "git@github.com:james/bond", user: "git", host: "github.com", port: "", path: "james/bond", + }, + { + // Most-extended case with port + url: "git@github.com:22:james/bond", user: "git", host: "github.com", port: "22", path: "james/bond", + }, + { + // Most-extended case with numeric path + url: "git@github.com:007/bond", user: "git", host: "github.com", port: "", path: "007/bond", + }, + { + // Most-extended case with port and numeric path + url: "git@github.com:22:007/bond", user: "git", host: "github.com", port: "22", path: "007/bond", + }, + { + // Single repo path + url: "git@github.com:bond", user: "git", host: "github.com", port: "", path: "bond", + }, + { + // Single repo path with port + url: "git@github.com:22:bond", user: "git", host: "github.com", port: "22", path: "bond", + }, + { + // Single repo path with port and numeric path + url: "git@github.com:22:007", user: "git", host: "github.com", port: "22", path: "007", + }, + { + // Repo path ending with .git and starting with _ + url: "git@github.com:22:_007.git", user: "git", host: "github.com", port: "22", path: "_007.git", + }, + { + // Repo path ending with .git and starting with _ + url: "git@github.com:_007.git", user: "git", host: "github.com", port: "", path: "_007.git", + }, + { + // Repo path ending with .git and starting with _ + url: "git@github.com:_james.git", user: "git", host: "github.com", port: "", path: "_james.git", + }, + { + // Repo path ending with .git and starting with _ + url: "git@github.com:_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) + } +} From f0ad218c319b2530e9c698ac78b59731c1649b06 Mon Sep 17 00:00:00 2001 From: Shane Friedman Date: Wed, 7 Feb 2024 14:08:42 -0500 Subject: [PATCH 2/2] Add interactive prompt for HTTP auth when cloning a private repo --- app/scaffold/pkgs/resolver.go | 51 ++++++++++++++++++++++++++++++++++- 1 file changed, 50 insertions(+), 1 deletion(-) diff --git a/app/scaffold/pkgs/resolver.go b/app/scaffold/pkgs/resolver.go index 78e0aad..e294f5c 100644 --- a/app/scaffold/pkgs/resolver.go +++ b/app/scaffold/pkgs/resolver.go @@ -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") @@ -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 @@ -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 +}