feat(mcp): derive env vars from the framework's real struct tags (#3)
Minor to v1.1.0. Make the whole environment-variable surface derive from the
indexed component-config tags instead of a hand-maintained list that drifts.
Also folds in the scaffold-symbol fixes staged as v1.0.1 (never tagged); the
generated scaffold compiles clean against einherjar v1.0.0.
internal/envspec (new):
- Parse index struct tags into env vars {name, module, struct, field, required,
default}. One source of truth: ParseTag, ForModule, FindStruct, All, KnownNames.
internal/tools:
- get_config_env: list the real env vars a component config reads (or all).
- check_env: flag unknown EINHERJAR_* names, required vars missing for the
composed modules, and dead vars (set for an uncomposed module).
- get_scaffold: .env.example is now DERIVED from the index — required vars
uncommented with a dev value, defaulted vars commented with their default.
The scaffold now composes logz.Config (Log logz.Config) so EINHERJAR_LOG_*
are live and documented, not hardcoded/ignored (log format is env-driven).
- validate_snippet: inject the real env-var name set into the rules package.
internal/index/builtins (wire conventions):
- Route the incremental "compose a component later" flow to get_config_env /
check_env; distinguish framework EINHERJAR_* from app-owned APP_* (JWT).
- Compose logz.Config in the config + Run() examples, to match the scaffold.
internal/rules:
- config.unknown-env-var (twelfth rule): reject an env:"EINHERJAR_*" struct tag
the framework doesn't declare. No-op until the server injects the name set, so
it never fires on incomplete knowledge.
Fixed (was v1.0.1): scaffold health hook + wire builtin used logz.Logger (real:
contracts/logging.Logger) and postgres.Component (hooks take Provider); env tags
were EINHERJAR_SERVER_ADDR / EINHERJAR_PG_DATABASE (real: _HOST/_PORT / _PG_NAME).
Tests: envspec unit tests; env tools against the real data/index.json; the rule.
Verified by generating the scaffold, building it against local einherjar v1.0.0
(exit 0), and runtime-loading the composed logz.Config (EINHERJAR_LOG_LEVEL=DEBUG
-> slog.LevelDebug, EINHERJAR_LOG_JSON=true). Version bumped to v1.1.0 (badge +
serverVersion). No dependency changes.
Reviewed-on: #3
Co-authored-by: Rene Nochebuena Guerrero <rene@nochebuena.dev>
Co-committed-by: Rene Nochebuena Guerrero <rene@nochebuena.dev>
This commit was merged in pull request #3.
This commit is contained in:
+69
-30
@@ -2,8 +2,10 @@ 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"
|
||||
)
|
||||
@@ -54,11 +56,11 @@ func registerGetScaffold(s *mcp.Server, idx *index.Index) {
|
||||
{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: repl.Replace(tplEnvExample)},
|
||||
{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.",
|
||||
"Every env var the config reads must also appear in .env.example, kept in lock-step. When a feature adds a var, update both.",
|
||||
".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.",
|
||||
@@ -98,8 +100,6 @@ func main() {
|
||||
const tplWire = `package wire
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
||||
"code.nochebuena.dev/einherjar/core/launcher"
|
||||
@@ -120,10 +120,11 @@ func Run() error {
|
||||
return err
|
||||
}
|
||||
|
||||
logger := logz.New(logz.Config{
|
||||
JSON: !strings.EqualFold(cfg.AppEnv, "local"),
|
||||
StaticArgs: []any{"service", "%%APP%%", "env", cfg.AppEnv},
|
||||
})
|
||||
// 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)
|
||||
srv := server.New(logger, cfg.Server,
|
||||
@@ -138,7 +139,7 @@ func Run() error {
|
||||
lc := launcher.New(logger)
|
||||
lc.Append(db, srv)
|
||||
|
||||
withHealth(lc, srv, logger, db)
|
||||
withHealth(lc, srv)
|
||||
// … one withFeature(lc, srv, …) call per feature in your domain.
|
||||
|
||||
return lc.Run()
|
||||
@@ -151,14 +152,13 @@ import (
|
||||
"net/http"
|
||||
|
||||
"code.nochebuena.dev/einherjar/core/launcher"
|
||||
"code.nochebuena.dev/einherjar/core/logz"
|
||||
"code.nochebuena.dev/einherjar/db-postgres"
|
||||
"code.nochebuena.dev/einherjar/web/server"
|
||||
)
|
||||
|
||||
// withHealth registers a liveness endpoint. Grow it into a readiness check
|
||||
// (ping db and other dependencies) as the service gains infrastructure.
|
||||
func withHealth(lc launcher.Launcher, srv server.Server, logger logz.Logger, db postgres.Component) {
|
||||
// withHealth registers a liveness endpoint — the simplest with<Feature> hook.
|
||||
// A real feature hook takes its deps (logging.Logger, postgres.Provider, …) and
|
||||
// wires repo→service→handler inside the closure; see get_example("wire").
|
||||
func withHealth(lc launcher.Launcher, srv server.Server) {
|
||||
lc.BeforeStart(func() error {
|
||||
srv.Get("/health", func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
@@ -181,19 +181,21 @@ package config
|
||||
import (
|
||||
"github.com/caarlos0/env/v11"
|
||||
|
||||
"code.nochebuena.dev/einherjar/core/logz"
|
||||
"code.nochebuena.dev/einherjar/db-postgres"
|
||||
"code.nochebuena.dev/einherjar/web/server"
|
||||
)
|
||||
|
||||
// Config is the fully-resolved startup configuration. caarlos0/env recurses into
|
||||
// the nested framework configs, populating their EINHERJAR_SERVER_* / EINHERJAR_PG_*
|
||||
// tags from the environment next to the app-owned APP_* fields.
|
||||
// the nested framework configs, populating their EINHERJAR_LOG_* / EINHERJAR_SERVER_* /
|
||||
// EINHERJAR_PG_* tags from the environment next to the app-owned APP_* fields.
|
||||
type Config struct {
|
||||
AppEnv string ` + "`" + `env:"APP_ENV" envDefault:"local"` + "`" + `
|
||||
CORSOrigins []string ` + "`" + `env:"APP_CORS_ORIGINS" envSeparator:","` + "`" + `
|
||||
|
||||
// Framework component configs — composed verbatim; their EINHERJAR_* tags
|
||||
// load through this same env.Parse call.
|
||||
Log logz.Config // EINHERJAR_LOG_*
|
||||
Server server.Config // EINHERJAR_SERVER_*
|
||||
PG postgres.Config // EINHERJAR_PG_*
|
||||
}
|
||||
@@ -207,20 +209,57 @@ func Load() (Config, error) {
|
||||
}
|
||||
`
|
||||
|
||||
const tplEnvExample = `# .env.example — copy to .env for local dev (godotenv autoloads it).
|
||||
# Every variable the config package reads lives here, documented. Keep in sync.
|
||||
// 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")
|
||||
|
||||
# ── App (APP_*) ────────────────────────────────────────────────────────────
|
||||
APP_ENV=local
|
||||
APP_CORS_ORIGINS=*
|
||||
b.WriteString("# ── App (APP_*) ────────────────────────────────────────────────────────────\n")
|
||||
b.WriteString("APP_ENV=local\n")
|
||||
b.WriteString("APP_CORS_ORIGINS=*\n\n")
|
||||
|
||||
# ── Einherjar: HTTP server (EINHERJAR_SERVER_*) ───────────────────────────
|
||||
EINHERJAR_SERVER_ADDR=:8080
|
||||
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: PostgreSQL", envspec.FindStruct(idx, "db-postgres", "", "Config"), app)
|
||||
return b.String()
|
||||
}
|
||||
|
||||
# ── Einherjar: PostgreSQL (EINHERJAR_PG_*) ────────────────────────────────
|
||||
EINHERJAR_PG_HOST=localhost
|
||||
EINHERJAR_PG_PORT=5432
|
||||
EINHERJAR_PG_USER=postgres
|
||||
EINHERJAR_PG_PASSWORD=postgres
|
||||
EINHERJAR_PG_DATABASE=%%APP%%
|
||||
`
|
||||
// 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")
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user