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:
@@ -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("<module>")` 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
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# einherjar/mcp
|
||||
|
||||
[](https://code.nochebuena.dev/einherjar/mcp)
|
||||
[](https://code.nochebuena.dev/einherjar/mcp)
|
||||
[](LICENSE)
|
||||
[](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).
|
||||
|
||||
---
|
||||
|
||||
|
||||
+1
-1
@@ -23,7 +23,7 @@ import (
|
||||
|
||||
const (
|
||||
serverName = "einherjar-mcp"
|
||||
serverVersion = "v1.0.0"
|
||||
serverVersion = "v1.1.0"
|
||||
)
|
||||
|
||||
func main() {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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("<module>")` 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("<module>")` 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,
|
||||
) {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)."
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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."
|
||||
}
|
||||
+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")
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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.",
|
||||
|
||||
Reference in New Issue
Block a user