From 56cf5ca8664213e4fa868f3bbff494b89f40c526 Mon Sep 17 00:00:00 2001 From: juancadev-io Date: Fri, 6 Mar 2026 18:00:32 -0500 Subject: [PATCH 1/3] feat(addon): add registry and manifest installer workflow --- internal/addon/http_test_helpers_test.go | 21 ++ internal/addon/installer.go | 179 +++++++++ internal/addon/installer_test.go | 452 +++++++++++++++++++++++ internal/addon/registry.go | 124 +++++++ internal/addon/registry_test.go | 291 +++++++++++++++ internal/addon/step.go | 104 ++++++ internal/addon/step_test.go | 174 +++++++++ 7 files changed, 1345 insertions(+) create mode 100644 internal/addon/http_test_helpers_test.go create mode 100644 internal/addon/installer.go create mode 100644 internal/addon/installer_test.go create mode 100644 internal/addon/registry.go create mode 100644 internal/addon/registry_test.go create mode 100644 internal/addon/step.go create mode 100644 internal/addon/step_test.go diff --git a/internal/addon/http_test_helpers_test.go b/internal/addon/http_test_helpers_test.go new file mode 100644 index 0000000..e145f29 --- /dev/null +++ b/internal/addon/http_test_helpers_test.go @@ -0,0 +1,21 @@ +package addon + +import ( + "net/http" + "testing" +) + +type roundTripFunc func(*http.Request) (*http.Response, error) + +func (fn roundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) { + return fn(req) +} + +func stubHTTPTransport(t *testing.T, fn roundTripFunc) { + t.Helper() + previous := http.DefaultTransport + http.DefaultTransport = fn + t.Cleanup(func() { + http.DefaultTransport = previous + }) +} diff --git a/internal/addon/installer.go b/internal/addon/installer.go new file mode 100644 index 0000000..2c7b150 --- /dev/null +++ b/internal/addon/installer.go @@ -0,0 +1,179 @@ +package addon + +import ( + "fmt" + "go/format" + "os" + "os/exec" + "strings" +) + +var execCommand = exec.Command + +// Install executes all steps defined in the manifest inside the current Keel project. +func Install(m *Manifest) error { + for _, step := range m.Steps { + if err := runStep(step, m.Name); err != nil { + return fmt.Errorf("step %q failed: %w", step.Type, err) + } + } + + if err := runGoModTidy(); err != nil { + fmt.Printf(" ⚠ %v\n", err) + } + + return nil +} + +func runStep(s Step, addonName string) error { + switch s.Type { + case "go_get": + return stepGoGet(s) + case "env": + return stepEnv(s) + case "main_import": + return stepMainImport(s) + case "main_code": + return stepMainCode(s) + default: + return fmt.Errorf("unknown step type %q in %s", s.Type, addonName) + } +} + +// stepGoGet runs: go get +func stepGoGet(s Step) error { + pkg := strings.TrimSpace(s.Package) + if pkg == "" { + return fmt.Errorf("go_get step is missing 'package'") + } + + target := resolveGoGetTarget(pkg) + fmt.Printf(" → go get %s\n", target) + cmd := execCommand("go", "get", target) + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + return cmd.Run() +} + +func resolveGoGetTarget(pkg string) string { + if strings.Contains(pkg, "@") { + return pkg + } + return pkg + "@latest" +} + +func runGoModTidy() error { + fmt.Printf(" → go mod tidy\n") + cmd := execCommand("go", "mod", "tidy") + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + if err := cmd.Run(); err != nil { + return fmt.Errorf("go mod tidy failed: %w", err) + } + return nil +} + +// stepEnv adds KEY=example to .env if the key is not already present. +func stepEnv(s Step) error { + if s.Key == "" { + return fmt.Errorf("env step is missing 'key'") + } + const envFile = ".env" + + existing, _ := os.ReadFile(envFile) + if strings.Contains(string(existing), s.Key+"=") { + return nil // already set + } + + line := s.Key + "=" + s.Example + "\n" + f, err := os.OpenFile(envFile, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644) + if err != nil { + return err + } + defer f.Close() + + _, err = f.WriteString(line) + if err == nil { + fmt.Printf(" → added %s to .env\n", s.Key) + } + return err +} + +// stepMainImport adds an import path to cmd/main.go. +func stepMainImport(s Step) error { + if s.Path == "" { + return fmt.Errorf("main_import step is missing 'path'") + } + return updateMainGo(func(content string) string { + importPath := fmt.Sprintf("%q", s.Path) + if strings.Contains(content, importPath) { + return content + } + fmt.Printf(" → added import %s to cmd/main.go\n", s.Path) + return addImport(content, importPath) + }) +} + +// stepMainCode inserts a code block before app.Listen() in cmd/main.go. +// The guard field prevents duplicate insertion. +func stepMainCode(s Step) error { + if s.Code == "" { + return fmt.Errorf("main_code step is missing 'code'") + } + return updateMainGo(func(content string) string { + if s.Guard != "" && strings.Contains(content, s.Guard) { + return content // already wired + } + fmt.Printf(" → wired %s into cmd/main.go\n", s.Guard) + return addMainLine(content, "\t"+strings.ReplaceAll(s.Code, "\n", "\n\t")) + }) +} + +func updateMainGo(transform func(string) string) error { + const mainPath = "cmd/main.go" + body, err := os.ReadFile(mainPath) + if err != nil { + return fmt.Errorf("cmd/main.go not found — run keel add inside a Keel project") + } + + original := string(body) + updated := transform(original) + if updated == original { + return nil + } + + formatted, err := format.Source([]byte(updated)) + if err == nil { + updated = string(formatted) + } + return os.WriteFile(mainPath, []byte(updated), 0644) +} + +func addImport(content, importPath string) string { + start := strings.Index(content, "import (") + if start == -1 { + return content + } + end := strings.Index(content[start:], ")") + if end == -1 { + return content + } + end += start + return content[:end] + "\n\t" + importPath + content[end:] +} + +func addMainLine(content, line string) string { + markers := []string{ + "\tlog.Fatal(app.Listen())", + "\tif err := app.Listen(); err != nil {", + "log.Fatal(app.Listen())", + "if err := app.Listen(); err != nil {", + } + for _, marker := range markers { + idx := strings.Index(content, marker) + if idx != -1 { + return content[:idx] + line + "\n\n" + content[idx:] + } + } + return content +} diff --git a/internal/addon/installer_test.go b/internal/addon/installer_test.go new file mode 100644 index 0000000..6c10c66 --- /dev/null +++ b/internal/addon/installer_test.go @@ -0,0 +1,452 @@ +package addon + +import ( + "os" + "os/exec" + "path/filepath" + "strings" + "testing" +) + +func resetExecCommand(t *testing.T) { + t.Helper() + previous := execCommand + t.Cleanup(func() { + execCommand = previous + }) +} + +func writeMainFile(t *testing.T, root, body string) string { + t.Helper() + path := filepath.Join(root, "cmd", "main.go") + if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil { + t.Fatalf("failed to create cmd directory: %v", err) + } + if err := os.WriteFile(path, []byte(body), 0644); err != nil { + t.Fatalf("failed to write cmd/main.go: %v", err) + } + return path +} + +const sampleMain = `package main + +import ( + "log" +) + +func main() { + log.Fatal(app.Listen()) +} +` + +func TestInstall(t *testing.T) { + t.Run("success", func(t *testing.T) { + root := t.TempDir() + withWorkingDir(t, root) + resetExecCommand(t) + writeMainFile(t, root, sampleMain) + + tidyCalled := false + execCommand = func(name string, args ...string) *exec.Cmd { + if name != "go" || len(args) != 2 || args[0] != "mod" || args[1] != "tidy" { + t.Fatalf("unexpected command: %s %#v", name, args) + } + tidyCalled = true + return exec.Command("true") + } + + manifest := &Manifest{ + Name: "gorm", + Steps: []Step{ + {Type: "env", Key: "DB_HOST", Example: "localhost"}, + {Type: "main_import", Path: "github.com/acme/addon"}, + {Type: "main_code", Guard: "app.Use(addon.Middleware())", Code: "app.Use(addon.Middleware())"}, + }, + } + + if err := Install(manifest); err != nil { + t.Fatalf("Install returned error: %v", err) + } + + envContent, err := os.ReadFile(filepath.Join(root, ".env")) + if err != nil { + t.Fatalf("failed to read .env: %v", err) + } + if !strings.Contains(string(envContent), "DB_HOST=localhost") { + t.Fatalf("expected env var to be added, got %q", string(envContent)) + } + + mainContent, err := os.ReadFile(filepath.Join(root, "cmd", "main.go")) + if err != nil { + t.Fatalf("failed to read cmd/main.go: %v", err) + } + text := string(mainContent) + if !strings.Contains(text, `"github.com/acme/addon"`) { + t.Fatalf("expected import to be added, got %q", text) + } + if !strings.Contains(text, "app.Use(addon.Middleware())") { + t.Fatalf("expected code to be wired, got %q", text) + } + if !tidyCalled { + t.Fatalf("expected go mod tidy to be executed") + } + }) + + t.Run("wraps failing step", func(t *testing.T) { + err := Install(&Manifest{ + Name: "broken", + Steps: []Step{{Type: "unknown"}}, + }) + if err == nil || !strings.Contains(err.Error(), `step "unknown" failed`) { + t.Fatalf("expected wrapped step error, got %v", err) + } + }) + + t.Run("continues on tidy error", func(t *testing.T) { + root := t.TempDir() + withWorkingDir(t, root) + resetExecCommand(t) + + execCommand = func(name string, args ...string) *exec.Cmd { + if name != "go" || len(args) != 2 || args[0] != "mod" || args[1] != "tidy" { + t.Fatalf("unexpected command: %s %#v", name, args) + } + return exec.Command("false") + } + + if err := Install(&Manifest{Name: "gorm"}); err != nil { + t.Fatalf("expected install to continue on tidy failure, got %v", err) + } + }) +} + +func TestRunStep(t *testing.T) { + t.Run("go_get dispatch", func(t *testing.T) { + resetExecCommand(t) + + execCommand = func(name string, args ...string) *exec.Cmd { + if name != "go" { + t.Fatalf("unexpected command name: %s", name) + } + if len(args) != 2 || args[0] != "get" || args[1] != "github.com/acme/addon@latest" { + t.Fatalf("unexpected args: %#v", args) + } + return exec.Command("true") + } + + if err := runStep(Step{Type: "go_get", Package: "github.com/acme/addon"}, "addon"); err != nil { + t.Fatalf("runStep(go_get) returned error: %v", err) + } + }) + + t.Run("env dispatch", func(t *testing.T) { + root := t.TempDir() + withWorkingDir(t, root) + + if err := runStep(Step{Type: "env", Key: "TOKEN", Example: "abc"}, "addon"); err != nil { + t.Fatalf("runStep(env) returned error: %v", err) + } + }) + + t.Run("main_import dispatch", func(t *testing.T) { + root := t.TempDir() + withWorkingDir(t, root) + writeMainFile(t, root, sampleMain) + + if err := runStep(Step{Type: "main_import", Path: "github.com/acme/addon"}, "addon"); err != nil { + t.Fatalf("runStep(main_import) returned error: %v", err) + } + }) + + t.Run("main_code dispatch", func(t *testing.T) { + root := t.TempDir() + withWorkingDir(t, root) + writeMainFile(t, root, sampleMain) + + if err := runStep(Step{Type: "main_code", Code: "app.Use(x)", Guard: "app.Use(x)"}, "addon"); err != nil { + t.Fatalf("runStep(main_code) returned error: %v", err) + } + }) + + t.Run("unknown type", func(t *testing.T) { + err := runStep(Step{Type: "unknown"}, "addon") + if err == nil || !strings.Contains(err.Error(), "unknown step type") { + t.Fatalf("expected unknown type error, got %v", err) + } + }) +} + +func TestStepGoGet(t *testing.T) { + t.Run("missing package", func(t *testing.T) { + err := stepGoGet(Step{}) + if err == nil || !strings.Contains(err.Error(), "missing 'package'") { + t.Fatalf("expected missing package error, got %v", err) + } + }) + + t.Run("command error", func(t *testing.T) { + resetExecCommand(t) + execCommand = func(name string, args ...string) *exec.Cmd { + return exec.Command("false") + } + + err := stepGoGet(Step{Package: "github.com/acme/fail"}) + if err == nil { + t.Fatalf("expected command error, got nil") + } + }) + + t.Run("keeps explicit version", func(t *testing.T) { + resetExecCommand(t) + + execCommand = func(name string, args ...string) *exec.Cmd { + if name != "go" { + t.Fatalf("unexpected command name: %s", name) + } + if len(args) != 2 || args[0] != "get" || args[1] != "github.com/acme/addon@v1.0.1" { + t.Fatalf("unexpected args: %#v", args) + } + return exec.Command("true") + } + + if err := stepGoGet(Step{Package: "github.com/acme/addon@v1.0.1"}); err != nil { + t.Fatalf("stepGoGet returned error: %v", err) + } + }) +} + +func TestResolveGoGetTarget(t *testing.T) { + tests := []struct { + name string + pkg string + want string + }{ + { + name: "adds latest when version is missing", + pkg: "github.com/acme/addon", + want: "github.com/acme/addon@latest", + }, + { + name: "keeps explicit semver", + pkg: "github.com/acme/addon@v1.0.1", + want: "github.com/acme/addon@v1.0.1", + }, + { + name: "keeps latest when already explicit", + pkg: "github.com/acme/addon@latest", + want: "github.com/acme/addon@latest", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := resolveGoGetTarget(tt.pkg) + if got != tt.want { + t.Fatalf("resolveGoGetTarget(%q) = %q, want %q", tt.pkg, got, tt.want) + } + }) + } +} + +func TestStepEnv(t *testing.T) { + t.Run("missing key", func(t *testing.T) { + err := stepEnv(Step{}) + if err == nil || !strings.Contains(err.Error(), "missing 'key'") { + t.Fatalf("expected missing key error, got %v", err) + } + }) + + t.Run("adds value and is idempotent", func(t *testing.T) { + root := t.TempDir() + withWorkingDir(t, root) + + if err := stepEnv(Step{Key: "API_KEY", Example: "secret"}); err != nil { + t.Fatalf("first stepEnv returned error: %v", err) + } + if err := stepEnv(Step{Key: "API_KEY", Example: "other"}); err != nil { + t.Fatalf("second stepEnv returned error: %v", err) + } + + content, err := os.ReadFile(filepath.Join(root, ".env")) + if err != nil { + t.Fatalf("failed to read .env: %v", err) + } + text := string(content) + if strings.Count(text, "API_KEY=") != 1 { + t.Fatalf("expected API_KEY once, got %q", text) + } + if !strings.Contains(text, "API_KEY=secret") { + t.Fatalf("expected initial API_KEY value, got %q", text) + } + }) +} + +func TestStepMainImport(t *testing.T) { + t.Run("missing path", func(t *testing.T) { + err := stepMainImport(Step{}) + if err == nil || !strings.Contains(err.Error(), "missing 'path'") { + t.Fatalf("expected missing path error, got %v", err) + } + }) + + t.Run("missing cmd main", func(t *testing.T) { + root := t.TempDir() + withWorkingDir(t, root) + + err := stepMainImport(Step{Path: "github.com/acme/addon"}) + if err == nil || !strings.Contains(err.Error(), "run keel add inside a Keel project") { + t.Fatalf("expected missing main.go error, got %v", err) + } + }) + + t.Run("adds import once", func(t *testing.T) { + root := t.TempDir() + withWorkingDir(t, root) + writeMainFile(t, root, sampleMain) + + step := Step{Path: "github.com/acme/addon"} + if err := stepMainImport(step); err != nil { + t.Fatalf("first stepMainImport returned error: %v", err) + } + if err := stepMainImport(step); err != nil { + t.Fatalf("second stepMainImport returned error: %v", err) + } + + content, err := os.ReadFile(filepath.Join(root, "cmd", "main.go")) + if err != nil { + t.Fatalf("failed to read main.go: %v", err) + } + if strings.Count(string(content), `"github.com/acme/addon"`) != 1 { + t.Fatalf("expected import once, got %q", string(content)) + } + }) +} + +func TestStepMainCode(t *testing.T) { + t.Run("missing code", func(t *testing.T) { + err := stepMainCode(Step{}) + if err == nil || !strings.Contains(err.Error(), "missing 'code'") { + t.Fatalf("expected missing code error, got %v", err) + } + }) + + t.Run("adds code before listen and guards duplicates", func(t *testing.T) { + root := t.TempDir() + withWorkingDir(t, root) + writeMainFile(t, root, sampleMain) + + step := Step{ + Code: "app.Use(gorm.Middleware())", + Guard: "app.Use(gorm.Middleware())", + } + + if err := stepMainCode(step); err != nil { + t.Fatalf("first stepMainCode returned error: %v", err) + } + if err := stepMainCode(step); err != nil { + t.Fatalf("second stepMainCode returned error: %v", err) + } + + content, err := os.ReadFile(filepath.Join(root, "cmd", "main.go")) + if err != nil { + t.Fatalf("failed to read main.go: %v", err) + } + text := string(content) + if strings.Count(text, "app.Use(gorm.Middleware())") != 1 { + t.Fatalf("expected guard code once, got %q", text) + } + if strings.Index(text, "app.Use(gorm.Middleware())") > strings.Index(text, "log.Fatal(app.Listen())") { + t.Fatalf("expected inserted code before app.Listen call, got %q", text) + } + }) +} + +func TestUpdateMainGo(t *testing.T) { + t.Run("missing file", func(t *testing.T) { + root := t.TempDir() + withWorkingDir(t, root) + + err := updateMainGo(func(content string) string { return content + "\n" }) + if err == nil || !strings.Contains(err.Error(), "cmd/main.go not found") { + t.Fatalf("expected missing file error, got %v", err) + } + }) + + t.Run("no changes", func(t *testing.T) { + root := t.TempDir() + withWorkingDir(t, root) + mainPath := writeMainFile(t, root, sampleMain) + + before, err := os.ReadFile(mainPath) + if err != nil { + t.Fatalf("failed to read main.go before update: %v", err) + } + + if err := updateMainGo(func(content string) string { return content }); err != nil { + t.Fatalf("expected nil error on no-op transform, got %v", err) + } + + after, err := os.ReadFile(mainPath) + if err != nil { + t.Fatalf("failed to read main.go after update: %v", err) + } + if string(after) != string(before) { + t.Fatalf("expected main.go to remain unchanged") + } + }) +} + +func TestAddImport(t *testing.T) { + t.Run("adds import in block", func(t *testing.T) { + original := "package main\n\nimport (\n\t\"log\"\n)\n" + updated := addImport(original, `"github.com/acme/addon"`) + if !strings.Contains(updated, `"github.com/acme/addon"`) { + t.Fatalf("expected import to be added, got %q", updated) + } + }) + + t.Run("returns original when import block missing", func(t *testing.T) { + original := "package main\n" + updated := addImport(original, `"github.com/acme/addon"`) + if updated != original { + t.Fatalf("expected unchanged content, got %q", updated) + } + }) +} + +func TestAddMainLine(t *testing.T) { + tests := []struct { + name string + content string + }{ + { + name: "tabbed log fatal marker", + content: "func main() {\n\tlog.Fatal(app.Listen())\n}\n", + }, + { + name: "if err marker", + content: "func main() {\n\tif err := app.Listen(); err != nil {\n\t\tpanic(err)\n\t}\n}\n", + }, + { + name: "non-tabbed marker", + content: "func main() {\nlog.Fatal(app.Listen())\n}\n", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + updated := addMainLine(tt.content, "\tapp.Use(middleware)") + if !strings.Contains(updated, "app.Use(middleware)") { + t.Fatalf("expected line insertion, got %q", updated) + } + }) + } + + t.Run("returns original when marker missing", func(t *testing.T) { + original := "func main() {\n\tprintln(\"hello\")\n}\n" + updated := addMainLine(original, "\tapp.Use(middleware)") + if updated != original { + t.Fatalf("expected unchanged content when marker is missing") + } + }) +} diff --git a/internal/addon/registry.go b/internal/addon/registry.go new file mode 100644 index 0000000..c1086d5 --- /dev/null +++ b/internal/addon/registry.go @@ -0,0 +1,124 @@ +package addon + +import ( + "encoding/json" + "fmt" + "io" + "net/http" + "os" + "path/filepath" + "time" +) + +const ( + registryURL = "https://raw.githubusercontent.com/slice-soft/ss-keel-addons/main/registry.json" + registryCacheTTL = time.Hour +) + +// RegistryEntry is a single addon entry in the official registry. +type RegistryEntry struct { + Alias string `json:"alias"` + Repo string `json:"repo"` + Description string `json:"description"` + Official bool `json:"official"` +} + +// Registry is the full addon registry fetched from ss-keel-addons. +type Registry struct { + Version string `json:"version"` + Addons []RegistryEntry `json:"addons"` +} + +// ResolveRepo maps an alias (e.g. "gorm") to a Go module path. +// Returns ("", false) when the alias is not in the registry. +func (r *Registry) ResolveRepo(alias string) (string, bool) { + for _, a := range r.Addons { + if a.Alias == alias { + return a.Repo, true + } + } + return "", false +} + +// FetchRegistry returns the addon registry, using a local cache when fresh. +// Pass forceRefresh=true to bypass the cache. +func FetchRegistry(forceRefresh bool) (*Registry, error) { + cachePath := registryCachePath() + + if !forceRefresh { + if reg, ok := loadCachedRegistry(cachePath); ok { + return reg, nil + } + } + + reg, err := fetchRegistryFromNetwork() + if err != nil { + // Fall back to stale cache rather than failing completely. + if reg, ok := loadCachedRegistry(cachePath); ok { + return reg, nil + } + return nil, fmt.Errorf("could not fetch addon registry: %w", err) + } + + saveRegistryCache(cachePath, reg) + return reg, nil +} + +func fetchRegistryFromNetwork() (*Registry, error) { + client := &http.Client{Timeout: 10 * time.Second} + resp, err := client.Get(registryURL) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("registry returned HTTP %d", resp.StatusCode) + } + + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, err + } + + var reg Registry + if err := json.Unmarshal(body, ®); err != nil { + return nil, fmt.Errorf("invalid registry format: %w", err) + } + return ®, nil +} + +type cachedRegistry struct { + FetchedAt time.Time `json:"fetched_at"` + Registry Registry `json:"registry"` +} + +func loadCachedRegistry(path string) (*Registry, bool) { + data, err := os.ReadFile(path) + if err != nil { + return nil, false + } + var cached cachedRegistry + if err := json.Unmarshal(data, &cached); err != nil { + return nil, false + } + if time.Since(cached.FetchedAt) > registryCacheTTL { + return nil, false + } + return &cached.Registry, true +} + +func saveRegistryCache(path string, reg *Registry) { + os.MkdirAll(filepath.Dir(path), 0755) + cached := cachedRegistry{FetchedAt: time.Now(), Registry: *reg} + data, err := json.Marshal(cached) + if err != nil { + return + } + os.WriteFile(path, data, 0644) +} + +func registryCachePath() string { + home, _ := os.UserHomeDir() + return filepath.Join(home, ".keel", "registry.json") +} diff --git a/internal/addon/registry_test.go b/internal/addon/registry_test.go new file mode 100644 index 0000000..da4e797 --- /dev/null +++ b/internal/addon/registry_test.go @@ -0,0 +1,291 @@ +package addon + +import ( + "encoding/json" + "errors" + "net/http" + "os" + "path/filepath" + "strings" + "testing" + "time" +) + +func writeCachedRegistry(t *testing.T, path string, cache cachedRegistry) { + t.Helper() + + if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil { + t.Fatalf("failed to create cache dir: %v", err) + } + data, err := json.Marshal(cache) + if err != nil { + t.Fatalf("failed to marshal cache: %v", err) + } + if err := os.WriteFile(path, data, 0644); err != nil { + t.Fatalf("failed to write cache: %v", err) + } +} + +func TestResolveRepo(t *testing.T) { + reg := &Registry{ + Addons: []RegistryEntry{ + {Alias: "gorm", Repo: "github.com/slice-soft/ss-keel-gorm"}, + }, + } + + repo, ok := reg.ResolveRepo("gorm") + if !ok || repo != "github.com/slice-soft/ss-keel-gorm" { + t.Fatalf("unexpected resolve result: repo=%q ok=%t", repo, ok) + } + + repo, ok = reg.ResolveRepo("unknown") + if ok || repo != "" { + t.Fatalf("expected missing alias to return empty,false got repo=%q ok=%t", repo, ok) + } +} + +func TestFetchRegistryFromNetwork(t *testing.T) { + t.Run("success", func(t *testing.T) { + stubHTTPTransport(t, func(req *http.Request) (*http.Response, error) { + if req.URL.String() != registryURL { + t.Fatalf("unexpected registry URL: %s", req.URL.String()) + } + body := `{"version":"1","addons":[{"alias":"gorm","repo":"github.com/slice-soft/ss-keel-gorm","official":true}]}` + return &http.Response{ + StatusCode: http.StatusOK, + Body: ioNopCloser(body), + Header: make(http.Header), + }, nil + }) + + reg, err := fetchRegistryFromNetwork() + if err != nil { + t.Fatalf("expected nil error, got %v", err) + } + if reg.Version != "1" || len(reg.Addons) != 1 || reg.Addons[0].Alias != "gorm" { + t.Fatalf("unexpected registry: %#v", reg) + } + }) + + t.Run("request error", func(t *testing.T) { + stubHTTPTransport(t, func(req *http.Request) (*http.Response, error) { + return nil, errors.New("network down") + }) + + _, err := fetchRegistryFromNetwork() + if err == nil { + t.Fatalf("expected request error, got nil") + } + }) + + t.Run("status error", func(t *testing.T) { + stubHTTPTransport(t, func(req *http.Request) (*http.Response, error) { + return &http.Response{ + StatusCode: http.StatusBadGateway, + Body: ioNopCloser("bad gateway"), + Header: make(http.Header), + }, nil + }) + + _, err := fetchRegistryFromNetwork() + if err == nil || !strings.Contains(err.Error(), "HTTP 502") { + t.Fatalf("expected status error, got %v", err) + } + }) + + t.Run("invalid json", func(t *testing.T) { + stubHTTPTransport(t, func(req *http.Request) (*http.Response, error) { + return &http.Response{ + StatusCode: http.StatusOK, + Body: ioNopCloser("{invalid"), + Header: make(http.Header), + }, nil + }) + + _, err := fetchRegistryFromNetwork() + if err == nil || !strings.Contains(err.Error(), "invalid registry format") { + t.Fatalf("expected json format error, got %v", err) + } + }) +} + +func TestLoadCachedRegistry(t *testing.T) { + cachePath := filepath.Join(t.TempDir(), "registry.json") + + if reg, ok := loadCachedRegistry(cachePath); ok || reg != nil { + t.Fatalf("expected missing cache to return nil,false") + } + + if err := os.WriteFile(cachePath, []byte("{invalid"), 0644); err != nil { + t.Fatalf("failed to write invalid cache: %v", err) + } + if reg, ok := loadCachedRegistry(cachePath); ok || reg != nil { + t.Fatalf("expected invalid cache to return nil,false") + } + + writeCachedRegistry(t, cachePath, cachedRegistry{ + FetchedAt: time.Now().Add(-2 * registryCacheTTL), + Registry: Registry{Version: "expired"}, + }) + if reg, ok := loadCachedRegistry(cachePath); ok || reg != nil { + t.Fatalf("expected expired cache to return nil,false") + } + + writeCachedRegistry(t, cachePath, cachedRegistry{ + FetchedAt: time.Now(), + Registry: Registry{ + Version: "fresh", + Addons: []RegistryEntry{{Alias: "gorm", Repo: "github.com/slice-soft/ss-keel-gorm"}}, + }, + }) + reg, ok := loadCachedRegistry(cachePath) + if !ok || reg == nil { + t.Fatalf("expected fresh cache to load") + } + if reg.Version != "fresh" || len(reg.Addons) != 1 || reg.Addons[0].Alias != "gorm" { + t.Fatalf("unexpected cached registry: %#v", reg) + } +} + +func TestSaveRegistryCacheAndPath(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + + path := registryCachePath() + wantPath := filepath.Join(home, ".keel", "registry.json") + if path != wantPath { + t.Fatalf("expected cache path %q, got %q", wantPath, path) + } + + saveRegistryCache(path, &Registry{ + Version: "saved", + Addons: []RegistryEntry{{Alias: "x", Repo: "github.com/acme/x"}}, + }) + + reg, ok := loadCachedRegistry(path) + if !ok || reg == nil { + t.Fatalf("expected saved cache to be readable") + } + if reg.Version != "saved" { + t.Fatalf("unexpected saved registry: %#v", reg) + } +} + +func TestFetchRegistry(t *testing.T) { + t.Run("uses fresh cache without network", func(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + + cachePath := filepath.Join(home, ".keel", "registry.json") + writeCachedRegistry(t, cachePath, cachedRegistry{ + FetchedAt: time.Now(), + Registry: Registry{ + Version: "cached", + Addons: []RegistryEntry{{Alias: "gorm", Repo: "github.com/slice-soft/ss-keel-gorm"}}, + }, + }) + + called := false + stubHTTPTransport(t, func(req *http.Request) (*http.Response, error) { + called = true + return nil, errors.New("should not be called") + }) + + reg, err := FetchRegistry(false) + if err != nil { + t.Fatalf("expected nil error, got %v", err) + } + if called { + t.Fatalf("did not expect network call when cache is fresh") + } + if reg.Version != "cached" { + t.Fatalf("expected cached version, got %q", reg.Version) + } + }) + + t.Run("force refresh updates cache from network", func(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + + cachePath := filepath.Join(home, ".keel", "registry.json") + writeCachedRegistry(t, cachePath, cachedRegistry{ + FetchedAt: time.Now(), + Registry: Registry{Version: "old"}, + }) + + stubHTTPTransport(t, func(req *http.Request) (*http.Response, error) { + body := `{"version":"new","addons":[{"alias":"sql","repo":"github.com/acme/sql","official":true}]}` + return &http.Response{ + StatusCode: http.StatusOK, + Body: ioNopCloser(body), + Header: make(http.Header), + }, nil + }) + + reg, err := FetchRegistry(true) + if err != nil { + t.Fatalf("expected nil error, got %v", err) + } + if reg.Version != "new" || len(reg.Addons) != 1 || reg.Addons[0].Alias != "sql" { + t.Fatalf("unexpected refreshed registry: %#v", reg) + } + + saved, ok := loadCachedRegistry(cachePath) + if !ok || saved == nil || saved.Version != "new" { + t.Fatalf("expected refreshed registry to be cached") + } + }) + + t.Run("falls back to cache when network fails", func(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + + cachePath := filepath.Join(home, ".keel", "registry.json") + writeCachedRegistry(t, cachePath, cachedRegistry{ + FetchedAt: time.Now(), + Registry: Registry{Version: "cached"}, + }) + + stubHTTPTransport(t, func(req *http.Request) (*http.Response, error) { + return nil, errors.New("network down") + }) + + reg, err := FetchRegistry(true) + if err != nil { + t.Fatalf("expected fallback to cache, got error %v", err) + } + if reg.Version != "cached" { + t.Fatalf("expected cached fallback, got %#v", reg) + } + }) + + t.Run("returns error when network fails and cache missing", func(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + + stubHTTPTransport(t, func(req *http.Request) (*http.Response, error) { + return nil, errors.New("network down") + }) + + _, err := FetchRegistry(true) + if err == nil || !strings.Contains(err.Error(), "could not fetch addon registry") { + t.Fatalf("expected wrapped fetch error, got %v", err) + } + }) +} + +func ioNopCloser(body string) *readCloser { + return &readCloser{reader: strings.NewReader(body)} +} + +type readCloser struct { + reader *strings.Reader +} + +func (r *readCloser) Read(p []byte) (int, error) { + return r.reader.Read(p) +} + +func (r *readCloser) Close() error { + return nil +} diff --git a/internal/addon/step.go b/internal/addon/step.go new file mode 100644 index 0000000..7ff603c --- /dev/null +++ b/internal/addon/step.go @@ -0,0 +1,104 @@ +package addon + +import ( + "encoding/json" + "fmt" + "io" + "net/http" + "os" + "strings" + "time" +) + +const addonManifestFile = "keel-addon.json" + +// Manifest is the keel-addon.json structure from an addon repo. +type Manifest struct { + Name string `json:"name"` + Version string `json:"version"` + Description string `json:"description"` + Repo string `json:"repo"` + Steps []Step `json:"steps"` +} + +// Step is a single installation action defined in keel-addon.json. +type Step struct { + // Type is one of: go_get | env | main_import | main_code + Type string `json:"type"` + + // go_get + Package string `json:"package,omitempty"` + + // env + Key string `json:"key,omitempty"` + Example string `json:"example,omitempty"` + Description string `json:"description,omitempty"` + + // main_import + Path string `json:"path,omitempty"` + + // main_code + Anchor string `json:"anchor,omitempty"` // "before_listen" + Guard string `json:"guard,omitempty"` // skip if already present + Code string `json:"code,omitempty"` +} + +// FetchManifest downloads keel-addon.json from a Go module path. +// Supports github.com paths only for now. +func FetchManifest(repo string) (*Manifest, error) { + rawURL, err := rawManifestURL(repo) + if err != nil { + return nil, err + } + + client := &http.Client{Timeout: 10 * time.Second} + resp, err := client.Get(rawURL) + if err != nil { + return nil, fmt.Errorf("could not fetch keel-addon.json from %s: %w", repo, err) + } + defer resp.Body.Close() + + if resp.StatusCode == http.StatusNotFound { + return nil, fmt.Errorf("%s does not have a keel-addon.json — it may not be a Keel addon", repo) + } + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("keel-addon.json fetch returned HTTP %d for %s", resp.StatusCode, repo) + } + + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, err + } + + var m Manifest + if err := json.Unmarshal(body, &m); err != nil { + return nil, fmt.Errorf("invalid keel-addon.json in %s: %w", repo, err) + } + return &m, nil +} + +// rawManifestURL builds the raw content URL for keel-addon.json given a Go module path. +// Only github.com is supported at this time. +func rawManifestURL(repo string) (string, error) { + // repo = "github.com/slice-soft/ss-keel-gorm" + if !strings.HasPrefix(repo, "github.com/") { + return "", fmt.Errorf("only github.com repos are supported (got %q)", repo) + } + // strip "github.com/" prefix → "slice-soft/ss-keel-gorm" + path := strings.TrimPrefix(repo, "github.com/") + return fmt.Sprintf("https://raw.githubusercontent.com/%s/main/%s", path, addonManifestFile), nil +} + +// LoadLocalManifest reads keel-addon.json from the current directory. +// Used for testing an addon locally before publishing. +func LoadLocalManifest() (*Manifest, error) { + data, err := os.ReadFile(addonManifestFile) + if err != nil { + return nil, fmt.Errorf("keel-addon.json not found in current directory") + } + var m Manifest + if err := json.Unmarshal(data, &m); err != nil { + return nil, fmt.Errorf("invalid keel-addon.json: %w", err) + } + return &m, nil +} diff --git a/internal/addon/step_test.go b/internal/addon/step_test.go new file mode 100644 index 0000000..de91ac8 --- /dev/null +++ b/internal/addon/step_test.go @@ -0,0 +1,174 @@ +package addon + +import ( + "errors" + "net/http" + "os" + "path/filepath" + "strings" + "testing" +) + +func withWorkingDir(t *testing.T, dir string) { + t.Helper() + previous, err := os.Getwd() + if err != nil { + t.Fatalf("failed to get current directory: %v", err) + } + if err := os.Chdir(dir); err != nil { + t.Fatalf("failed to change directory: %v", err) + } + t.Cleanup(func() { + _ = os.Chdir(previous) + }) +} + +func TestRawManifestURL(t *testing.T) { + url, err := rawManifestURL("github.com/slice-soft/ss-keel-gorm") + if err != nil { + t.Fatalf("expected nil error, got %v", err) + } + want := "https://raw.githubusercontent.com/slice-soft/ss-keel-gorm/main/keel-addon.json" + if url != want { + t.Fatalf("expected %q, got %q", want, url) + } + + _, err = rawManifestURL("gitlab.com/acme/addon") + if err == nil || !strings.Contains(err.Error(), "only github.com repos are supported") { + t.Fatalf("expected unsupported host error, got %v", err) + } +} + +func TestFetchManifest(t *testing.T) { + t.Run("unsupported repo", func(t *testing.T) { + _, err := FetchManifest("bitbucket.org/acme/addon") + if err == nil || !strings.Contains(err.Error(), "only github.com repos are supported") { + t.Fatalf("expected unsupported repo error, got %v", err) + } + }) + + t.Run("request error", func(t *testing.T) { + stubHTTPTransport(t, func(req *http.Request) (*http.Response, error) { + return nil, errors.New("network down") + }) + + _, err := FetchManifest("github.com/acme/addon") + if err == nil || !strings.Contains(err.Error(), "could not fetch keel-addon.json") { + t.Fatalf("expected request error, got %v", err) + } + }) + + t.Run("not found", func(t *testing.T) { + stubHTTPTransport(t, func(req *http.Request) (*http.Response, error) { + return &http.Response{ + StatusCode: http.StatusNotFound, + Body: ioNopCloser("missing"), + Header: make(http.Header), + }, nil + }) + + _, err := FetchManifest("github.com/acme/addon") + if err == nil || !strings.Contains(err.Error(), "does not have a keel-addon.json") { + t.Fatalf("expected not found error, got %v", err) + } + }) + + t.Run("status error", func(t *testing.T) { + stubHTTPTransport(t, func(req *http.Request) (*http.Response, error) { + return &http.Response{ + StatusCode: http.StatusBadGateway, + Body: ioNopCloser("bad gateway"), + Header: make(http.Header), + }, nil + }) + + _, err := FetchManifest("github.com/acme/addon") + if err == nil || !strings.Contains(err.Error(), "HTTP 502") { + t.Fatalf("expected status error, got %v", err) + } + }) + + t.Run("invalid json", func(t *testing.T) { + stubHTTPTransport(t, func(req *http.Request) (*http.Response, error) { + return &http.Response{ + StatusCode: http.StatusOK, + Body: ioNopCloser("{invalid"), + Header: make(http.Header), + }, nil + }) + + _, err := FetchManifest("github.com/acme/addon") + if err == nil || !strings.Contains(err.Error(), "invalid keel-addon.json") { + t.Fatalf("expected json error, got %v", err) + } + }) + + t.Run("success", func(t *testing.T) { + stubHTTPTransport(t, func(req *http.Request) (*http.Response, error) { + want := "https://raw.githubusercontent.com/acme/addon/main/keel-addon.json" + if req.URL.String() != want { + t.Fatalf("unexpected URL: %s", req.URL.String()) + } + body := `{"name":"gorm","version":"1.0.0","repo":"github.com/acme/addon","steps":[{"type":"env","key":"DB_HOST","example":"localhost"}]}` + return &http.Response{ + StatusCode: http.StatusOK, + Body: ioNopCloser(body), + Header: make(http.Header), + }, nil + }) + + manifest, err := FetchManifest("github.com/acme/addon") + if err != nil { + t.Fatalf("expected nil error, got %v", err) + } + if manifest.Name != "gorm" || manifest.Repo != "github.com/acme/addon" || len(manifest.Steps) != 1 { + t.Fatalf("unexpected manifest: %#v", manifest) + } + }) +} + +func TestLoadLocalManifest(t *testing.T) { + t.Run("missing file", func(t *testing.T) { + dir := t.TempDir() + withWorkingDir(t, dir) + + _, err := LoadLocalManifest() + if err == nil || !strings.Contains(err.Error(), "not found in current directory") { + t.Fatalf("expected missing file error, got %v", err) + } + }) + + t.Run("invalid json", func(t *testing.T) { + dir := t.TempDir() + withWorkingDir(t, dir) + + path := filepath.Join(dir, addonManifestFile) + if err := os.WriteFile(path, []byte("{invalid"), 0644); err != nil { + t.Fatalf("failed to write manifest: %v", err) + } + + _, err := LoadLocalManifest() + if err == nil || !strings.Contains(err.Error(), "invalid keel-addon.json") { + t.Fatalf("expected invalid json error, got %v", err) + } + }) + + t.Run("success", func(t *testing.T) { + dir := t.TempDir() + withWorkingDir(t, dir) + + path := filepath.Join(dir, addonManifestFile) + body := `{"name":"x","version":"1.0.0","steps":[{"type":"main_import","path":"github.com/acme/x"}]}` + if err := os.WriteFile(path, []byte(body), 0644); err != nil { + t.Fatalf("failed to write manifest: %v", err) + } + + manifest, err := LoadLocalManifest() + if err != nil { + t.Fatalf("expected nil error, got %v", err) + } + if manifest.Name != "x" || len(manifest.Steps) != 1 || manifest.Steps[0].Type != "main_import" { + t.Fatalf("unexpected manifest: %#v", manifest) + } + }) +} From f1dd2f44d51ee796263c6c8e5cfafeb0e583a180 Mon Sep 17 00:00:00 2001 From: juancadev-io Date: Fri, 6 Mar 2026 18:00:41 -0500 Subject: [PATCH 2/3] feat(cli): add keel add command --- cmd/add/add.go | 108 ++++++++++++++++ cmd/add/add_test.go | 294 ++++++++++++++++++++++++++++++++++++++++++++ cmd/root.go | 2 + 3 files changed, 404 insertions(+) create mode 100644 cmd/add/add.go create mode 100644 cmd/add/add_test.go diff --git a/cmd/add/add.go b/cmd/add/add.go new file mode 100644 index 0000000..86cc07f --- /dev/null +++ b/cmd/add/add.go @@ -0,0 +1,108 @@ +package add + +import ( + "bufio" + "errors" + "fmt" + "os" + "strings" + + "github.com/slice-soft/keel/internal/addon" + "github.com/spf13/cobra" +) + +var forceRefresh bool + +const invalidProjectMessage = "keel add must be executed inside a Keel project" + +var ( + fetchRegistryFn = addon.FetchRegistry + fetchManifestFn = addon.FetchManifest + installAddonFn = addon.Install +) + +func NewCommand() *cobra.Command { + cmd := &cobra.Command{ + Use: "add [alias|repo]", + Short: "Install a Keel addon into the current project", + Long: `Install a Keel addon and wire it automatically into cmd/main.go. + + keel add gorm # official alias + keel add github.com/username/my-addon # any repo with keel-addon.json`, + Args: cobra.ExactArgs(1), + RunE: runAdd, + } + cmd.Flags().BoolVar(&forceRefresh, "refresh", false, "Force refresh of the addon registry cache") + return cmd +} + +func runAdd(cmd *cobra.Command, args []string) error { + if err := validateKeelProject(); err != nil { + return err + } + + target := strings.TrimSpace(args[0]) + + reg, err := fetchRegistryFn(forceRefresh) + if err != nil { + // Non-fatal: we can still install by direct repo path. + fmt.Fprintf(os.Stderr, " ⚠ Could not fetch addon registry: %v\n", err) + } + + repo, isOfficial := resolveRepo(target, reg) + + if !isOfficial { + fmt.Printf("\n ⚠ %q is not in the official Keel addon registry.\n", repo) + fmt.Printf(" Verify community addons at: https://github.com/slice-soft/ss-keel-addons\n") + fmt.Printf(" Install anyway? [y/N] ") + + reader := bufio.NewReader(os.Stdin) + answer, _ := reader.ReadString('\n') + if strings.ToLower(strings.TrimSpace(answer)) != "y" { + fmt.Println(" Aborted.") + return nil + } + } + + fmt.Printf("\n Installing %s...\n\n", repo) + + manifest, err := fetchManifestFn(repo) + if err != nil { + return err + } + + if err := installAddonFn(manifest); err != nil { + return err + } + + fmt.Printf("\n ✓ %s installed successfully\n\n", manifest.Name) + return nil +} + +func validateKeelProject() error { + required := []string{"go.mod", "cmd/main.go", "internal"} + for _, path := range required { + if _, err := os.Stat(path); err != nil { + return errors.New(invalidProjectMessage) + } + } + return nil +} + +// resolveRepo maps an alias or full repo path to a module path + whether it's official. +func resolveRepo(target string, reg *addon.Registry) (repo string, official bool) { + // Full module path (e.g. github.com/user/repo) — skip registry lookup. + if strings.Contains(target, "/") { + return target, false + } + + // Alias lookup. + if reg != nil { + if resolved, ok := reg.ResolveRepo(target); ok { + return resolved, true + } + } + + // Unknown alias — treat as-is and warn. + return target, false +} diff --git a/cmd/add/add_test.go b/cmd/add/add_test.go new file mode 100644 index 0000000..55dc214 --- /dev/null +++ b/cmd/add/add_test.go @@ -0,0 +1,294 @@ +package add + +import ( + "errors" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/slice-soft/keel/internal/addon" +) + +func resetAddDeps(t *testing.T) { + t.Helper() + + prevFetchRegistry := fetchRegistryFn + prevFetchManifest := fetchManifestFn + prevInstallAddon := installAddonFn + prevForceRefresh := forceRefresh + + t.Cleanup(func() { + fetchRegistryFn = prevFetchRegistry + fetchManifestFn = prevFetchManifest + installAddonFn = prevInstallAddon + forceRefresh = prevForceRefresh + }) +} + +func withWorkingDir(t *testing.T, dir string) { + t.Helper() + previous, err := os.Getwd() + if err != nil { + t.Fatalf("failed to get current directory: %v", err) + } + if err := os.Chdir(dir); err != nil { + t.Fatalf("failed to change directory: %v", err) + } + t.Cleanup(func() { + _ = os.Chdir(previous) + }) +} + +func setupKeelProject(t *testing.T) { + t.Helper() + root := t.TempDir() + withWorkingDir(t, root) + + if err := os.WriteFile(filepath.Join(root, "go.mod"), []byte("module example.com/test\n"), 0644); err != nil { + t.Fatalf("failed to write go.mod: %v", err) + } + mainPath := filepath.Join(root, "cmd", "main.go") + if err := os.MkdirAll(filepath.Dir(mainPath), 0755); err != nil { + t.Fatalf("failed to create cmd directory: %v", err) + } + if err := os.WriteFile(mainPath, []byte("package main\nfunc main(){}\n"), 0644); err != nil { + t.Fatalf("failed to write cmd/main.go: %v", err) + } + if err := os.MkdirAll(filepath.Join(root, "internal"), 0755); err != nil { + t.Fatalf("failed to create internal directory: %v", err) + } +} + +func setStdin(t *testing.T, input string) { + t.Helper() + + previous := os.Stdin + r, w, err := os.Pipe() + if err != nil { + t.Fatalf("failed to create stdin pipe: %v", err) + } + if _, err := w.WriteString(input); err != nil { + t.Fatalf("failed to write stdin input: %v", err) + } + _ = w.Close() + + os.Stdin = r + t.Cleanup(func() { + os.Stdin = previous + _ = r.Close() + }) +} + +func TestNewCommand(t *testing.T) { + cmd := NewCommand() + if cmd.Use != "add [alias|repo]" { + t.Fatalf("unexpected command use: %q", cmd.Use) + } + if cmd.RunE == nil { + t.Fatalf("expected RunE to be configured") + } + if cmd.Flags().Lookup("refresh") == nil { + t.Fatalf("expected --refresh flag") + } +} + +func TestResolveRepo(t *testing.T) { + reg := &addon.Registry{ + Addons: []addon.RegistryEntry{ + {Alias: "gorm", Repo: "github.com/slice-soft/ss-keel-gorm"}, + }, + } + + tests := []struct { + name string + target string + registry *addon.Registry + wantRepo string + wantOfficial bool + }{ + { + name: "full repo path skips registry", + target: "github.com/acme/addon", + registry: reg, + wantRepo: "github.com/acme/addon", + wantOfficial: false, + }, + { + name: "official alias from registry", + target: "gorm", + registry: reg, + wantRepo: "github.com/slice-soft/ss-keel-gorm", + wantOfficial: true, + }, + { + name: "unknown alias without registry", + target: "custom", + registry: nil, + wantRepo: "custom", + wantOfficial: false, + }, + { + name: "unknown alias with registry", + target: "custom", + registry: reg, + wantRepo: "custom", + wantOfficial: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + gotRepo, gotOfficial := resolveRepo(tt.target, tt.registry) + if gotRepo != tt.wantRepo || gotOfficial != tt.wantOfficial { + t.Fatalf("resolveRepo(%q) = (%q, %t), want (%q, %t)", tt.target, gotRepo, gotOfficial, tt.wantRepo, tt.wantOfficial) + } + }) + } +} + +func TestRunAddOfficialAlias(t *testing.T) { + resetAddDeps(t) + setupKeelProject(t) + + forceRefresh = true + + calledRegistry := false + calledManifest := false + calledInstall := false + + fetchRegistryFn = func(refresh bool) (*addon.Registry, error) { + calledRegistry = true + if !refresh { + t.Fatalf("expected force refresh to be forwarded") + } + return &addon.Registry{ + Addons: []addon.RegistryEntry{ + {Alias: "gorm", Repo: "github.com/slice-soft/ss-keel-gorm"}, + }, + }, nil + } + fetchManifestFn = func(repo string) (*addon.Manifest, error) { + calledManifest = true + if repo != "github.com/slice-soft/ss-keel-gorm" { + t.Fatalf("unexpected repo passed to fetchManifest: %q", repo) + } + return &addon.Manifest{Name: "gorm"}, nil + } + installAddonFn = func(m *addon.Manifest) error { + calledInstall = true + if m.Name != "gorm" { + t.Fatalf("unexpected manifest passed to installer: %+v", m) + } + return nil + } + + if err := runAdd(nil, []string{"gorm"}); err != nil { + t.Fatalf("runAdd returned error: %v", err) + } + if !calledRegistry || !calledManifest || !calledInstall { + t.Fatalf("expected all dependencies to be called, got registry=%t manifest=%t install=%t", calledRegistry, calledManifest, calledInstall) + } +} + +func TestRunAddCommunityAddonAbort(t *testing.T) { + resetAddDeps(t) + setupKeelProject(t) + setStdin(t, "n\n") + + calledManifest := false + calledInstall := false + + fetchRegistryFn = func(refresh bool) (*addon.Registry, error) { + return nil, errors.New("registry down") + } + fetchManifestFn = func(repo string) (*addon.Manifest, error) { + calledManifest = true + return &addon.Manifest{Name: "custom"}, nil + } + installAddonFn = func(m *addon.Manifest) error { + calledInstall = true + return nil + } + + if err := runAdd(nil, []string{"custom-addon"}); err != nil { + t.Fatalf("runAdd returned error: %v", err) + } + if calledManifest || calledInstall { + t.Fatalf("expected install flow to abort before fetching manifest") + } +} + +func TestRunAddCommunityAddonErrors(t *testing.T) { + t.Run("manifest fetch error", func(t *testing.T) { + resetAddDeps(t) + setupKeelProject(t) + setStdin(t, "y\n") + + wantErr := errors.New("manifest not found") + + fetchRegistryFn = func(refresh bool) (*addon.Registry, error) { + return nil, nil + } + fetchManifestFn = func(repo string) (*addon.Manifest, error) { + return nil, wantErr + } + installAddonFn = func(m *addon.Manifest) error { + t.Fatalf("install should not be called on manifest error") + return nil + } + + err := runAdd(nil, []string{"github.com/acme/addon"}) + if !errors.Is(err, wantErr) { + t.Fatalf("expected manifest error, got %v", err) + } + }) + + t.Run("install error", func(t *testing.T) { + resetAddDeps(t) + setupKeelProject(t) + setStdin(t, "y\n") + + wantErr := errors.New("install failed") + + fetchRegistryFn = func(refresh bool) (*addon.Registry, error) { + return nil, nil + } + fetchManifestFn = func(repo string) (*addon.Manifest, error) { + return &addon.Manifest{Name: "custom"}, nil + } + installAddonFn = func(m *addon.Manifest) error { + return wantErr + } + + err := runAdd(nil, []string{"github.com/acme/addon"}) + if !errors.Is(err, wantErr) { + t.Fatalf("expected install error, got %v", err) + } + }) +} + +func TestRunAddInvalidProject(t *testing.T) { + resetAddDeps(t) + + root := t.TempDir() + withWorkingDir(t, root) + + fetchRegistryFn = func(refresh bool) (*addon.Registry, error) { + t.Fatalf("registry should not be called for invalid project") + return nil, nil + } + fetchManifestFn = func(repo string) (*addon.Manifest, error) { + t.Fatalf("manifest should not be called for invalid project") + return nil, nil + } + installAddonFn = func(m *addon.Manifest) error { + t.Fatalf("installer should not be called for invalid project") + return nil + } + + err := runAdd(nil, []string{"gorm"}) + if err == nil || !strings.Contains(err.Error(), invalidProjectMessage) { + t.Fatalf("expected invalid project error, got %v", err) + } +} diff --git a/cmd/root.go b/cmd/root.go index 6ce8661..e0583d5 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -5,6 +5,7 @@ import ( "io" "os" + "github.com/slice-soft/keel/cmd/add" "github.com/slice-soft/keel/cmd/completion" "github.com/slice-soft/keel/cmd/generate" initcmd "github.com/slice-soft/keel/cmd/init" @@ -54,6 +55,7 @@ func Execute() { func init() { syncRootVersionOutput() + rootCmd.AddCommand(add.NewCommand()) rootCmd.AddCommand(new.NewCommand()) rootCmd.AddCommand(initcmd.NewCommand()) rootCmd.AddCommand(generate.NewCommand()) From ccb7c4c6987e7cc02cc4cf1d6336de45fb6f95ff Mon Sep 17 00:00:00 2001 From: juancadev-io Date: Fri, 6 Mar 2026 18:00:54 -0500 Subject: [PATCH 3/3] feat(generate): wire gorm repositories and module dependencies --- cmd/generate/generate.go | 120 ++++++++++++++++-- cmd/generate/generate_test.go | 80 +++++++++++- cmd/generate/wire.go | 64 +++++++++- internal/generator/addon.go | 18 +++ internal/generator/data.go | 12 +- .../generate/controller/controller.go.tmpl | 16 ++- .../controller/controller_test.go.tmpl | 2 +- .../templates/generate/module/module.go.tmpl | 39 ++++-- .../generate/module/module_test.go.tmpl | 2 +- .../repository/repository_gorm.go.tmpl | 35 +++++ .../repository/repository_gorm_test.go.tmpl | 15 +++ .../generate/service/service.go.tmpl | 10 +- 12 files changed, 386 insertions(+), 27 deletions(-) create mode 100644 internal/generator/addon.go create mode 100644 internal/generator/templates/generate/repository/repository_gorm.go.tmpl create mode 100644 internal/generator/templates/generate/repository/repository_gorm_test.go.tmpl diff --git a/cmd/generate/generate.go b/cmd/generate/generate.go index e130600..18b53ec 100644 --- a/cmd/generate/generate.go +++ b/cmd/generate/generate.go @@ -109,7 +109,10 @@ func execute(genType, rawName string, opts Options) error { if err := generateInModule(resolvedType, parsed.moduleName, parsed.componentName); err != nil { return err } - return regenerateModuleRegistry(parsed.moduleName) + if err := regenerateModuleRegistry(parsed.moduleName); err != nil { + return err + } + return ensureModuleRegisteredInMain(parsed.moduleName) } func resolveType(raw string) (string, error) { @@ -133,7 +136,13 @@ func generateModule(name string, opts Options) error { files = append(files, buildSimpleFiles(typeController, name, moduleDir(name), "controller.go.tmpl", "controller_test.go.tmpl", name)...) } if opts.WithRepository { - files = append(files, buildSimpleFiles(typeRepository, name, moduleDir(name), "repository.go.tmpl", "repository_test.go.tmpl", name)...) + repoFiles := buildGormRepositoryFiles(name, moduleDir(name), name) + if !generator.IsAddonInstalled("github.com/slice-soft/ss-keel-gorm") { + fmt.Println(" ⚠ ss-keel-gorm not found in go.mod — generated stub repository") + fmt.Println(" Install the GORM adapter with: keel add gorm") + repoFiles = buildSimpleFiles(typeRepository, name, moduleDir(name), "repository.go.tmpl", "repository_test.go.tmpl", name) + } + files = append(files, repoFiles...) } for _, file := range files { @@ -149,6 +158,35 @@ func generateModule(name string, opts Options) error { return regenerateModuleRegistry(name) } +func generateRepository(componentName, baseDir, packageOverride string) error { + if generator.IsAddonInstalled("github.com/slice-soft/ss-keel-gorm") { + return createFiles(buildGormRepositoryFiles(componentName, baseDir, packageOverride)) + } + fmt.Println(" ⚠ ss-keel-gorm not found in go.mod — generated stub repository") + fmt.Println(" Install the GORM adapter with: keel add gorm") + fmt.Println(" Then regenerate: keel generate repository ") + return createFiles(buildSimpleFiles(typeRepository, componentName, baseDir, "repository.go.tmpl", "repository_test.go.tmpl", packageOverride)) +} + +func buildGormRepositoryFiles(componentName, baseDir, packageOverride string) []genFile { + data := generator.NewData(componentName) + if packageOverride != "" { + data.PackageName = generator.NewData(packageOverride).PackageName + } + return []genFile{ + { + template: "templates/generate/repository/repository_gorm.go.tmpl", + dest: filepath.Join(baseDir, data.SnakeName+"_repository.go"), + data: data, + }, + { + template: "templates/generate/repository/repository_gorm_test.go.tmpl", + dest: filepath.Join(baseDir, data.SnakeName+"_repository_test.go"), + data: data, + }, + } +} + func generateStandalone(genType, componentName string, opts Options) error { switch genType { case typeService: @@ -198,7 +236,7 @@ func generateInModule(genType, moduleName, componentName string) error { case typeController: return createFiles(buildSimpleFiles(genType, componentName, baseDir, "controller.go.tmpl", "controller_test.go.tmpl", moduleName)) case typeRepository: - return createFiles(buildSimpleFiles(genType, componentName, baseDir, "repository.go.tmpl", "repository_test.go.tmpl", moduleName)) + return generateRepository(componentName, baseDir, moduleName) default: return fmt.Errorf("%s does not support module/name format", genType) } @@ -389,9 +427,10 @@ func regenerateModuleRegistry(moduleName string) error { } moduleData := generator.NewData(moduleName) - moduleData.Services = toRegistrations(services) - moduleData.Controllers = toRegistrations(controllers) - moduleData.Repositories = toRegistrations(repositories) + moduleData.Repositories = toRepositoryRegistrations(moduleName, repositories) + moduleData.Services = toServiceRegistrations(services, moduleData.Repositories) + moduleData.Controllers = toControllerRegistrations(controllers, moduleData.Services) + moduleData.UsesDatabase = hasDatabaseBackedRepository(moduleData.Repositories) dest := moduleRegistryPath(moduleName) alreadyExisted := generator.FileExists(dest) @@ -428,15 +467,80 @@ func listComponents(dir, suffix string) ([]string, error) { return items, nil } -func toRegistrations(names []string) []generator.ComponentRegistration { +func toRepositoryRegistrations(moduleName string, names []string) []generator.ComponentRegistration { items := make([]generator.ComponentRegistration, 0, len(names)) for _, name := range names { d := generator.NewData(name) items = append(items, generator.ComponentRegistration{ + Name: name, + PascalName: d.PascalName, + VarName: d.CamelName, + UsesDatabaseRepo: repositoryUsesDatabase(moduleName, name), + }) + } + return items +} + +func toServiceRegistrations(names []string, repositories []generator.ComponentRegistration) []generator.ComponentRegistration { + reposByName := make(map[string]generator.ComponentRegistration, len(repositories)) + for _, repo := range repositories { + reposByName[repo.Name] = repo + } + + items := make([]generator.ComponentRegistration, 0, len(names)) + for _, name := range names { + d := generator.NewData(name) + item := generator.ComponentRegistration{ Name: name, PascalName: d.PascalName, VarName: d.CamelName, - }) + } + if repo, ok := reposByName[name]; ok { + item.HasRepository = true + item.RepositoryVar = repo.VarName + "Repository" + } + items = append(items, item) } return items } + +func toControllerRegistrations(names []string, services []generator.ComponentRegistration) []generator.ComponentRegistration { + servicesByName := make(map[string]generator.ComponentRegistration, len(services)) + for _, service := range services { + servicesByName[service.Name] = service + } + + items := make([]generator.ComponentRegistration, 0, len(names)) + for _, name := range names { + d := generator.NewData(name) + item := generator.ComponentRegistration{ + Name: name, + PascalName: d.PascalName, + VarName: d.CamelName, + } + if service, ok := servicesByName[name]; ok { + item.HasService = true + item.ServiceVar = service.VarName + "Service" + } + items = append(items, item) + } + return items +} + +func hasDatabaseBackedRepository(repositories []generator.ComponentRegistration) bool { + for _, repo := range repositories { + if repo.UsesDatabaseRepo { + return true + } + } + return false +} + +func repositoryUsesDatabase(moduleName, repositoryName string) bool { + repoPath := filepath.Join(moduleDir(moduleName), repositoryName+"_repository.go") + content, err := os.ReadFile(repoPath) + if err != nil { + return false + } + return strings.Contains(string(content), "\"github.com/slice-soft/ss-keel-gorm/database\"") +} diff --git a/cmd/generate/generate_test.go b/cmd/generate/generate_test.go index cdbecb5..99c6886 100644 --- a/cmd/generate/generate_test.go +++ b/cmd/generate/generate_test.go @@ -98,8 +98,11 @@ func TestGenerateModuleDefaultsAndAlias(t *testing.T) { assertFile(t, filepath.Join(root, "internal", "modules", "users", "users_service.go")) assertFile(t, filepath.Join(root, "internal", "modules", "users", "users_controller.go")) moduleContent := mustRead(t, filepath.Join(root, "internal", "modules", "users", "users_module.go")) - if !strings.Contains(moduleContent, "usersController := NewUsersController(m.log)") { - t.Fatalf("expected controller variable suffix to avoid collisions, got:\\n%s", moduleContent) + if !strings.Contains(moduleContent, "usersService := NewUsersService(m.log)") { + t.Fatalf("expected service registration in module, got:\\n%s", moduleContent) + } + if !strings.Contains(moduleContent, "usersController := NewUsersController(usersService, m.log)") { + t.Fatalf("expected controller to receive service dependency, got:\\n%s", moduleContent) } mainContent := mustRead(t, filepath.Join(root, "cmd", "main.go")) @@ -134,6 +137,71 @@ func TestGenerateTransactionalModuleWithRepository(t *testing.T) { } } +func TestGenerateModuleWithGormRepositoryWiresDatabaseInMain(t *testing.T) { + root := t.TempDir() + oldWD, _ := os.Getwd() + defer func() { _ = os.Chdir(oldWD) }() + + seedProject(t, root) + enableGormAddonInGoMod(t, root) + + if err := os.Chdir(root); err != nil { + t.Fatalf("chdir failed: %v", err) + } + + opts := Options{WithRepository: true} + if err := execute("module", "payments", opts); err != nil { + t.Fatalf("generate module failed: %v", err) + } + + moduleContent := mustRead(t, filepath.Join(root, "internal", "modules", "payments", "payments_module.go")) + if !strings.Contains(moduleContent, "paymentsRepository := NewPaymentsRepository(m.log, m.db)") { + t.Fatalf("expected module to wire repository with logger and db, got:\n%s", moduleContent) + } + if !strings.Contains(moduleContent, "paymentsService := NewPaymentsServiceWithRepository(paymentsRepository, m.log)") { + t.Fatalf("expected module to wire service with repository, got:\n%s", moduleContent) + } + if !strings.Contains(moduleContent, "paymentsController := NewPaymentsController(paymentsService, m.log)") { + t.Fatalf("expected module to wire controller with service, got:\n%s", moduleContent) + } + + mainContent := mustRead(t, filepath.Join(root, "cmd", "main.go")) + if !strings.Contains(mainContent, "\"github.com/slice-soft/ss-keel-gorm/database\"") { + t.Fatalf("expected database import in main.go, got:\n%s", mainContent) + } + if !strings.Contains(mainContent, "db, err := database.New(database.Config{") { + t.Fatalf("expected database bootstrap in main.go, got:\n%s", mainContent) + } + if !strings.Contains(mainContent, "app.Use(payments.NewModule(appLogger, db))") { + t.Fatalf("expected module registration with db in main.go, got:\n%s", mainContent) + } +} + +func TestGenerateRepositoryInModuleRewiresMainWithDatabase(t *testing.T) { + root := t.TempDir() + oldWD, _ := os.Getwd() + defer func() { _ = os.Chdir(oldWD) }() + + seedProject(t, root) + enableGormAddonInGoMod(t, root) + + if err := os.Chdir(root); err != nil { + t.Fatalf("chdir failed: %v", err) + } + + if err := execute("module", "billing", Options{}); err != nil { + t.Fatalf("generate module failed: %v", err) + } + if err := execute("repository", "billing/billing", Options{}); err != nil { + t.Fatalf("generate repository failed: %v", err) + } + + mainContent := mustRead(t, filepath.Join(root, "cmd", "main.go")) + if !strings.Contains(mainContent, "app.Use(billing.NewModule(appLogger, db))") { + t.Fatalf("expected module registration to be rewired with db, got:\n%s", mainContent) + } +} + func TestGenerateModuleScopedServiceKeepsModulePackage(t *testing.T) { root := t.TempDir() oldWD, _ := os.Getwd() @@ -356,3 +424,11 @@ func mustRead(t *testing.T, path string) string { } return string(b) } + +func enableGormAddonInGoMod(t *testing.T, root string) { + t.Helper() + mustWrite(t, filepath.Join(root, "go.mod"), `module example.com/app + +require github.com/slice-soft/ss-keel-gorm v0.0.0 +`) +} diff --git a/cmd/generate/wire.go b/cmd/generate/wire.go index 5446df2..6d773b0 100644 --- a/cmd/generate/wire.go +++ b/cmd/generate/wire.go @@ -4,6 +4,7 @@ import ( "fmt" "go/format" "os" + "path/filepath" "strings" "github.com/slice-soft/keel/internal/generator" @@ -14,16 +15,32 @@ func ensureModuleRegisteredInMain(moduleName string) error { if modulePath == "" { return fmt.Errorf(invalidProjectMessage) } + needsDatabase, err := moduleNeedsDatabase(moduleName) + if err != nil { + return err + } data := generator.NewData(moduleName) importPath := fmt.Sprintf("\"%s/internal/modules/%s\"", modulePath, data.PackageName) useLine := fmt.Sprintf("\tapp.Use(%s.NewModule(appLogger))", data.PackageName) + useLineWithDB := fmt.Sprintf("\tapp.Use(%s.NewModule(appLogger, db))", data.PackageName) return updateMainGo(func(content string) string { content = ensureAppLoggerBootstrap(content) + if needsDatabase { + content = ensureDatabaseBootstrap(content) + } if !strings.Contains(content, importPath) { content = addImport(content, importPath) } + if needsDatabase { + content = strings.ReplaceAll(content, useLine, useLineWithDB) + if !strings.Contains(content, useLineWithDB) { + content = addMainLine(content, useLineWithDB) + } + return content + } + content = strings.ReplaceAll(content, useLineWithDB, useLine) if !strings.Contains(content, useLine) { content = addMainLine(content, useLine) } @@ -61,7 +78,7 @@ func ensureStandaloneControllerRegisteredInMain(componentName string) error { data := generator.NewData(componentName) importPath := fmt.Sprintf("\"%s/internal/controllers\"", modulePath) - registerLine := fmt.Sprintf("\tapp.RegisterController(controllers.New%sController(appLogger))", data.PascalName) + registerLine := fmt.Sprintf("\tapp.RegisterController(controllers.New%sController(nil, appLogger))", data.PascalName) return updateMainGo(func(content string) string { content = ensureAppLoggerBootstrap(content) @@ -224,6 +241,11 @@ func addMainLine(content, line string) string { } func ensureAppLoggerBootstrap(content string) string { + configImport := "\"github.com/slice-soft/ss-keel-core/config\"" + if !strings.Contains(content, configImport) { + content = addImport(content, configImport) + } + loggerImport := "\"github.com/slice-soft/ss-keel-core/logger\"" if !strings.Contains(content, loggerImport) { content = addImport(content, loggerImport) @@ -236,3 +258,43 @@ func ensureAppLoggerBootstrap(content string) string { return content } + +func ensureDatabaseBootstrap(content string) string { + databaseImport := "\"github.com/slice-soft/ss-keel-gorm/database\"" + if !strings.Contains(content, databaseImport) { + content = addImport(content, databaseImport) + } + + if strings.Contains(content, "database.New(") { + return content + } + + setupLine := "\tdatabaseURL := config.GetEnvOrDefault(\"DATABASE_URL\", \"postgres://user:pass@localhost:5432/db?sslmode=disable\")\n\tdb, err := database.New(database.Config{\n\t\tEngine: database.EnginePostgres,\n\t\tDSN: databaseURL,\n\t\tLogger: appLogger,\n\t})\n\tif err != nil {\n\t\tappLogger.Error(\"failed to start app: %v\", err)\n\t}\n\tdefer db.Close()\n\tapp.RegisterHealthChecker(database.NewHealthChecker(db))" + return addMainLine(content, setupLine) +} + +func moduleNeedsDatabase(moduleName string) (bool, error) { + repositories, err := listComponents(moduleDir(moduleName), "_repository.go") + if err != nil { + if os.IsNotExist(err) { + return false, nil + } + return false, err + } + + for _, repositoryName := range repositories { + repositoryPath := filepath.Join(moduleDir(moduleName), repositoryName+"_repository.go") + content, readErr := os.ReadFile(repositoryPath) + if readErr != nil { + if os.IsNotExist(readErr) { + continue + } + return false, readErr + } + if strings.Contains(string(content), "\"github.com/slice-soft/ss-keel-gorm/database\"") { + return true, nil + } + } + + return false, nil +} diff --git a/internal/generator/addon.go b/internal/generator/addon.go new file mode 100644 index 0000000..eb5b4e8 --- /dev/null +++ b/internal/generator/addon.go @@ -0,0 +1,18 @@ +package generator + +import ( + "os" + "strings" +) + +// IsAddonInstalled reports whether the given Go module path is present +// as a direct dependency in the go.mod of the current directory. +// +// generator.IsAddonInstalled("github.com/slice-soft/ss-keel-gorm") +func IsAddonInstalled(modulePath string) bool { + data, err := os.ReadFile("go.mod") + if err != nil { + return false + } + return strings.Contains(string(data), modulePath) +} diff --git a/internal/generator/data.go b/internal/generator/data.go index bd63d4e..1e70d17 100644 --- a/internal/generator/data.go +++ b/internal/generator/data.go @@ -18,6 +18,7 @@ type Data struct { KebabName string // users SnakeName string // users CoreVersion string // github.com/slice-soft/ss-keel-core v1.2.3 + UsesDatabase bool Services []ComponentRegistration Controllers []ComponentRegistration Repositories []ComponentRegistration @@ -25,9 +26,14 @@ type Data struct { // ComponentRegistration holds naming metadata used in generated registries. type ComponentRegistration struct { - Name string - PascalName string - VarName string + Name string + PascalName string + VarName string + HasRepository bool + RepositoryVar string + HasService bool + ServiceVar string + UsesDatabaseRepo bool } // NewData builds Data from a name in any supported format. diff --git a/internal/generator/templates/generate/controller/controller.go.tmpl b/internal/generator/templates/generate/controller/controller.go.tmpl index c7cc3dc..766e263 100644 --- a/internal/generator/templates/generate/controller/controller.go.tmpl +++ b/internal/generator/templates/generate/controller/controller.go.tmpl @@ -1,16 +1,23 @@ package {{.PackageName}} import ( + "context" + "github.com/slice-soft/ss-keel-core/core" "github.com/slice-soft/ss-keel-core/logger" ) +type {{.PascalName}}ServiceContract interface { + Execute(ctx context.Context) error +} + type {{.PascalName}}Controller struct { + svc {{.PascalName}}ServiceContract log *logger.Logger } -func New{{.PascalName}}Controller(log *logger.Logger) *{{.PascalName}}Controller { - return &{{.PascalName}}Controller{log: log} +func New{{.PascalName}}Controller(svc {{.PascalName}}ServiceContract, log *logger.Logger) *{{.PascalName}}Controller { + return &{{.PascalName}}Controller{svc: svc, log: log} } func (c *{{.PascalName}}Controller) Routes() []core.Route { @@ -22,6 +29,11 @@ func (c *{{.PascalName}}Controller) Routes() []core.Route { } func (c *{{.PascalName}}Controller) Handle(ctx *core.Ctx) error { + if c.svc != nil { + if err := c.svc.Execute(context.Background()); err != nil && c.log != nil { + c.log.Warn("{{.KebabName}} service returned an error: %v", err) + } + } if c.log != nil { c.log.Info("handling {{.KebabName}} controller request") } diff --git a/internal/generator/templates/generate/controller/controller_test.go.tmpl b/internal/generator/templates/generate/controller/controller_test.go.tmpl index 0a89120..64adad9 100644 --- a/internal/generator/templates/generate/controller/controller_test.go.tmpl +++ b/internal/generator/templates/generate/controller/controller_test.go.tmpl @@ -7,7 +7,7 @@ import ( ) func Test{{.PascalName}}Controller(t *testing.T) { - ctrl := New{{.PascalName}}Controller(logger.NewLogger(false)) + ctrl := New{{.PascalName}}Controller(nil, logger.NewLogger(false)) if ctrl == nil { t.Fatal("expected controller instance") } diff --git a/internal/generator/templates/generate/module/module.go.tmpl b/internal/generator/templates/generate/module/module.go.tmpl index 0d8653c..11f13f3 100644 --- a/internal/generator/templates/generate/module/module.go.tmpl +++ b/internal/generator/templates/generate/module/module.go.tmpl @@ -3,30 +3,53 @@ package {{.PackageName}} import ( "github.com/slice-soft/ss-keel-core/core" "github.com/slice-soft/ss-keel-core/logger" +{{- if .UsesDatabase }} + "github.com/slice-soft/ss-keel-gorm/database" +{{- end }} ) type Module struct { log *logger.Logger +{{- if .UsesDatabase }} + db *database.DBinstance +{{- end }} } -func NewModule(log *logger.Logger) *Module { - return &Module{log: log} +func NewModule(log *logger.Logger{{- if .UsesDatabase }}, db *database.DBinstance{{- end }}) *Module { + return &Module{ + log: log, +{{- if .UsesDatabase }} + db: db, +{{- end }} + } } func (m *Module) Register(app *core.App) { +{{- if .Repositories }} +{{- range .Repositories }} +{{- if .UsesDatabaseRepo }} + {{.VarName}}Repository := New{{.PascalName}}Repository(m.log, m.db) +{{- else }} + {{.VarName}}Repository := New{{.PascalName}}Repository(m.log) +{{- end }} +{{- end }} +{{- end }} {{- if .Services }} {{- range .Services }} - _ = New{{.PascalName}}Service(m.log) -{{- end }} +{{- if .HasRepository }} + {{.VarName}}Service := New{{.PascalName}}ServiceWithRepository({{.RepositoryVar}}, m.log) +{{- else }} + {{.VarName}}Service := New{{.PascalName}}Service(m.log) {{- end }} -{{- if .Repositories }} -{{- range .Repositories }} - _ = New{{.PascalName}}Repository(m.log) {{- end }} {{- end }} {{- if .Controllers }} {{- range .Controllers }} - {{.VarName}}Controller := New{{.PascalName}}Controller(m.log) +{{- if .HasService }} + {{.VarName}}Controller := New{{.PascalName}}Controller({{.ServiceVar}}, m.log) +{{- else }} + {{.VarName}}Controller := New{{.PascalName}}Controller(nil, m.log) +{{- end }} app.RegisterController({{.VarName}}Controller) {{- end }} {{- end }} diff --git a/internal/generator/templates/generate/module/module_test.go.tmpl b/internal/generator/templates/generate/module/module_test.go.tmpl index d069acf..72faccc 100644 --- a/internal/generator/templates/generate/module/module_test.go.tmpl +++ b/internal/generator/templates/generate/module/module_test.go.tmpl @@ -9,6 +9,6 @@ import ( func TestModuleRegister(t *testing.T) { app := core.New(core.KConfig{Port: 7331, ServiceName: "test", Env: "test"}) - m := NewModule(logger.NewLogger(false)) + m := NewModule(logger.NewLogger(false){{- if .UsesDatabase }}, nil{{- end }}) m.Register(app) } diff --git a/internal/generator/templates/generate/repository/repository_gorm.go.tmpl b/internal/generator/templates/generate/repository/repository_gorm.go.tmpl new file mode 100644 index 0000000..dae6fa9 --- /dev/null +++ b/internal/generator/templates/generate/repository/repository_gorm.go.tmpl @@ -0,0 +1,35 @@ +package {{.PackageName}} + +import ( + "github.com/slice-soft/ss-keel-core/logger" + "github.com/slice-soft/ss-keel-gorm/database" +) + +type {{.PascalName}}Entity struct { + ID string `gorm:"primaryKey" json:"id"` +} + +// {{.PascalName}}Repository wraps the generic GormRepository with CRUD operations +// already implemented. Add custom query methods below as needed. +type {{.PascalName}}Repository struct { + *database.GormRepository[{{.PascalName}}Entity, string] + log *logger.Logger +} + +func New{{.PascalName}}Repository(log *logger.Logger, db *database.DBinstance) *{{.PascalName}}Repository { + return &{{.PascalName}}Repository{ + GormRepository: database.NewGormRepository[{{.PascalName}}Entity, string](db), + log: log, + } +} + +// Add custom queries here. Example: +// +// func (r *{{.PascalName}}Repository) FindByEmail(ctx context.Context, email string) (*{{.PascalName}}Entity, error) { +// var entity {{.PascalName}}Entity +// err := r.DB().WithContext(ctx).Where("email = ?", email).First(&entity).Error +// if errors.Is(err, gorm.ErrRecordNotFound) { +// return nil, nil +// } +// return &entity, err +// } diff --git a/internal/generator/templates/generate/repository/repository_gorm_test.go.tmpl b/internal/generator/templates/generate/repository/repository_gorm_test.go.tmpl new file mode 100644 index 0000000..2655885 --- /dev/null +++ b/internal/generator/templates/generate/repository/repository_gorm_test.go.tmpl @@ -0,0 +1,15 @@ +package {{.PackageName}} + +import ( + "testing" + + "github.com/slice-soft/ss-keel-gorm/database" +) + +func Test{{.PascalName}}RepositoryStruct(t *testing.T) { + // Compile-time check: *{{.PascalName}}Repository satisfies core.Repository[{{.PascalName}}Entity, string] + // via the embedded GormRepository. No database connection needed for this test. + var _ interface { + DB() interface{} + } = (*database.GormRepository[{{.PascalName}}Entity, string])(nil) +} diff --git a/internal/generator/templates/generate/service/service.go.tmpl b/internal/generator/templates/generate/service/service.go.tmpl index 4404d24..025ff6e 100644 --- a/internal/generator/templates/generate/service/service.go.tmpl +++ b/internal/generator/templates/generate/service/service.go.tmpl @@ -7,13 +7,21 @@ import ( ) type {{.PascalName}}Service struct { - log *logger.Logger + repository any + log *logger.Logger } func New{{.PascalName}}Service(log *logger.Logger) *{{.PascalName}}Service { return &{{.PascalName}}Service{log: log} } +func New{{.PascalName}}ServiceWithRepository(repository any, log *logger.Logger) *{{.PascalName}}Service { + return &{{.PascalName}}Service{ + repository: repository, + log: log, + } +} + func (s *{{.PascalName}}Service) Execute(ctx context.Context) error { _ = ctx if s.log != nil {