From 850b63607c713935ed0cdfd97f9c637f0a3cfcda Mon Sep 17 00:00:00 2001 From: Rene Nochebuena Guerrero Date: Fri, 7 Aug 2026 17:00:02 -0600 Subject: [PATCH] feat(mcp): derive env vars from the framework's real struct tags (#3) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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: https://code.nochebuena.dev/einherjar/mcp/pulls/3 Co-authored-by: Rene Nochebuena Guerrero Co-committed-by: Rene Nochebuena Guerrero --- CHANGELOG.md | 50 ++++++++ README.md | 13 ++- cmd/server/main.go | 2 +- internal/envspec/envspec.go | 157 +++++++++++++++++++++++++ internal/envspec/envspec_test.go | 132 +++++++++++++++++++++ internal/index/builtins/README.md | 48 +++++--- internal/rules/env_rules.go | 82 +++++++++++++ internal/rules/env_rules_test.go | 63 ++++++++++ internal/tools/check_env.go | 179 +++++++++++++++++++++++++++++ internal/tools/env_tools_test.go | 160 ++++++++++++++++++++++++++ internal/tools/get_config_env.go | 60 ++++++++++ internal/tools/scaffold.go | 99 +++++++++++----- internal/tools/tools.go | 2 + internal/tools/validate_snippet.go | 6 +- 14 files changed, 1003 insertions(+), 50 deletions(-) create mode 100644 internal/envspec/envspec.go create mode 100644 internal/envspec/envspec_test.go create mode 100644 internal/rules/env_rules.go create mode 100644 internal/rules/env_rules_test.go create mode 100644 internal/tools/check_env.go create mode 100644 internal/tools/env_tools_test.go create mode 100644 internal/tools/get_config_env.go diff --git a/CHANGELOG.md b/CHANGELOG.md index 8306ef8..f6a7d1f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,56 @@ This module adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html --- +## [1.1.0] — 2026-08-07 + +Minor release. The env-var story is now **derived from the framework's real struct tags** end +to end: a tool to read the truth, `.env.example` generated from it, and a rule that rejects +invented names. Also folds in the scaffold-symbol fixes that were staged as v1.0.1 (never +tagged separately). The generated scaffold compiles clean against einherjar v1.0.0. + +### Added + +- **`get_config_env` tool.** Lists the real environment variables a component config reads — + name, declaring module/struct/field, `required`, and default — parsed from the indexed + struct tags (`internal/envspec`). The single source of truth for which `EINHERJAR_*` vars + exist; no hand-maintained list to drift. +- **`check_env` tool.** Checks a `.env` / `.env.example` against the framework: flags + `EINHERJAR_*` names that don't exist (e.g. `EINHERJAR_PG_DATABASE`), required vars missing + for the modules an app composes, and vars set for a module it does not compose (dead vars, + the way `EINHERJAR_SERVER_CORS_ORIGINS` is inert unless you compose `web.Config`). +- **`config.unknown-env-var` rule** (now twelve). `validate_snippet` rejects any struct field + tagged `env:"EINHERJAR_*"` whose name the framework does not declare — the exact class of + drift that shipped `EINHERJAR_PG_DATABASE` / `_SERVER_ADDR`. The valid-name set is injected + from the index at startup, so the rule can never drift from the real tags. + +### Changed + +- **`get_scaffold` now derives `.env.example` from the index** instead of a hand-written + string. Required vars (no default) are emitted uncommented with a runnable dev value; + defaulted vars are emitted commented, documenting the framework default. Names and defaults + can no longer drift from the modules the scaffold composes (`core/logz` + `web/server` + + `db-postgres`). +- **The scaffold composes `logz.Config`** instead of hard-coding the logger, so + `EINHERJAR_LOG_LEVEL` / `_JSON` are live and documented rather than silently ignored. + Verified end to end: `env.Parse` loads `slog.Level` from `EINHERJAR_LOG_LEVEL`. (Log format + is now env-driven; set `EINHERJAR_LOG_JSON=true` in production.) +- **The `wire` builtin routes the incremental flow to the new tools.** Composing a component + later now points at `get_config_env("")` for its exact vars and `check_env` to + confirm `.env.example` is complete — and spells out the split between framework `EINHERJAR_*` + (discoverable, name-checked) and app-owned `APP_*` (your discipline). + +### Fixed + +- **`logz.Logger` → `logging.Logger`.** The logger interface is `contracts/logging.Logger`; + `logz.New` returns it. The scaffold's health hook and the `wire` builtin's `withUsers` + example typed loggers as the non-existent `logz.Logger`. +- **`postgres.Component` → `postgres.Provider`** in hook signatures. `postgres.New` returns a + `Component` (a lifecycle component that embeds `Provider`); hooks and `NewUnitOfWork` take a + `Provider`. +- **The real env tags throughout:** `EINHERJAR_SERVER_ADDR` → `EINHERJAR_SERVER_HOST` + + `_PORT`, and `EINHERJAR_PG_DATABASE` → `EINHERJAR_PG_NAME` (plus `EINHERJAR_PG_SSL_MODE`). +- The scaffold's minimal health hook no longer takes unused `logger`/`db` parameters. + ## [1.0.0] — 2026-08-07 The MCP reaches **v1.0.0**, aligned with the v1.0.0 framework. The headline is the diff --git a/README.md b/README.md index 585096e..d3f2d6c 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # einherjar/mcp -[![version](https://img.shields.io/badge/version-v1.0.0-5C4EE5?style=flat-square)](https://code.nochebuena.dev/einherjar/mcp) +[![version](https://img.shields.io/badge/version-v1.1.0-5C4EE5?style=flat-square)](https://code.nochebuena.dev/einherjar/mcp) [![license](https://img.shields.io/badge/license-AGPL--3.0-22863A?style=flat-square)](LICENSE) [![go](https://img.shields.io/badge/Go-1.26+-00ADD8?style=flat-square&logo=go&logoColor=white)](https://go.dev) @@ -38,7 +38,7 @@ under pressure. ## Tools -The server exposes **eleven** tools to MCP-aware clients (Claude desktop, Claude Code, +The server exposes **thirteen** tools to MCP-aware clients (Claude desktop, Claude Code, Cursor, Zed, and anything else that speaks MCP): | Tool | Purpose | @@ -50,16 +50,19 @@ Cursor, Zed, and anything else that speaks MCP): | `list_adrs` | List architectural decision records, optionally restricted to one module | | `get_adr` | Fetch a single ADR's markdown body | | `get_example` | Canonical usage snippet — pulled from module READMEs and from the synthetic `wire` conventions | -| `get_scaffold` | The canonical **minimum application scaffold** as ready-to-write files — a clean `main.go`, `internal/wire/wire.go`, a composed `internal/config`, a health hook, and `.env.example`. Use it when starting a new Einherjar service | +| `get_scaffold` | The canonical **minimum application scaffold** as ready-to-write files — a clean `main.go`, `internal/wire/wire.go`, a composed `internal/config`, a health hook, and a **`.env.example` derived from the real component-config tags**. Use it when starting a new Einherjar service | +| `get_config_env` | The **real** env vars a component config reads — name, declaring module/struct/field, required, and default — derived from the framework's struct tags. The source of truth for building a global config or a `.env` | +| `check_env` | Check a `.env` / `.env.example` against the framework: flags EINHERJAR_* names that don't exist (e.g. `EINHERJAR_PG_DATABASE`), missing required vars for the modules you compose, and vars set for a module you don't compose | | `get_compliance` | Interface assertions and structural test names from a module's `compliance_test.go` | | `get_changelog` | Full `CHANGELOG.md` markdown for one module | | `validate_snippet` | Pattern-match a Go snippet against framework conventions; returns findings with severity, hint, and line | -`validate_snippet` ships **eleven** wiring-convention rules at v1.0.0: +`validate_snippet` ships **twelve** wiring-convention rules: `launcher.missing-run`, `launcher.no-components`, `launcher.run-error-discarded`, `logz.direct-env-read`, `web.server-not-appended`, `wire.hook-bad-signature`, `wire.hook-outside-beforestart`, `wire.route-specific-after-param`, -`main.dirty`, `main.no-godotenv-autoload`, and `config.raw-getenv`. +`main.dirty`, `main.no-godotenv-autoload`, `config.raw-getenv`, and +`config.unknown-env-var` (rejects an `env:"EINHERJAR_*"` tag the framework doesn't declare). --- diff --git a/cmd/server/main.go b/cmd/server/main.go index 7cd51a2..47c3ab6 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -23,7 +23,7 @@ import ( const ( serverName = "einherjar-mcp" - serverVersion = "v1.0.0" + serverVersion = "v1.1.0" ) func main() { diff --git a/internal/envspec/envspec.go b/internal/envspec/envspec.go new file mode 100644 index 0000000..fd185c9 --- /dev/null +++ b/internal/envspec/envspec.go @@ -0,0 +1,157 @@ +// Package envspec derives the real environment variables of Einherjar's +// component configs from the framework index — the single source of truth for +// which EINHERJAR_* vars exist, which are required, and what they default to. +// +// Every Einherjar component (db-postgres, web/server, core/logz, …) exposes a +// Config struct whose fields carry caarlos0/env tags. The indexer already +// captures those tags (index.Field.Tag); this package parses them so tools can +// build a correct global config, generate an accurate .env.example, and reject +// invented or misspelled var names — instead of anyone maintaining that list by +// hand and drifting from the framework. +package envspec + +import ( + "reflect" + "sort" + "strings" + + "code.nochebuena.dev/einherjar/mcp/internal/index" +) + +// Var is one environment variable declared by a component Config field. +type Var struct { + Name string `json:"name"` // e.g. EINHERJAR_PG_HOST + Module string `json:"module"` // e.g. db-postgres + SubPackage string `json:"subPackage,omitempty"` // e.g. server (empty at module root) + Struct string `json:"struct"` // the declaring struct, e.g. Config + Field string `json:"field"` // the Go field, e.g. Host + Type string `json:"type"` // the Go type, e.g. string + Required bool `json:"required"` // env:"...,required" + Default string `json:"default,omitempty"` // envDefault:"..." (empty when none) + HasDefault bool `json:"hasDefault"` // distinguishes "" default from no default + Doc string `json:"doc,omitempty"` // field doc comment, if any +} + +// ParseTag extracts the env var declared by a raw struct tag (backticks already +// stripped, as stored in the index). ok is false when the tag has no env key or +// the key is "-" (explicitly excluded, e.g. an http.RoundTripper field). +func ParseTag(tag string) (v struct { + Name string + Required bool + Default string + HasDefault bool +}, ok bool) { + st := reflect.StructTag(tag) + raw, present := st.Lookup("env") + if !present { + return v, false + } + parts := strings.Split(raw, ",") + name := strings.TrimSpace(parts[0]) + if name == "" || name == "-" { + return v, false + } + v.Name = name + for _, opt := range parts[1:] { + if strings.TrimSpace(opt) == "required" { + v.Required = true + } + } + if def, has := st.Lookup("envDefault"); has { + v.Default = def + v.HasDefault = true + } + ok = true + return v, ok +} + +// varsForSymbol returns the env vars declared directly by a struct symbol's +// fields. Nested component-config fields (a field whose type is another Config, +// carrying no env tag of its own) are not expanded here — caller-facing helpers +// surface them because sibling structs are indexed independently. +func varsForSymbol(m index.Module, s index.Symbol) []Var { + var out []Var + for _, f := range s.Fields { + parsed, ok := ParseTag(f.Tag) + if !ok { + continue + } + out = append(out, Var{ + Name: parsed.Name, + Module: m.Name, + SubPackage: s.SubPackage, + Struct: s.Name, + Field: f.Name, + Type: f.Type, + Required: parsed.Required, + Default: parsed.Default, + HasDefault: parsed.HasDefault, + Doc: strings.TrimSpace(f.Doc), + }) + } + return out +} + +// ForModule returns every env var declared by any struct in a module, across +// all its sub-packages (e.g. web yields web/server's EINHERJAR_SERVER_* plus +// web's EINHERJAR_SERVER_CORS_ORIGINS plus web/health's timeout). Returns nil +// when the module is unknown. Order is deterministic: sub-package, then struct, +// then declaration order. +func ForModule(idx *index.Index, module string) []Var { + m := idx.FindModule(module) + if m == nil { + return nil + } + var out []Var + for _, s := range m.Symbols { + if s.Kind != "type" || len(s.Fields) == 0 { + continue + } + out = append(out, varsForSymbol(*m, s)...) + } + return out +} + +// FindStruct returns the env vars of one specific struct, identified by module, +// sub-package (empty for a module-root package), and struct name — the precise +// selector a scaffold uses to compose named component configs. Returns nil when +// no such struct is indexed. +func FindStruct(idx *index.Index, module, subPackage, name string) []Var { + m := idx.FindModule(module) + if m == nil { + return nil + } + for _, s := range m.Symbols { + if s.Kind == "type" && s.Name == name && s.SubPackage == subPackage { + return varsForSymbol(*m, s) + } + } + return nil +} + +// All returns every env var declared anywhere in the framework, sorted by name. +func All(idx *index.Index) []Var { + var out []Var + for i := range idx.Modules { + m := idx.Modules[i] + for _, s := range m.Symbols { + if s.Kind != "type" || len(s.Fields) == 0 { + continue + } + out = append(out, varsForSymbol(m, s)...) + } + } + sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name }) + return out +} + +// KnownNames returns the set of every valid framework env-var name. It backs the +// config.unknown-env-var rule, which rejects any EINHERJAR_* tag that is not in +// this set (the exact bug class that shipped EINHERJAR_PG_DATABASE / _SERVER_ADDR). +func KnownNames(idx *index.Index) map[string]struct{} { + names := map[string]struct{}{} + for _, v := range All(idx) { + names[v.Name] = struct{}{} + } + return names +} diff --git a/internal/envspec/envspec_test.go b/internal/envspec/envspec_test.go new file mode 100644 index 0000000..32f5616 --- /dev/null +++ b/internal/envspec/envspec_test.go @@ -0,0 +1,132 @@ +package envspec + +import ( + "testing" + + "code.nochebuena.dev/einherjar/mcp/internal/index" +) + +func TestParseTag(t *testing.T) { + cases := []struct { + tag string + wantOK bool + wantName string + wantReq bool + wantDef string + wantHas bool + }{ + {`env:"EINHERJAR_PG_HOST,required"`, true, "EINHERJAR_PG_HOST", true, "", false}, + {`env:"EINHERJAR_PG_PORT" envDefault:"5432"`, true, "EINHERJAR_PG_PORT", false, "5432", true}, + {`env:"EINHERJAR_LOG_JSON" envDefault:"false"`, true, "EINHERJAR_LOG_JSON", false, "false", true}, + {`env:"-"`, false, "", false, "", false}, + {`json:"x"`, false, "", false, "", false}, + {``, false, "", false, "", false}, + } + for _, c := range cases { + got, ok := ParseTag(c.tag) + if ok != c.wantOK { + t.Errorf("ParseTag(%q) ok=%v want %v", c.tag, ok, c.wantOK) + continue + } + if !ok { + continue + } + if got.Name != c.wantName || got.Required != c.wantReq || got.Default != c.wantDef || got.HasDefault != c.wantHas { + t.Errorf("ParseTag(%q) = %+v, want name=%q req=%v def=%q has=%v", + c.tag, got, c.wantName, c.wantReq, c.wantDef, c.wantHas) + } + } +} + +// synthIndex mirrors the shape of the real index: a web module whose server +// sub-package and root both declare env vars, plus a db-postgres module. +func synthIndex() *index.Index { + return &index.Index{ + Modules: []index.Module{ + { + Name: "web", + Symbols: []index.Symbol{ + {Kind: "type", Name: "Config", SubPackage: "server", Fields: []index.Field{ + {Name: "Host", Type: "string", Tag: `env:"EINHERJAR_SERVER_HOST" envDefault:"0.0.0.0"`}, + {Name: "Port", Type: "int", Tag: `env:"EINHERJAR_SERVER_PORT" envDefault:"8080"`}, + }}, + {Kind: "type", Name: "Config", SubPackage: "", Fields: []index.Field{ + {Name: "Server", Type: "server.Config"}, // nested, no env tag + {Name: "AllowedOrigins", Type: "[]string", Tag: `env:"EINHERJAR_SERVER_CORS_ORIGINS" envSeparator:","`}, + }}, + }, + }, + { + Name: "db-postgres", + Symbols: []index.Symbol{ + {Kind: "type", Name: "Config", SubPackage: "", Fields: []index.Field{ + {Name: "Host", Type: "string", Tag: `env:"EINHERJAR_PG_HOST,required"`}, + {Name: "Name", Type: "string", Tag: `env:"EINHERJAR_PG_NAME,required"`}, + {Name: "Port", Type: "int", Tag: `env:"EINHERJAR_PG_PORT" envDefault:"5432"`}, + }}, + }, + }, + }, + } +} + +func TestForModule(t *testing.T) { + idx := synthIndex() + got := ForModule(idx, "web") + if len(got) != 3 { // 2 server + 1 CORS; the nested Server field is not a leaf + t.Fatalf("ForModule(web) = %d vars, want 3: %+v", len(got), got) + } + names := map[string]bool{} + for _, v := range got { + names[v.Name] = true + } + for _, want := range []string{"EINHERJAR_SERVER_HOST", "EINHERJAR_SERVER_PORT", "EINHERJAR_SERVER_CORS_ORIGINS"} { + if !names[want] { + t.Errorf("ForModule(web) missing %s", want) + } + } + if ForModule(idx, "nope") != nil { + t.Error("ForModule(unknown) should be nil") + } +} + +func TestFindStruct(t *testing.T) { + idx := synthIndex() + server := FindStruct(idx, "web", "server", "Config") + if len(server) != 2 { + t.Fatalf("FindStruct(web/server/Config) = %d, want 2", len(server)) + } + // The root web.Config must NOT be returned for the server selector. + for _, v := range server { + if v.Name == "EINHERJAR_SERVER_CORS_ORIGINS" { + t.Error("server selector leaked the root web.Config CORS var") + } + } + pg := FindStruct(idx, "db-postgres", "", "Config") + if len(pg) != 3 { + t.Fatalf("FindStruct(db-postgres//Config) = %d, want 3", len(pg)) + } + var host envspecVarFound + for _, v := range pg { + if v.Name == "EINHERJAR_PG_HOST" { + host = envspecVarFound{found: true, required: v.Required, hasDefault: v.HasDefault} + } + } + if !host.found || !host.required || host.hasDefault { + t.Errorf("EINHERJAR_PG_HOST: %+v, want required with no default", host) + } +} + +type envspecVarFound struct { + found, required, hasDefault bool +} + +func TestKnownNames(t *testing.T) { + names := KnownNames(synthIndex()) + if _, ok := names["EINHERJAR_PG_NAME"]; !ok { + t.Error("KnownNames missing EINHERJAR_PG_NAME") + } + if _, ok := names["EINHERJAR_PG_DATABASE"]; ok { + t.Error("KnownNames must not contain the bogus EINHERJAR_PG_DATABASE") + } +} diff --git a/internal/index/builtins/README.md b/internal/index/builtins/README.md index a8967db..95c31a3 100644 --- a/internal/index/builtins/README.md +++ b/internal/index/builtins/README.md @@ -84,6 +84,7 @@ import ( "github.com/caarlos0/env/v11" + "code.nochebuena.dev/einherjar/core/logz" "code.nochebuena.dev/einherjar/db-postgres" "code.nochebuena.dev/einherjar/web/server" ) @@ -108,6 +109,7 @@ type Config struct { // Framework component configs — composed verbatim. Their own EINHERJAR_* tags // load through this one env.Parse call. + Log logz.Config // EINHERJAR_LOG_* Server server.Config // EINHERJAR_SERVER_* PG postgres.Config // EINHERJAR_PG_* } @@ -133,6 +135,21 @@ var, the same change adds its `env:"..."` tag to `config` **and** a documented l `.env.example`. This is not optional bookkeeping — it is what stops a long feature from shipping and then failing at boot because nobody knew which variables to set. +**Two kinds of var, two ways to keep them honest:** + +- **Framework component vars (`EINHERJAR_*`)** — when you compose a new component later (e.g. + `cachevalkey.Config`, `minio.Config`, `smtp.Config`), the MCP knows its real vars: call + `get_config_env("")` for the exact set (name, required, default) and add each to + `.env.example`. The `config.unknown-env-var` rule rejects any `EINHERJAR_*` tag the framework + doesn't declare, so a typo like `EINHERJAR_PG_DATABASE` is caught at `validate_snippet` time. +- **App-owned vars (`APP_*`)** — like `APP_JWT_SECRET` above: the framework can't know these, so + keeping them in `.env.example` is your discipline, not something it can name-check. + +After you compose a component, run **`check_env`** with the modules the app composes: it flags +`EINHERJAR_*` names that don't exist, required vars you forgot to document, and vars set for a +module you don't actually compose (dead vars). `get_scaffold` already emits a `.env.example` +derived from these same tags, so the starting point is correct by construction. + ```bash # .env.example — copy to .env for local dev. Every var the app reads lives here. @@ -142,19 +159,25 @@ APP_CORS_ORIGINS=* APP_JWT_SECRET=change-me APP_JWT_ISSUER=myapp +# ── Einherjar: logging (EINHERJAR_LOG_*) ────────────────────────────────── +# EINHERJAR_LOG_LEVEL=INFO +# EINHERJAR_LOG_JSON=false + # ── Einherjar: HTTP server (EINHERJAR_SERVER_*) ─────────────────────────── -EINHERJAR_SERVER_ADDR=:8080 +EINHERJAR_SERVER_HOST=0.0.0.0 +EINHERJAR_SERVER_PORT=8080 # ── Einherjar: PostgreSQL (EINHERJAR_PG_*) ──────────────────────────────── EINHERJAR_PG_HOST=localhost EINHERJAR_PG_PORT=5432 EINHERJAR_PG_USER=postgres EINHERJAR_PG_PASSWORD=postgres -EINHERJAR_PG_DATABASE=myapp +EINHERJAR_PG_NAME=myapp ``` -To discover the full set, walk every `env:"..."` tag reachable from `config.Config` (including the -nested framework configs) — every one of them belongs in `.env.example`. +To discover the full set for any component you compose, use `get_config_env("")` rather +than reading source by hand; every var it returns belongs in `.env.example`, and `check_env` +confirms none are missing, misspelled, or dead. ## wire.go — Run() @@ -166,8 +189,6 @@ appended, then feature hooks, then `lc.Run()`. package wire import ( - "strings" - "github.com/google/uuid" authjwt "code.nochebuena.dev/einherjar/auth-jwt" @@ -189,10 +210,11 @@ func Run() error { return err } - logger := logz.New(logz.Config{ - JSON: !strings.EqualFold(cfg.AppEnv, "local"), - StaticArgs: []any{"service", "myapp", "env", cfg.AppEnv}, - }) + // logz.Config is composed in config, so EINHERJAR_LOG_LEVEL / _JSON load from + // the environment; StaticArgs are set here (they carry no env tag). + logCfg := cfg.Log + logCfg.StaticArgs = []any{"service", "myapp", "env", cfg.AppEnv} + logger := logz.New(logCfg) signer := authjwt.NewHMACSigner([]byte(cfg.JWT.Secret)) @@ -239,9 +261,9 @@ registration — lives inside the closure. package wire import ( + "code.nochebuena.dev/einherjar/contracts/logging" "code.nochebuena.dev/einherjar/contracts/security" "code.nochebuena.dev/einherjar/core/launcher" - "code.nochebuena.dev/einherjar/core/logz" "code.nochebuena.dev/einherjar/core/valid" "code.nochebuena.dev/einherjar/db-postgres" "code.nochebuena.dev/einherjar/web/server" @@ -255,8 +277,8 @@ import ( func withUsers( lc launcher.Launcher, srv server.Server, - db postgres.Component, - logger logz.Logger, + db postgres.Provider, + logger logging.Logger, provider security.PermissionProvider, v valid.Validator, ) { diff --git a/internal/rules/env_rules.go b/internal/rules/env_rules.go new file mode 100644 index 0000000..49a5022 --- /dev/null +++ b/internal/rules/env_rules.go @@ -0,0 +1,82 @@ +package rules + +import ( + "go/ast" + "reflect" + "strings" +) + +// knownEnvVars is the set of every real framework env-var name, injected once at +// server startup from the index (see rules.SetKnownEnvVars). When empty — e.g. +// in unit tests that exercise rules without an index — config.unknown-env-var is +// a no-op, so it never fires on incomplete knowledge. +var knownEnvVars map[string]struct{} + +// SetKnownEnvVars installs the authoritative set of framework env-var names that +// backs config.unknown-env-var. Call once before serving; the server derives the +// set from the index so the rule can never drift from the real struct tags. +func SetKnownEnvVars(names map[string]struct{}) { + knownEnvVars = names +} + +func init() { + registered = append(registered, + Rule{ + ID: "config.unknown-env-var", + Severity: SeverityError, + Module: "wire", + Check: checkUnknownEnvVar, + }, + ) +} + +// checkUnknownEnvVar flags any struct field tagged with an EINHERJAR_* env var +// that the framework does not actually declare — the exact class of drift that +// shipped EINHERJAR_PG_DATABASE (real name: _PG_NAME) and EINHERJAR_SERVER_ADDR +// (real: _SERVER_HOST/_PORT). App-owned prefixes (APP_*) are never flagged. +func checkUnknownEnvVar(c *Context) []Finding { + if len(knownEnvVars) == 0 { + return nil + } + var hits []Finding + ast.Inspect(c.File, func(n ast.Node) bool { + st, ok := n.(*ast.StructType) + if !ok || st.Fields == nil { + return true + } + for _, field := range st.Fields.List { + if field.Tag == nil { + continue + } + name, ok := envName(strings.Trim(field.Tag.Value, "`")) + if !ok || !strings.HasPrefix(name, "EINHERJAR_") { + continue + } + if _, real := knownEnvVars[name]; real { + continue + } + hits = append(hits, Finding{ + Message: name + " is not a real Einherjar env var (invented or misspelled)", + Hint: "Verify the exact tag with get_config_env. The framework never invents EINHERJAR_* names; compose the component's real Config or fix the tag.", + Line: c.Fset.Position(field.Tag.Pos()).Line, + }) + } + return true + }) + return hits +} + +// envName extracts the env-var name from a raw (backtick-stripped) struct tag, +// dropping the ,required / ,unset options. ok is false when there is no env key +// or it is "-". +func envName(tag string) (string, bool) { + raw, present := reflect.StructTag(tag).Lookup("env") + if !present { + return "", false + } + name := strings.TrimSpace(strings.Split(raw, ",")[0]) + if name == "" || name == "-" { + return "", false + } + return name, true +} diff --git a/internal/rules/env_rules_test.go b/internal/rules/env_rules_test.go new file mode 100644 index 0000000..f5dc444 --- /dev/null +++ b/internal/rules/env_rules_test.go @@ -0,0 +1,63 @@ +package rules + +import "testing" + +const configWithBadTag = `package config + +type Config struct { + Host string ` + "`" + `env:"EINHERJAR_PG_HOST,required"` + "`" + ` + Bad string ` + "`" + `env:"EINHERJAR_PG_DATABASE"` + "`" + ` + App string ` + "`" + `env:"APP_ENV" envDefault:"local"` + "`" + ` +} +` + +func findingsFor(fs []Finding, ruleID string) []Finding { + var out []Finding + for _, f := range fs { + if f.RuleID == ruleID { + out = append(out, f) + } + } + return out +} + +func TestUnknownEnvVarFires(t *testing.T) { + SetKnownEnvVars(map[string]struct{}{"EINHERJAR_PG_HOST": {}}) + defer SetKnownEnvVars(nil) + + got := findingsFor(Run(configWithBadTag), "config.unknown-env-var") + if len(got) != 1 { + t.Fatalf("want 1 unknown-env-var finding, got %d: %+v", len(got), got) + } + if got[0].Severity != SeverityError { + t.Errorf("want error severity, got %s", got[0].Severity) + } + // EINHERJAR_PG_HOST is known and APP_ENV is app-owned — neither may be flagged. + for _, f := range got { + if f.Message == "" || f.Line == 0 { + t.Errorf("finding should carry a message and line: %+v", f) + } + } +} + +func TestUnknownEnvVarNoopWithoutIndex(t *testing.T) { + SetKnownEnvVars(nil) // simulate no injected index + if got := findingsFor(Run(configWithBadTag), "config.unknown-env-var"); len(got) != 0 { + t.Errorf("rule must be a no-op without an injected var set, got %+v", got) + } +} + +func TestUnknownEnvVarCleanConfig(t *testing.T) { + SetKnownEnvVars(map[string]struct{}{"EINHERJAR_PG_HOST": {}, "EINHERJAR_PG_NAME": {}}) + defer SetKnownEnvVars(nil) + + clean := `package config +type Config struct { + Host string ` + "`" + `env:"EINHERJAR_PG_HOST,required"` + "`" + ` + Name string ` + "`" + `env:"EINHERJAR_PG_NAME,required"` + "`" + ` +} +` + if got := findingsFor(Run(clean), "config.unknown-env-var"); len(got) != 0 { + t.Errorf("clean config should not fire, got %+v", got) + } +} diff --git a/internal/tools/check_env.go b/internal/tools/check_env.go new file mode 100644 index 0000000..6ba4099 --- /dev/null +++ b/internal/tools/check_env.go @@ -0,0 +1,179 @@ +package tools + +import ( + "context" + "fmt" + "sort" + "strings" + + "code.nochebuena.dev/einherjar/mcp/internal/envspec" + "code.nochebuena.dev/einherjar/mcp/internal/index" + "github.com/modelcontextprotocol/go-sdk/mcp" +) + +type checkEnvInput struct { + Env string `json:"env" jsonschema:"the .env or .env.example contents to check (KEY=value lines; comments and blanks ignored)."` + Modules []string `json:"modules,omitempty" jsonschema:"the framework modules whose component configs the app composes, e.g. [\"db-postgres\",\"web\"]. Their required vars must be present; omit to only validate that EINHERJAR_* names are real."` +} + +type envFinding struct { + Severity string `json:"severity"` // error | warning | info + Kind string `json:"kind"` // unknown-var | missing-required | not-composed + Var string `json:"var"` + Message string `json:"message"` + Hint string `json:"hint,omitempty"` +} + +type checkEnvOutput struct { + Findings []envFinding `json:"findings"` + OK bool `json:"ok"` + Summary string `json:"summary"` +} + +func registerCheckEnv(s *mcp.Server, idx *index.Index) { + mcp.AddTool(s, &mcp.Tool{ + Name: "check_env", + Description: "Check a .env / .env.example against the framework's real env vars. Flags EINHERJAR_* names that don't exist (invented or misspelled, e.g. EINHERJAR_PG_DATABASE), " + + "and — when you pass the modules the app composes — required vars that are missing, plus framework vars set for a module you don't compose (dead vars). " + + "This is the enforcement half of building a correct global config: get_scaffold emits an accurate .env.example, get_config_env lists the truth, and check_env catches drift in an existing file.", + }, func(ctx context.Context, req *mcp.CallToolRequest, args checkEnvInput) (*mcp.CallToolResult, checkEnvOutput, error) { + findings, err := checkEnvVars(idx, args.Env, args.Modules) + if err != nil { + return errorResult(err.Error()), checkEnvOutput{}, nil + } + ok := true + for _, f := range findings { + if f.Severity == "error" { + ok = false + } + } + out := checkEnvOutput{Findings: findings, OK: ok, Summary: summariseEnvCheck(findings, ok)} + return jsonText(out), out, nil + }) +} + +// checkEnvVars is the pure core of check_env: given the framework index, an env +// file body, and the modules the app composes, it returns the drift findings. +func checkEnvVars(idx *index.Index, env string, modules []string) ([]envFinding, error) { + present := parseEnvKeys(env) + known := envspec.KnownNames(idx) + + // Expected vars from the composed modules, and the module each real var belongs to. + expected := map[string]envspec.Var{} + composed := map[string]bool{} + for _, mod := range modules { + mod = strings.TrimSpace(mod) + if mod == "" { + continue + } + if idx.FindModule(mod) == nil { + return nil, fmt.Errorf("module not found: %s", mod) + } + composed[mod] = true + for _, v := range envspec.ForModule(idx, mod) { + expected[v.Name] = v + } + } + varModule := map[string]string{} + for _, v := range envspec.All(idx) { + varModule[v.Name] = v.Module + } + + var findings []envFinding + + // 1. EINHERJAR_* present in the file that is not a real framework var. + for name := range present { + if !strings.HasPrefix(name, "EINHERJAR_") { + continue + } + if _, real := known[name]; real { + continue + } + findings = append(findings, envFinding{ + Severity: "error", Kind: "unknown-var", Var: name, + Message: name + " is not a real Einherjar env var", + Hint: "Confirm the exact name with get_config_env; e.g. the db name is EINHERJAR_PG_NAME (not _DATABASE) and the server bind is EINHERJAR_SERVER_HOST/_PORT (not _ADDR).", + }) + } + + // 2. Required vars of a composed module that the file never sets. + for name, v := range expected { + if !v.Required { + continue + } + if _, ok := present[name]; !ok { + findings = append(findings, envFinding{ + Severity: "error", Kind: "missing-required", Var: name, + Message: name + " is required by " + v.Module + "." + v.Struct + " but is absent from the file", + Hint: "Add " + name + "= to the file; a required var with no default fails config.Load() at boot.", + }) + } + } + + // 3. Real framework var set for a module the app doesn't compose → dead. + if len(composed) > 0 { + for name := range present { + mod, real := varModule[name] + if !real || composed[mod] { + continue + } + findings = append(findings, envFinding{ + Severity: "info", Kind: "not-composed", Var: name, + Message: name + " belongs to module " + mod + ", which this app does not list as composed — it will be ignored", + Hint: "Either compose " + mod + "'s Config, or drop the var. (This is how EINHERJAR_SERVER_CORS_ORIGINS is dead unless you compose web.Config.)", + }) + } + } + + sort.Slice(findings, func(i, j int) bool { + if findings[i].Kind != findings[j].Kind { + return findings[i].Kind < findings[j].Kind + } + return findings[i].Var < findings[j].Var + }) + if findings == nil { + findings = []envFinding{} + } + return findings, nil +} + +// parseEnvKeys returns the set of assigned variable names in an env file body, +// tolerating `export KEY=`, leading whitespace, comments, and blank lines. +func parseEnvKeys(body string) map[string]bool { + keys := map[string]bool{} + for _, line := range strings.Split(body, "\n") { + line = strings.TrimSpace(line) + if line == "" || strings.HasPrefix(line, "#") { + continue + } + line = strings.TrimPrefix(line, "export ") + eq := strings.IndexByte(line, '=') + if eq <= 0 { + continue + } + key := strings.TrimSpace(line[:eq]) + if key != "" { + keys[key] = true + } + } + return keys +} + +func summariseEnvCheck(fs []envFinding, ok bool) string { + if len(fs) == 0 { + return "No issues — every EINHERJAR_* name is real and all required vars are present." + } + var errs, infos int + for _, f := range fs { + if f.Severity == "error" { + errs++ + } else { + infos++ + } + } + status := "OK" + if !ok { + status = "FAILED" + } + return status + ": " + itoa(errs) + " error(s), " + itoa(infos) + " note(s)." +} diff --git a/internal/tools/env_tools_test.go b/internal/tools/env_tools_test.go new file mode 100644 index 0000000..c3b60da --- /dev/null +++ b/internal/tools/env_tools_test.go @@ -0,0 +1,160 @@ +package tools + +import ( + "path/filepath" + "strings" + "testing" + + "code.nochebuena.dev/einherjar/mcp/internal/index" +) + +// loadRealIndex builds the index from the sibling framework checkout at test +// time, so the env tools are tested against the framework's actual struct tags. +// It deliberately does NOT read data/index.json: that file is only an empty +// placeholder in the repo (the real index is regenerated by cmd/indexer at +// image-build time). When the sibling modules aren't present — e.g. a CI that +// checks out only this module — the test skips rather than fails. +func loadRealIndex(t *testing.T) *index.Index { + t.Helper() + idx, err := index.Build(filepath.Join("..", "..", "..")) + if err != nil || idx == nil || len(idx.Modules) == 0 { + t.Skip("framework checkout not available; skipping index-derived test") + } + return idx +} + +func TestRenderEnvExample(t *testing.T) { + idx := loadRealIndex(t) + got := renderEnvExample(idx, "myapp") + + // Required, no default → uncommented with a runnable dev value. + mustContain := []string{ + "APP_ENV=local", + "APP_CORS_ORIGINS=*", + "EINHERJAR_PG_HOST=localhost", + "EINHERJAR_PG_USER=postgres", + "EINHERJAR_PG_PASSWORD=postgres", + "EINHERJAR_PG_NAME=myapp", + // logz is composed → its vars (both defaulted) are documented, commented. + "# EINHERJAR_LOG_LEVEL=INFO", + "# EINHERJAR_LOG_JSON=false", + // Has a framework default → commented line documenting it. + "# EINHERJAR_SERVER_HOST=0.0.0.0", + "# EINHERJAR_SERVER_PORT=8080", + "# EINHERJAR_PG_PORT=5432", + "# EINHERJAR_PG_SSL_MODE=disable", + } + for _, s := range mustContain { + if !strings.Contains(got, s) { + t.Errorf(".env.example missing line: %q\n---\n%s", s, got) + } + } + + // The historical drift names must never appear. + mustNotContain := []string{ + "EINHERJAR_PG_DATABASE", + "EINHERJAR_SERVER_ADDR", + // CORS lives on web.Config, which the scaffold does not compose — so it + // must not be emitted (it would be a dead var). + "EINHERJAR_SERVER_CORS_ORIGINS", + } + for _, s := range mustNotContain { + if strings.Contains(got, s) { + t.Errorf(".env.example must not contain %q\n---\n%s", s, got) + } + } + + // A required var must never be silently emitted as a commented line. + for _, line := range strings.Split(got, "\n") { + if strings.HasPrefix(strings.TrimSpace(line), "# EINHERJAR_PG_NAME") { + t.Errorf("required EINHERJAR_PG_NAME emitted commented: %q", line) + } + } +} + +func TestCheckEnvUnknownVar(t *testing.T) { + idx := loadRealIndex(t) + env := "EINHERJAR_PG_DATABASE=x\nEINHERJAR_SERVER_ADDR=:8080\nAPP_ENV=local\n" + findings, err := checkEnvVars(idx, env, nil) + if err != nil { + t.Fatal(err) + } + unknown := map[string]bool{} + for _, f := range findings { + if f.Kind == "unknown-var" { + unknown[f.Var] = true + } + } + for _, want := range []string{"EINHERJAR_PG_DATABASE", "EINHERJAR_SERVER_ADDR"} { + if !unknown[want] { + t.Errorf("expected unknown-var finding for %s; got %+v", want, findings) + } + } + // APP_* must never be flagged. + for _, f := range findings { + if f.Var == "APP_ENV" { + t.Errorf("APP_ENV should never be flagged: %+v", f) + } + } +} + +func TestCheckEnvMissingRequired(t *testing.T) { + idx := loadRealIndex(t) + // Compose db-postgres but set none of its required vars. + findings, err := checkEnvVars(idx, "APP_ENV=local\n", []string{"db-postgres"}) + if err != nil { + t.Fatal(err) + } + missing := map[string]bool{} + for _, f := range findings { + if f.Kind == "missing-required" { + missing[f.Var] = true + } + } + for _, want := range []string{"EINHERJAR_PG_HOST", "EINHERJAR_PG_USER", "EINHERJAR_PG_PASSWORD", "EINHERJAR_PG_NAME"} { + if !missing[want] { + t.Errorf("expected missing-required for %s; got %+v", want, findings) + } + } +} + +func TestCheckEnvNotComposed(t *testing.T) { + idx := loadRealIndex(t) + // A real MinIO var while only composing db-postgres → dead (not-composed). + env := "EINHERJAR_MINIO_ENDPOINT=x\nEINHERJAR_PG_HOST=h\nEINHERJAR_PG_USER=u\nEINHERJAR_PG_PASSWORD=p\nEINHERJAR_PG_NAME=n\n" + findings, err := checkEnvVars(idx, env, []string{"db-postgres"}) + if err != nil { + t.Fatal(err) + } + var got bool + for _, f := range findings { + if f.Kind == "not-composed" && f.Var == "EINHERJAR_MINIO_ENDPOINT" { + got = true + } + } + if !got { + t.Errorf("expected not-composed for EINHERJAR_MINIO_ENDPOINT; got %+v", findings) + } +} + +func TestCheckEnvClean(t *testing.T) { + idx := loadRealIndex(t) + // The scaffold's own output, checked against the modules it composes, is clean. + env := renderEnvExample(idx, "myapp") + findings, err := checkEnvVars(idx, env, []string{"core", "web", "db-postgres"}) + if err != nil { + t.Fatal(err) + } + for _, f := range findings { + if f.Severity == "error" { + t.Errorf("scaffold .env.example should have no errors, got %+v", f) + } + } +} + +func TestCheckEnvUnknownModule(t *testing.T) { + idx := loadRealIndex(t) + if _, err := checkEnvVars(idx, "", []string{"nope"}); err == nil { + t.Error("expected error for unknown module") + } +} diff --git a/internal/tools/get_config_env.go b/internal/tools/get_config_env.go new file mode 100644 index 0000000..8793791 --- /dev/null +++ b/internal/tools/get_config_env.go @@ -0,0 +1,60 @@ +package tools + +import ( + "context" + "strings" + + "code.nochebuena.dev/einherjar/mcp/internal/envspec" + "code.nochebuena.dev/einherjar/mcp/internal/index" + "github.com/modelcontextprotocol/go-sdk/mcp" +) + +type getConfigEnvInput struct { + Module string `json:"module,omitempty" jsonschema:"restrict to one module's env vars, e.g. db-postgres or web. Omit to return every framework env var."` +} + +type getConfigEnvOutput struct { + Vars []envspec.Var `json:"vars"` + Count int `json:"count"` + Summary string `json:"summary"` +} + +func registerGetConfigEnv(s *mcp.Server, idx *index.Index) { + mcp.AddTool(s, &mcp.Tool{ + Name: "get_config_env", + Description: "List the REAL environment variables an Einherjar component config reads, derived from the framework's struct tags (never hand-maintained). " + + "Each entry gives the var name, the declaring module/struct/field, whether it is required, and its default. " + + "Use this before composing a global Config or writing a .env — it is the source of truth for which EINHERJAR_* vars exist. " + + "Pass a module (e.g. db-postgres, web, core, storage-minio) to scope it; omit to get all.", + }, func(ctx context.Context, req *mcp.CallToolRequest, args getConfigEnvInput) (*mcp.CallToolResult, getConfigEnvOutput, error) { + module := strings.TrimSpace(args.Module) + var vars []envspec.Var + if module == "" { + vars = envspec.All(idx) + } else { + if idx.FindModule(module) == nil { + return errorResult("module not found: " + module), getConfigEnvOutput{}, nil + } + vars = envspec.ForModule(idx, module) + } + if vars == nil { + vars = []envspec.Var{} + } + out := getConfigEnvOutput{Vars: vars, Count: len(vars), Summary: summariseEnv(vars, module)} + return jsonText(out), out, nil + }) +} + +func summariseEnv(vars []envspec.Var, module string) string { + var required int + for _, v := range vars { + if v.Required { + required++ + } + } + scope := "the framework" + if module != "" { + scope = module + } + return itoa(len(vars)) + " env var(s) for " + scope + ", " + itoa(required) + " required." +} diff --git a/internal/tools/scaffold.go b/internal/tools/scaffold.go index 9f3c321..934227a 100644 --- a/internal/tools/scaffold.go +++ b/internal/tools/scaffold.go @@ -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/.go per feature (a with hook) plus its internal//{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 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") +} diff --git a/internal/tools/tools.go b/internal/tools/tools.go index b8dbc3d..2ed4c1c 100644 --- a/internal/tools/tools.go +++ b/internal/tools/tools.go @@ -20,6 +20,8 @@ func Register(s *mcp.Server, idx *index.Index) { registerGetADR(s, idx) registerGetExample(s, idx) registerGetScaffold(s, idx) + registerGetConfigEnv(s, idx) + registerCheckEnv(s, idx) registerValidateSnippet(s, idx) registerGetCompliance(s, idx) registerGetChangelog(s, idx) diff --git a/internal/tools/validate_snippet.go b/internal/tools/validate_snippet.go index 872d0dd..9f91545 100644 --- a/internal/tools/validate_snippet.go +++ b/internal/tools/validate_snippet.go @@ -3,6 +3,7 @@ package tools import ( "context" + "code.nochebuena.dev/einherjar/mcp/internal/envspec" "code.nochebuena.dev/einherjar/mcp/internal/index" "code.nochebuena.dev/einherjar/mcp/internal/rules" "github.com/modelcontextprotocol/go-sdk/mcp" @@ -17,7 +18,10 @@ type validateSnippetOutput struct { Summary string `json:"summary"` } -func registerValidateSnippet(s *mcp.Server, _ *index.Index) { +func registerValidateSnippet(s *mcp.Server, idx *index.Index) { + // Inject the real framework env-var names so config.unknown-env-var can reject + // invented/misspelled EINHERJAR_* tags instead of guessing. + rules.SetKnownEnvVars(envspec.KnownNames(idx)) mcp.AddTool(s, &mcp.Tool{ Name: "validate_snippet", Description: "Validate a Go snippet against Einherjar wiring conventions: lifecycle setup, logger configuration, env-var handling, server registration. Findings are advisory, not a substitute for go vet or the project's tests.",