282 lines
11 KiB
Go
282 lines
11 KiB
Go
package tools
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"strings"
|
|
|
|
"code.nochebuena.dev/einherjar/mcp/internal/envspec"
|
|
"code.nochebuena.dev/einherjar/mcp/internal/index"
|
|
"github.com/modelcontextprotocol/go-sdk/mcp"
|
|
)
|
|
|
|
type getScaffoldInput struct {
|
|
Module string `json:"module,omitempty" jsonschema:"the go.mod module path of the new app, e.g. code.nochebuena.dev/org/myapp; used to fill import paths. Defaults to 'myapp'."`
|
|
Service string `json:"service,omitempty" jsonschema:"short service name for the cmd/ dir and the logz service tag, e.g. myapp. Defaults to the last path segment of module."`
|
|
}
|
|
|
|
type scaffoldFile struct {
|
|
Path string `json:"path"`
|
|
Content string `json:"content"`
|
|
}
|
|
|
|
type getScaffoldOutput struct {
|
|
Layout string `json:"layout"`
|
|
Files []scaffoldFile `json:"files"`
|
|
Notes []string `json:"notes"`
|
|
}
|
|
|
|
func registerGetScaffold(s *mcp.Server, idx *index.Index) {
|
|
mcp.AddTool(s, &mcp.Tool{
|
|
Name: "get_scaffold",
|
|
Description: "Return the canonical MINIMUM Einherjar application scaffold as ready-to-write files: " +
|
|
"a clean main.go (godotenv autoload + wire.Run), internal/wire/wire.go (the launcher assembly), " +
|
|
"internal/config/config.go (one Config composing the framework's component configs via caarlos0/env), " +
|
|
"a health feature hook, and .env.example. This is the one opinionated starting point — call it when " +
|
|
"creating a new Einherjar service so main.go and the launcher stay clean instead of being hand-rolled. " +
|
|
"Migrations and seeding are deliberately excluded — those are the developer's choice, not a framework convention.",
|
|
}, func(ctx context.Context, req *mcp.CallToolRequest, args getScaffoldInput) (*mcp.CallToolResult, getScaffoldOutput, error) {
|
|
module := strings.TrimSpace(args.Module)
|
|
if module == "" {
|
|
module = "myapp"
|
|
}
|
|
service := strings.TrimSpace(args.Service)
|
|
if service == "" {
|
|
service = module
|
|
if i := strings.LastIndex(module, "/"); i >= 0 {
|
|
service = module[i+1:]
|
|
}
|
|
}
|
|
repl := strings.NewReplacer("%%MODULE%%", module, "%%APP%%", service)
|
|
|
|
out := getScaffoldOutput{
|
|
Layout: scaffoldLayout,
|
|
Files: []scaffoldFile{
|
|
{Path: "cmd/" + service + "/main.go", Content: repl.Replace(tplMain)},
|
|
{Path: "internal/wire/wire.go", Content: repl.Replace(tplWire)},
|
|
{Path: "internal/wire/health.go", Content: repl.Replace(tplHealth)},
|
|
{Path: "internal/config/config.go", Content: repl.Replace(tplConfig)},
|
|
{Path: ".env.example", Content: renderEnvExample(idx, service)},
|
|
},
|
|
Notes: []string{
|
|
"main.go contains ONLY the godotenv autoload blank import and wire.Run() — never construct components there.",
|
|
".env.example is DERIVED from the framework's real component-config tags (web/server + db-postgres), so the EINHERJAR_* names and defaults are always correct. When you compose another component, add its section — get_config_env(module) lists the exact vars, and check_env catches drift.",
|
|
"App-owned config fields use the APP_* prefix; framework component configs load their own EINHERJAR_* tags through the same env.Parse.",
|
|
"Add one internal/wire/<feature>.go per feature (a with<Feature> hook) plus its internal/<feature>/{dto,handler,repository,service} layers; call validate_snippet / get_example(\"wire\") for the hook shape.",
|
|
"Migrations and seeding are the developer's choice — not part of this scaffold.",
|
|
},
|
|
}
|
|
return jsonText(out), out, nil
|
|
})
|
|
}
|
|
|
|
const scaffoldLayout = `cmd/%%APP%%/main.go godotenv autoload + wire.Run()
|
|
internal/wire/wire.go Run() — config, infra, feature hooks
|
|
internal/wire/<feature>.go one file per feature (with<Feature> hook)
|
|
internal/wire/middleware.go authz / skip helpers (add when you add authz'd routes)
|
|
internal/config/config.go Config composing framework component configs
|
|
.env.example every env var the config reads, in sync
|
|
internal/<feature>/{dto,handler,repository,service}/`
|
|
|
|
const tplMain = `package main
|
|
|
|
import (
|
|
"fmt"
|
|
"os"
|
|
|
|
_ "github.com/joho/godotenv/autoload"
|
|
|
|
"%%MODULE%%/internal/wire"
|
|
)
|
|
|
|
func main() {
|
|
if err := wire.Run(); err != nil {
|
|
fmt.Fprintln(os.Stderr, "fatal:", err)
|
|
os.Exit(1)
|
|
}
|
|
}
|
|
`
|
|
|
|
const tplWire = `package wire
|
|
|
|
import (
|
|
"net/http"
|
|
"strings"
|
|
|
|
"github.com/google/uuid"
|
|
|
|
"code.nochebuena.dev/einherjar/core/launcher"
|
|
"code.nochebuena.dev/einherjar/core/logz"
|
|
"code.nochebuena.dev/einherjar/db-postgres"
|
|
"code.nochebuena.dev/einherjar/web/mw"
|
|
"code.nochebuena.dev/einherjar/web/server"
|
|
|
|
"%%MODULE%%/internal/config"
|
|
)
|
|
|
|
// Run loads configuration, builds infrastructure, registers feature hooks, and
|
|
// blocks until shutdown. Order is load-bearing: config, logger, infra, launcher
|
|
// with every component appended, then feature hooks, then lc.Run().
|
|
func Run() error {
|
|
cfg, err := config.Load()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
// logz.Config is composed in config.Config, so EINHERJAR_LOG_LEVEL / _JSON load
|
|
// from the environment; StaticArgs are set here in code (they carry no env tag).
|
|
logCfg := cfg.Log
|
|
logCfg.StaticArgs = []any{"service", "%%APP%%", "env", cfg.AppEnv}
|
|
logger := logz.New(logCfg)
|
|
|
|
db := postgres.New(logger, cfg.PG)
|
|
|
|
// CORS: allow-all in local dev; explicit origins elsewhere. Origins come from the
|
|
// framework's own EINHERJAR_SERVER_CORS_ORIGINS (cfg.Server.CORSOrigins). mw.CORS
|
|
// panics on "*", so never set that var to a wildcard outside local.
|
|
var corsMW func(http.Handler) http.Handler
|
|
if strings.EqualFold(cfg.AppEnv, "local") {
|
|
corsMW = mw.CORSAllowAll()
|
|
} else {
|
|
corsMW = mw.CORS(cfg.Server.CORSOrigins)
|
|
}
|
|
|
|
srv := server.New(logger, cfg.Server,
|
|
server.WithMiddleware(
|
|
mw.RequestID(uuid.NewString),
|
|
mw.Recover(logger),
|
|
corsMW,
|
|
mw.RequestLogger(logger),
|
|
),
|
|
)
|
|
|
|
lc := launcher.New(logger, cfg.Launcher)
|
|
lc.Append(db, srv)
|
|
|
|
withHealth(lc, srv, logger, cfg.Health, db)
|
|
// … one withFeature(lc, srv, …) call per feature in your domain.
|
|
|
|
return lc.Run()
|
|
}
|
|
`
|
|
|
|
const tplHealth = `package wire
|
|
|
|
import (
|
|
"code.nochebuena.dev/einherjar/contracts/logging"
|
|
"code.nochebuena.dev/einherjar/contracts/observability"
|
|
"code.nochebuena.dev/einherjar/core/launcher"
|
|
"code.nochebuena.dev/einherjar/web/health"
|
|
"code.nochebuena.dev/einherjar/web/server"
|
|
)
|
|
|
|
// withHealth wires a concurrent health endpoint from the app's Checkable components
|
|
// (db, cache, …). health.NewHandlerWithConfig honors EINHERJAR_HEALTH_CHECK_TIMEOUT
|
|
// through cfg. Add more with<Feature> hooks the same way; see get_example("wire").
|
|
func withHealth(lc launcher.Launcher, srv server.Server, logger logging.Logger, cfg health.Config, checks ...observability.Checkable) {
|
|
lc.BeforeStart(func() error {
|
|
srv.Get("/health", health.NewHandlerWithConfig(logger, cfg, checks...).ServeHTTP)
|
|
return nil
|
|
})
|
|
}
|
|
`
|
|
|
|
const tplConfig = `// Package config loads %%APP%%'s startup configuration from the environment.
|
|
//
|
|
// Einherjar component configs (server.Config, postgres.Config, …) are composed
|
|
// verbatim as nested fields so their EINHERJAR_* env tags load alongside the
|
|
// app-owned APP_* fields through a single caarlos0/env parse. Every env var
|
|
// declared here must also be documented in .env.example, kept in lock-step.
|
|
package config
|
|
|
|
import (
|
|
"github.com/caarlos0/env/v11"
|
|
|
|
"code.nochebuena.dev/einherjar/core/launcher"
|
|
"code.nochebuena.dev/einherjar/core/logz"
|
|
"code.nochebuena.dev/einherjar/db-postgres"
|
|
"code.nochebuena.dev/einherjar/web/health"
|
|
"code.nochebuena.dev/einherjar/web/server"
|
|
)
|
|
|
|
// Config is the fully-resolved startup configuration. caarlos0/env recurses into
|
|
// the nested framework configs, populating their EINHERJAR_* tags from the
|
|
// environment next to the app-owned APP_* fields.
|
|
type Config struct {
|
|
AppEnv string ` + "`" + `env:"APP_ENV" envDefault:"local"` + "`" + `
|
|
|
|
// Framework component configs — composed verbatim; their EINHERJAR_* tags
|
|
// load through this same env.Parse call.
|
|
Launcher launcher.Config // EINHERJAR_COMPONENT_STOP_TIMEOUT
|
|
Log logz.Config // EINHERJAR_LOG_*
|
|
Server server.Config // EINHERJAR_SERVER_* (incl. EINHERJAR_SERVER_CORS_ORIGINS)
|
|
Health health.Config // EINHERJAR_HEALTH_CHECK_TIMEOUT
|
|
PG postgres.Config // EINHERJAR_PG_*
|
|
}
|
|
|
|
func Load() (Config, error) {
|
|
var cfg Config
|
|
if err := env.Parse(&cfg); err != nil {
|
|
return Config{}, err
|
|
}
|
|
return cfg, nil
|
|
}
|
|
`
|
|
|
|
// renderEnvExample builds .env.example from the framework's real component-config
|
|
// tags rather than a hand-maintained string, so the EINHERJAR_* names and defaults
|
|
// can never drift from the modules the scaffold composes (web/server + db-postgres).
|
|
// Required vars (no default) are emitted uncommented with a runnable dev value;
|
|
// vars that carry a framework default are emitted commented — documented, inert
|
|
// until overridden.
|
|
func renderEnvExample(idx *index.Index, app string) string {
|
|
var b strings.Builder
|
|
b.WriteString("# .env.example — copy to .env for local dev (godotenv autoloads it).\n")
|
|
b.WriteString("# Derived from the framework's real config tags. Required vars are uncommented;\n")
|
|
b.WriteString("# a commented line documents that var's framework default — uncomment to override.\n\n")
|
|
|
|
b.WriteString("# ── App (APP_*) ────────────────────────────────────────────────────────────\n")
|
|
b.WriteString("APP_ENV=local\n")
|
|
b.WriteString("# CORS: local uses mw.CORSAllowAll(); non-local reads EINHERJAR_SERVER_CORS_ORIGINS below.\n\n")
|
|
|
|
writeEnvSection(&b, "Einherjar: launcher", envspec.FindStruct(idx, "core", "launcher", "Config"), app)
|
|
writeEnvSection(&b, "Einherjar: logging", envspec.FindStruct(idx, "core", "logz", "Config"), app)
|
|
writeEnvSection(&b, "Einherjar: HTTP server", envspec.FindStruct(idx, "web", "server", "Config"), app)
|
|
writeEnvSection(&b, "Einherjar: health", envspec.FindStruct(idx, "web", "health", "Config"), app)
|
|
writeEnvSection(&b, "Einherjar: PostgreSQL", envspec.FindStruct(idx, "db-postgres", "", "Config"), app)
|
|
return b.String()
|
|
}
|
|
|
|
// envDevDefaults gives required vars (which carry no framework default) a value
|
|
// that runs against the local devtools stack out of the box.
|
|
var envDevDefaults = map[string]string{
|
|
"EINHERJAR_PG_HOST": "localhost",
|
|
"EINHERJAR_PG_USER": "postgres",
|
|
"EINHERJAR_PG_PASSWORD": "postgres",
|
|
}
|
|
|
|
func envDevValue(v envspec.Var, app string) string {
|
|
if v.Name == "EINHERJAR_PG_NAME" {
|
|
return app
|
|
}
|
|
return envDevDefaults[v.Name]
|
|
}
|
|
|
|
func writeEnvSection(b *strings.Builder, title string, vars []envspec.Var, app string) {
|
|
if len(vars) == 0 {
|
|
return
|
|
}
|
|
fmt.Fprintf(b, "# ── %s ──────────────────────────────────────\n", title)
|
|
for _, v := range vars {
|
|
switch {
|
|
case v.HasDefault:
|
|
fmt.Fprintf(b, "# %s=%s\n", v.Name, v.Default)
|
|
case v.Required:
|
|
fmt.Fprintf(b, "%s=%s\n", v.Name, envDevValue(v, app))
|
|
default:
|
|
fmt.Fprintf(b, "# %s=%s\n", v.Name, envDevValue(v, app))
|
|
}
|
|
}
|
|
b.WriteString("\n")
|
|
}
|