feat(app): support git-URL recipes in 'abra app new'

Allow `abra app new <git-url>` to use a recipe from outside the
catalogue. On clone, a `.abra-source` sidecar records the canonical
host/path name (the on-disk directory escapes "/" and "." lossily), and
IsClean ignores it. When templating the app's .env, a `RECIPE=<canonical
name>` line is injected so a later `abra app deploy`, possibly on another
machine, re-fetches the recipe from the same git source. `recipe ls` now
shows a source column listing these external recipes alongside catalogue
ones.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Linus Gasser
2026-06-13 22:40:10 +02:00
parent 95220049e8
commit f230f89dcb
7 changed files with 193 additions and 2 deletions

View File

@ -32,6 +32,18 @@ deploy <domain>" to do so.
You can see what recipes are available (i.e. values for the [recipe] argument)
by running "abra recipe ls".
In addition to short catalogue names, [recipe] also accepts arbitrary git
URLs to use a recipe from outside the catalogue (e.g. a fork or work in
progress). Any of these forms is accepted:
abra app new git.example.com/user/recipe
abra app new https://git.example.com/user/recipe
abra app new git@git.example.com:user/recipe
In that case a RECIPE=<canonical name> line is written to the app's .env
file so a subsequent "abra app deploy" (on this or another machine) will
re-fetch the recipe from the same git source.
Recipe commit hashes are supported values for "[version]".
Passing the "--secrets/-S" flag will automatically generate secrets for your
@ -295,7 +307,7 @@ func ensureDomainFlag(recipe recipePkg.Recipe, server string) error {
if appDomain == "" && !internal.NoInput {
prompt := &survey.Input{
Message: i18n.G("Specify app domain"),
Default: fmt.Sprintf("%s.%s", recipe.Name, server),
Default: fmt.Sprintf("%s.%s", recipe.ShortName(), server),
}
if err := survey.AskOne(prompt, &appDomain); err != nil {
return err
@ -306,6 +318,10 @@ func ensureDomainFlag(recipe recipePkg.Recipe, server string) error {
return errors.New(i18n.G("no domain provided"))
}
if strings.ContainsAny(appDomain, "/\\") {
return errors.New(i18n.G("invalid domain '%s': must not contain '/' or '\\'", appDomain))
}
return nil
}

View File

@ -2,11 +2,13 @@ package recipe
import (
"fmt"
"path"
"sort"
"strconv"
"strings"
"coopcloud.tech/abra/cli/internal"
"coopcloud.tech/abra/pkg/config"
"coopcloud.tech/abra/pkg/formatter"
"coopcloud.tech/abra/pkg/i18n"
"coopcloud.tech/abra/pkg/log"
@ -41,6 +43,7 @@ var RecipeListCommand = &cobra.Command{
headers := []string{
i18n.G("name"),
i18n.G("source"),
i18n.G("category"),
i18n.G("status"),
i18n.G("healthcheck"),
@ -56,6 +59,7 @@ var RecipeListCommand = &cobra.Command{
for _, recipe := range recipes {
row := []string{
recipe.Name,
i18n.G("catalogue"),
recipe.Category,
strconv.Itoa(recipe.Features.Status),
recipe.Features.Healthcheck,
@ -76,6 +80,23 @@ var RecipeListCommand = &cobra.Command{
}
}
externals := externalRecipes(catl)
sort.Strings(externals)
for _, name := range externals {
row := []string{
name,
i18n.G("external"),
"-", "-", "-", "-", "-", "-", "-",
}
if pattern != "" {
if !strings.Contains(name, pattern) {
continue
}
}
table.Row(row...)
rows = append(rows, row)
}
if len(rows) > 0 {
if internal.MachineReadable {
out, err := formatter.ToJSON(headers, rows)
@ -93,6 +114,30 @@ var RecipeListCommand = &cobra.Command{
},
}
// externalRecipes returns canonical names of locally-cloned recipes that
// were sourced from an arbitrary git URL (i.e. they carry a .abra-source
// sidecar) and are not already listed in the catalogue.
func externalRecipes(catl recipe.RecipeCatalogue) []string {
dirs, err := recipe.GetRecipesLocal()
if err != nil {
log.Debug(i18n.G("can't read local recipes: %s", err))
return nil
}
var names []string
for _, dir := range dirs {
canonical := recipe.ReadRecipeSource(path.Join(config.RECIPES_DIR, dir))
if canonical == "" {
continue
}
if _, inCatalogue := catl[canonical]; inCatalogue {
continue
}
names = append(names, canonical)
}
return names
}
var (
pattern string
)