From 6d9896fc460da833bd05e68afeab9cef06be9dce Mon Sep 17 00:00:00 2001 From: Linus Gasser Date: Sat, 13 Jun 2026 22:39:53 +0200 Subject: [PATCH] feat(recipe): normalize git URLs to canonical host/path names Add NormalizeRecipeName to unify the various ways a recipe can be referenced - https/http/ssh URLs, SCP-style git@host:path, already- canonical host/path, and short catalogue names - to a single stable "host/path" form, preserving any ":version" suffix. Wire it into recipe.Get and ValidateRecipe so every entry point accepts git URLs, and add Recipe.ShortName to recover the bare recipe name from a prefixed one. --- cli/internal/validate.go | 12 +++- pkg/recipe/recipe.go | 88 +++++++++++++++++++++++++++ pkg/recipe/recipe_test.go | 125 ++++++++++++++++++++++++++++++++++++++ 3 files changed, 222 insertions(+), 3 deletions(-) diff --git a/cli/internal/validate.go b/cli/internal/validate.go index f39b9d34..e281b2ee 100644 --- a/cli/internal/validate.go +++ b/cli/internal/validate.go @@ -59,9 +59,15 @@ func ValidateRecipe(args []string, cmdName string) recipe.Recipe { log.Fatal(i18n.G("no recipe name provided")) } - if _, ok := knownRecipes[recipeName]; !ok { - if !strings.Contains(recipeName, "/") { - log.Fatal(i18n.G("no recipe '%s' exists?", recipeName)) + recipeName = recipe.NormalizeRecipeName(recipeName) + + lookupName := recipeName + if i := strings.LastIndex(lookupName, ":"); i >= 0 { + lookupName = lookupName[:i] + } + if _, ok := knownRecipes[lookupName]; !ok { + if !strings.Contains(lookupName, "/") { + log.Fatal(i18n.G("no recipe '%s' exists? pass a git URL (e.g. https://git.example.com/user/recipe) to use a recipe outside the catalogue", lookupName)) } } diff --git a/pkg/recipe/recipe.go b/pkg/recipe/recipe.go index a5fa1b6e..55dc2815 100644 --- a/pkg/recipe/recipe.go +++ b/pkg/recipe/recipe.go @@ -8,6 +8,7 @@ import ( "net/url" "os" "path" + "regexp" "sort" "strconv" "strings" @@ -121,7 +122,84 @@ type Features struct { SSO string `json:"sso"` } +// scpURLPattern matches SCP-style git URLs like git@host:path or git@host:port/path. +// Captures: 1=host(:port)?, 2=path. +var scpURLPattern = regexp.MustCompile(`^[\w.-]+@([\w.-]+(?::\d+)?):(.+)$`) + +// NormalizeRecipeName canonicalizes a recipe identifier to a stable +// "host/path" form (or returns short catalog names unchanged). Accepts: +// +// - https://host/path[.git][:version] +// - http://host/path[.git][:version] +// - ssh://git@host[:port]/path[.git][:version] +// - git@host:path[.git][:version] (SCP-style) +// - host/path[.git][:version] (already canonical) +// - short-name[:version] (catalog recipe, pass through) +// +// The optional trailing :version suffix is preserved verbatim. The .git +// suffix and trailing slashes on the path are stripped so the four URL +// forms of the same repository collapse to one canonical value. +func NormalizeRecipeName(input string) string { + input = strings.TrimSpace(input) + + // Split off the version suffix first, but only if it's not part of a + // scheme (https://) or SCP-style git@host: prefix. The simplest way is + // to detect those prefixes and treat the rest as the path-with-version. + var ( + body = input + version string + ) + + switch { + case strings.HasPrefix(input, "https://") || strings.HasPrefix(input, "http://") || strings.HasPrefix(input, "ssh://"): + u, err := url.Parse(input) + if err != nil { + return input + } + host := u.Hostname() // strip port to keep canonical form colon-free + p := strings.TrimPrefix(u.Path, "/") + p, version = splitVersion(p) + body = host + "/" + p + case scpURLPattern.MatchString(input): + m := scpURLPattern.FindStringSubmatch(input) + host := m[1] + if i := strings.Index(host, ":"); i >= 0 { + host = host[:i] // strip port + } + p, v := splitVersion(m[2]) + body = host + "/" + p + version = v + default: + body, version = splitVersion(input) + } + + body = strings.TrimSuffix(body, "/") + body = strings.TrimSuffix(body, ".git") + + if version != "" { + return body + ":" + version + } + return body +} + +// splitVersion separates a trailing ":version" suffix from a recipe path. +// It only treats the final colon-separated segment as a version when it is +// non-empty and contains no slash (a slash means the colon belonged to the +// path, e.g. a host:port or scheme separator). +func splitVersion(s string) (string, string) { + idx := strings.LastIndex(s, ":") + if idx < 0 { + return s, "" + } + candidate := s[idx+1:] + if candidate == "" || strings.Contains(candidate, "/") { + return s, "" + } + return s[:idx], candidate +} + func Get(name string) Recipe { + name = NormalizeRecipeName(name) version := "" versionRaw := "" if strings.Contains(name, ":") { @@ -209,6 +287,16 @@ func (r Recipe) String() string { return out } +// ShortName returns the final path segment of the recipe name, i.e. the +// bare recipe name without any "host/org/" prefix carried by externally +// sourced (git URL) recipes. For catalogue recipes it returns Name unchanged. +func (r Recipe) ShortName() string { + if i := strings.LastIndex(r.Name, "/"); i >= 0 { + return r.Name[i+1:] + } + return r.Name +} + func escapeRecipeName(recipeName string) string { recipeName = strings.ReplaceAll(recipeName, "/", "_") recipeName = strings.ReplaceAll(recipeName, ".", "_") diff --git a/pkg/recipe/recipe_test.go b/pkg/recipe/recipe_test.go index c623f161..be21d01a 100644 --- a/pkg/recipe/recipe_test.go +++ b/pkg/recipe/recipe_test.go @@ -88,6 +88,88 @@ func TestGet(t *testing.T) { AbraShPath: path.Join(cfg.GetAbraDir(), "recipes/mygit_org_myorg_cool-recipe/abra.sh"), }, }, + { + name: "https://mygit.org/myorg/cool-recipe", + recipe: Recipe{ + Name: "mygit.org/myorg/cool-recipe", + Dir: path.Join(cfg.GetAbraDir(), "/recipes/mygit_org_myorg_cool-recipe"), + GitURL: "https://mygit.org/myorg/cool-recipe.git", + SSHURL: "ssh://git@mygit.org/myorg/cool-recipe.git", + ComposePath: path.Join(cfg.GetAbraDir(), "recipes/mygit_org_myorg_cool-recipe/compose.yml"), + ReadmePath: path.Join(cfg.GetAbraDir(), "recipes/mygit_org_myorg_cool-recipe/README.md"), + SampleEnvPath: path.Join(cfg.GetAbraDir(), "recipes/mygit_org_myorg_cool-recipe/.env.sample"), + AbraShPath: path.Join(cfg.GetAbraDir(), "recipes/mygit_org_myorg_cool-recipe/abra.sh"), + }, + }, + { + name: "https://mygit.org/myorg/cool-recipe.git", + recipe: Recipe{ + Name: "mygit.org/myorg/cool-recipe", + Dir: path.Join(cfg.GetAbraDir(), "/recipes/mygit_org_myorg_cool-recipe"), + GitURL: "https://mygit.org/myorg/cool-recipe.git", + SSHURL: "ssh://git@mygit.org/myorg/cool-recipe.git", + ComposePath: path.Join(cfg.GetAbraDir(), "recipes/mygit_org_myorg_cool-recipe/compose.yml"), + ReadmePath: path.Join(cfg.GetAbraDir(), "recipes/mygit_org_myorg_cool-recipe/README.md"), + SampleEnvPath: path.Join(cfg.GetAbraDir(), "recipes/mygit_org_myorg_cool-recipe/.env.sample"), + AbraShPath: path.Join(cfg.GetAbraDir(), "recipes/mygit_org_myorg_cool-recipe/abra.sh"), + }, + }, + { + name: "https://mygit.org/myorg/cool-recipe.git:1.2.4", + recipe: Recipe{ + Name: "mygit.org/myorg/cool-recipe", + EnvVersion: "1.2.4", + EnvVersionRaw: "1.2.4", + Dir: path.Join(cfg.GetAbraDir(), "/recipes/mygit_org_myorg_cool-recipe"), + GitURL: "https://mygit.org/myorg/cool-recipe.git", + SSHURL: "ssh://git@mygit.org/myorg/cool-recipe.git", + ComposePath: path.Join(cfg.GetAbraDir(), "recipes/mygit_org_myorg_cool-recipe/compose.yml"), + ReadmePath: path.Join(cfg.GetAbraDir(), "recipes/mygit_org_myorg_cool-recipe/README.md"), + SampleEnvPath: path.Join(cfg.GetAbraDir(), "recipes/mygit_org_myorg_cool-recipe/.env.sample"), + AbraShPath: path.Join(cfg.GetAbraDir(), "recipes/mygit_org_myorg_cool-recipe/abra.sh"), + }, + }, + { + name: "ssh://git@mygit.org/myorg/cool-recipe.git", + recipe: Recipe{ + Name: "mygit.org/myorg/cool-recipe", + Dir: path.Join(cfg.GetAbraDir(), "/recipes/mygit_org_myorg_cool-recipe"), + GitURL: "https://mygit.org/myorg/cool-recipe.git", + SSHURL: "ssh://git@mygit.org/myorg/cool-recipe.git", + ComposePath: path.Join(cfg.GetAbraDir(), "recipes/mygit_org_myorg_cool-recipe/compose.yml"), + ReadmePath: path.Join(cfg.GetAbraDir(), "recipes/mygit_org_myorg_cool-recipe/README.md"), + SampleEnvPath: path.Join(cfg.GetAbraDir(), "recipes/mygit_org_myorg_cool-recipe/.env.sample"), + AbraShPath: path.Join(cfg.GetAbraDir(), "recipes/mygit_org_myorg_cool-recipe/abra.sh"), + }, + }, + { + name: "git@mygit.org:myorg/cool-recipe.git", + recipe: Recipe{ + Name: "mygit.org/myorg/cool-recipe", + Dir: path.Join(cfg.GetAbraDir(), "/recipes/mygit_org_myorg_cool-recipe"), + GitURL: "https://mygit.org/myorg/cool-recipe.git", + SSHURL: "ssh://git@mygit.org/myorg/cool-recipe.git", + ComposePath: path.Join(cfg.GetAbraDir(), "recipes/mygit_org_myorg_cool-recipe/compose.yml"), + ReadmePath: path.Join(cfg.GetAbraDir(), "recipes/mygit_org_myorg_cool-recipe/README.md"), + SampleEnvPath: path.Join(cfg.GetAbraDir(), "recipes/mygit_org_myorg_cool-recipe/.env.sample"), + AbraShPath: path.Join(cfg.GetAbraDir(), "recipes/mygit_org_myorg_cool-recipe/abra.sh"), + }, + }, + { + name: "git@mygit.org:myorg/cool-recipe:1.2.4", + recipe: Recipe{ + Name: "mygit.org/myorg/cool-recipe", + EnvVersion: "1.2.4", + EnvVersionRaw: "1.2.4", + Dir: path.Join(cfg.GetAbraDir(), "/recipes/mygit_org_myorg_cool-recipe"), + GitURL: "https://mygit.org/myorg/cool-recipe.git", + SSHURL: "ssh://git@mygit.org/myorg/cool-recipe.git", + ComposePath: path.Join(cfg.GetAbraDir(), "recipes/mygit_org_myorg_cool-recipe/compose.yml"), + ReadmePath: path.Join(cfg.GetAbraDir(), "recipes/mygit_org_myorg_cool-recipe/README.md"), + SampleEnvPath: path.Join(cfg.GetAbraDir(), "recipes/mygit_org_myorg_cool-recipe/.env.sample"), + AbraShPath: path.Join(cfg.GetAbraDir(), "recipes/mygit_org_myorg_cool-recipe/abra.sh"), + }, + }, } for _, tc := range testcases { @@ -101,6 +183,49 @@ func TestGet(t *testing.T) { } } +func TestNormalizeRecipeName(t *testing.T) { + cases := []struct { + in, want string + }{ + // catalog short names pass through + {"foo", "foo"}, + {"foo:1.2.3", "foo:1.2.3"}, + + // bare host/path form + {"mygit.org/myorg/cool-recipe", "mygit.org/myorg/cool-recipe"}, + {"mygit.org/myorg/cool-recipe.git", "mygit.org/myorg/cool-recipe"}, + {"mygit.org/myorg/cool-recipe.git:1.2.3", "mygit.org/myorg/cool-recipe:1.2.3"}, + {"mygit.org/myorg/cool-recipe/", "mygit.org/myorg/cool-recipe"}, + + // https:// + {"https://mygit.org/myorg/cool-recipe", "mygit.org/myorg/cool-recipe"}, + {"https://mygit.org/myorg/cool-recipe.git", "mygit.org/myorg/cool-recipe"}, + {"https://mygit.org/myorg/cool-recipe.git:1.2.3", "mygit.org/myorg/cool-recipe:1.2.3"}, + {"http://mygit.org/myorg/cool-recipe", "mygit.org/myorg/cool-recipe"}, + + // ssh://, with and without port + {"ssh://git@mygit.org/myorg/cool-recipe.git", "mygit.org/myorg/cool-recipe"}, + {"ssh://git@mygit.org:2222/myorg/cool-recipe.git", "mygit.org/myorg/cool-recipe"}, + + // SCP-style git@host:path + {"git@mygit.org:myorg/cool-recipe", "mygit.org/myorg/cool-recipe"}, + {"git@mygit.org:myorg/cool-recipe.git", "mygit.org/myorg/cool-recipe"}, + {"git@mygit.org:myorg/cool-recipe:1.2.3", "mygit.org/myorg/cool-recipe:1.2.3"}, + + // whitespace + {" https://mygit.org/myorg/cool-recipe ", "mygit.org/myorg/cool-recipe"}, + } + + for _, tc := range cases { + t.Run(tc.in, func(t *testing.T) { + got := NormalizeRecipeName(tc.in) + if got != tc.want { + t.Errorf("NormalizeRecipeName(%q) = %q, want %q", tc.in, got, tc.want) + } + }) + } +} + func TestGetVersionLabelLocalDoesNotUseTimeoutLabel(t *testing.T) { test.Setup() t.Cleanup(func() { test.Teardown() })