feat(mcp): derive env vars from the framework's real struct tags
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.
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
|
## [1.0.0] — 2026-08-07
|
||||||
|
|
||||||
The MCP reaches **v1.0.0**, aligned with the v1.0.0 framework. The headline is the
|
The MCP reaches **v1.0.0**, aligned with the v1.0.0 framework. The headline is the
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# einherjar/mcp
|
# einherjar/mcp
|
||||||
|
|
||||||
[](https://code.nochebuena.dev/einherjar/mcp)
|
[](https://code.nochebuena.dev/einherjar/mcp)
|
||||||
[](LICENSE)
|
[](LICENSE)
|
||||||
[](https://go.dev)
|
[](https://go.dev)
|
||||||
|
|
||||||
@@ -38,7 +38,7 @@ under pressure.
|
|||||||
|
|
||||||
## Tools
|
## 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):
|
Cursor, Zed, and anything else that speaks MCP):
|
||||||
|
|
||||||
| Tool | Purpose |
|
| 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 |
|
| `list_adrs` | List architectural decision records, optionally restricted to one module |
|
||||||
| `get_adr` | Fetch a single ADR's markdown body |
|
| `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_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_compliance` | Interface assertions and structural test names from a module's `compliance_test.go` |
|
||||||
| `get_changelog` | Full `CHANGELOG.md` markdown for one module |
|
| `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` | 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`,
|
`launcher.missing-run`, `launcher.no-components`, `launcher.run-error-discarded`,
|
||||||
`logz.direct-env-read`, `web.server-not-appended`, `wire.hook-bad-signature`,
|
`logz.direct-env-read`, `web.server-not-appended`, `wire.hook-bad-signature`,
|
||||||
`wire.hook-outside-beforestart`, `wire.route-specific-after-param`,
|
`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 (
|
const (
|
||||||
serverName = "einherjar-mcp"
|
serverName = "einherjar-mcp"
|
||||||
serverVersion = "v1.0.0"
|
serverVersion = "v1.1.0"
|
||||||
)
|
)
|
||||||
|
|
||||||
func main() {
|
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"
|
"github.com/caarlos0/env/v11"
|
||||||
|
|
||||||
|
"code.nochebuena.dev/einherjar/core/logz"
|
||||||
"code.nochebuena.dev/einherjar/db-postgres"
|
"code.nochebuena.dev/einherjar/db-postgres"
|
||||||
"code.nochebuena.dev/einherjar/web/server"
|
"code.nochebuena.dev/einherjar/web/server"
|
||||||
)
|
)
|
||||||
@@ -108,6 +109,7 @@ type Config struct {
|
|||||||
|
|
||||||
// Framework component configs — composed verbatim. Their own EINHERJAR_* tags
|
// Framework component configs — composed verbatim. Their own EINHERJAR_* tags
|
||||||
// load through this one env.Parse call.
|
// load through this one env.Parse call.
|
||||||
|
Log logz.Config // EINHERJAR_LOG_*
|
||||||
Server server.Config // EINHERJAR_SERVER_*
|
Server server.Config // EINHERJAR_SERVER_*
|
||||||
PG postgres.Config // EINHERJAR_PG_*
|
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
|
`.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.
|
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
|
```bash
|
||||||
# .env.example — copy to .env for local dev. Every var the app reads lives here.
|
# .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_SECRET=change-me
|
||||||
APP_JWT_ISSUER=myapp
|
APP_JWT_ISSUER=myapp
|
||||||
|
|
||||||
|
# ── Einherjar: logging (EINHERJAR_LOG_*) ──────────────────────────────────
|
||||||
|
# EINHERJAR_LOG_LEVEL=INFO
|
||||||
|
# EINHERJAR_LOG_JSON=false
|
||||||
|
|
||||||
# ── Einherjar: HTTP server (EINHERJAR_SERVER_*) ───────────────────────────
|
# ── 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: PostgreSQL (EINHERJAR_PG_*) ────────────────────────────────
|
||||||
EINHERJAR_PG_HOST=localhost
|
EINHERJAR_PG_HOST=localhost
|
||||||
EINHERJAR_PG_PORT=5432
|
EINHERJAR_PG_PORT=5432
|
||||||
EINHERJAR_PG_USER=postgres
|
EINHERJAR_PG_USER=postgres
|
||||||
EINHERJAR_PG_PASSWORD=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
|
To discover the full set for any component you compose, use `get_config_env("<module>")` rather
|
||||||
nested framework configs) — every one of them belongs in `.env.example`.
|
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()
|
## wire.go — Run()
|
||||||
|
|
||||||
@@ -166,8 +189,6 @@ appended, then feature hooks, then `lc.Run()`.
|
|||||||
package wire
|
package wire
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"strings"
|
|
||||||
|
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
|
|
||||||
authjwt "code.nochebuena.dev/einherjar/auth-jwt"
|
authjwt "code.nochebuena.dev/einherjar/auth-jwt"
|
||||||
@@ -189,10 +210,11 @@ func Run() error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
logger := logz.New(logz.Config{
|
// logz.Config is composed in config, so EINHERJAR_LOG_LEVEL / _JSON load from
|
||||||
JSON: !strings.EqualFold(cfg.AppEnv, "local"),
|
// the environment; StaticArgs are set here (they carry no env tag).
|
||||||
StaticArgs: []any{"service", "myapp", "env", cfg.AppEnv},
|
logCfg := cfg.Log
|
||||||
})
|
logCfg.StaticArgs = []any{"service", "myapp", "env", cfg.AppEnv}
|
||||||
|
logger := logz.New(logCfg)
|
||||||
|
|
||||||
signer := authjwt.NewHMACSigner([]byte(cfg.JWT.Secret))
|
signer := authjwt.NewHMACSigner([]byte(cfg.JWT.Secret))
|
||||||
|
|
||||||
@@ -239,9 +261,9 @@ registration — lives inside the closure.
|
|||||||
package wire
|
package wire
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"code.nochebuena.dev/einherjar/contracts/logging"
|
||||||
"code.nochebuena.dev/einherjar/contracts/security"
|
"code.nochebuena.dev/einherjar/contracts/security"
|
||||||
"code.nochebuena.dev/einherjar/core/launcher"
|
"code.nochebuena.dev/einherjar/core/launcher"
|
||||||
"code.nochebuena.dev/einherjar/core/logz"
|
|
||||||
"code.nochebuena.dev/einherjar/core/valid"
|
"code.nochebuena.dev/einherjar/core/valid"
|
||||||
"code.nochebuena.dev/einherjar/db-postgres"
|
"code.nochebuena.dev/einherjar/db-postgres"
|
||||||
"code.nochebuena.dev/einherjar/web/server"
|
"code.nochebuena.dev/einherjar/web/server"
|
||||||
@@ -255,8 +277,8 @@ import (
|
|||||||
func withUsers(
|
func withUsers(
|
||||||
lc launcher.Launcher,
|
lc launcher.Launcher,
|
||||||
srv server.Server,
|
srv server.Server,
|
||||||
db postgres.Component,
|
db postgres.Provider,
|
||||||
logger logz.Logger,
|
logger logging.Logger,
|
||||||
provider security.PermissionProvider,
|
provider security.PermissionProvider,
|
||||||
v valid.Validator,
|
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 (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"fmt"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
|
"code.nochebuena.dev/einherjar/mcp/internal/envspec"
|
||||||
"code.nochebuena.dev/einherjar/mcp/internal/index"
|
"code.nochebuena.dev/einherjar/mcp/internal/index"
|
||||||
"github.com/modelcontextprotocol/go-sdk/mcp"
|
"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/wire.go", Content: repl.Replace(tplWire)},
|
||||||
{Path: "internal/wire/health.go", Content: repl.Replace(tplHealth)},
|
{Path: "internal/wire/health.go", Content: repl.Replace(tplHealth)},
|
||||||
{Path: "internal/config/config.go", Content: repl.Replace(tplConfig)},
|
{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{
|
Notes: []string{
|
||||||
"main.go contains ONLY the godotenv autoload blank import and wire.Run() — never construct components there.",
|
"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.",
|
"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.",
|
"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.",
|
"Migrations and seeding are the developer's choice — not part of this scaffold.",
|
||||||
@@ -98,8 +100,6 @@ func main() {
|
|||||||
const tplWire = `package wire
|
const tplWire = `package wire
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"strings"
|
|
||||||
|
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
|
|
||||||
"code.nochebuena.dev/einherjar/core/launcher"
|
"code.nochebuena.dev/einherjar/core/launcher"
|
||||||
@@ -120,10 +120,11 @@ func Run() error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
logger := logz.New(logz.Config{
|
// logz.Config is composed in config.Config, so EINHERJAR_LOG_LEVEL / _JSON load
|
||||||
JSON: !strings.EqualFold(cfg.AppEnv, "local"),
|
// from the environment; StaticArgs are set here in code (they carry no env tag).
|
||||||
StaticArgs: []any{"service", "%%APP%%", "env", cfg.AppEnv},
|
logCfg := cfg.Log
|
||||||
})
|
logCfg.StaticArgs = []any{"service", "%%APP%%", "env", cfg.AppEnv}
|
||||||
|
logger := logz.New(logCfg)
|
||||||
|
|
||||||
db := postgres.New(logger, cfg.PG)
|
db := postgres.New(logger, cfg.PG)
|
||||||
srv := server.New(logger, cfg.Server,
|
srv := server.New(logger, cfg.Server,
|
||||||
@@ -138,7 +139,7 @@ func Run() error {
|
|||||||
lc := launcher.New(logger)
|
lc := launcher.New(logger)
|
||||||
lc.Append(db, srv)
|
lc.Append(db, srv)
|
||||||
|
|
||||||
withHealth(lc, srv, logger, db)
|
withHealth(lc, srv)
|
||||||
// … one withFeature(lc, srv, …) call per feature in your domain.
|
// … one withFeature(lc, srv, …) call per feature in your domain.
|
||||||
|
|
||||||
return lc.Run()
|
return lc.Run()
|
||||||
@@ -151,14 +152,13 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
"code.nochebuena.dev/einherjar/core/launcher"
|
"code.nochebuena.dev/einherjar/core/launcher"
|
||||||
"code.nochebuena.dev/einherjar/core/logz"
|
|
||||||
"code.nochebuena.dev/einherjar/db-postgres"
|
|
||||||
"code.nochebuena.dev/einherjar/web/server"
|
"code.nochebuena.dev/einherjar/web/server"
|
||||||
)
|
)
|
||||||
|
|
||||||
// withHealth registers a liveness endpoint. Grow it into a readiness check
|
// withHealth registers a liveness endpoint — the simplest with<Feature> hook.
|
||||||
// (ping db and other dependencies) as the service gains infrastructure.
|
// A real feature hook takes its deps (logging.Logger, postgres.Provider, …) and
|
||||||
func withHealth(lc launcher.Launcher, srv server.Server, logger logz.Logger, db postgres.Component) {
|
// wires repo→service→handler inside the closure; see get_example("wire").
|
||||||
|
func withHealth(lc launcher.Launcher, srv server.Server) {
|
||||||
lc.BeforeStart(func() error {
|
lc.BeforeStart(func() error {
|
||||||
srv.Get("/health", func(w http.ResponseWriter, r *http.Request) {
|
srv.Get("/health", func(w http.ResponseWriter, r *http.Request) {
|
||||||
w.Header().Set("Content-Type", "application/json")
|
w.Header().Set("Content-Type", "application/json")
|
||||||
@@ -181,19 +181,21 @@ package config
|
|||||||
import (
|
import (
|
||||||
"github.com/caarlos0/env/v11"
|
"github.com/caarlos0/env/v11"
|
||||||
|
|
||||||
|
"code.nochebuena.dev/einherjar/core/logz"
|
||||||
"code.nochebuena.dev/einherjar/db-postgres"
|
"code.nochebuena.dev/einherjar/db-postgres"
|
||||||
"code.nochebuena.dev/einherjar/web/server"
|
"code.nochebuena.dev/einherjar/web/server"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Config is the fully-resolved startup configuration. caarlos0/env recurses into
|
// Config is the fully-resolved startup configuration. caarlos0/env recurses into
|
||||||
// the nested framework configs, populating their EINHERJAR_SERVER_* / EINHERJAR_PG_*
|
// the nested framework configs, populating their EINHERJAR_LOG_* / EINHERJAR_SERVER_* /
|
||||||
// tags from the environment next to the app-owned APP_* fields.
|
// EINHERJAR_PG_* tags from the environment next to the app-owned APP_* fields.
|
||||||
type Config struct {
|
type Config struct {
|
||||||
AppEnv string ` + "`" + `env:"APP_ENV" envDefault:"local"` + "`" + `
|
AppEnv string ` + "`" + `env:"APP_ENV" envDefault:"local"` + "`" + `
|
||||||
CORSOrigins []string ` + "`" + `env:"APP_CORS_ORIGINS" envSeparator:","` + "`" + `
|
CORSOrigins []string ` + "`" + `env:"APP_CORS_ORIGINS" envSeparator:","` + "`" + `
|
||||||
|
|
||||||
// Framework component configs — composed verbatim; their EINHERJAR_* tags
|
// Framework component configs — composed verbatim; their EINHERJAR_* tags
|
||||||
// load through this same env.Parse call.
|
// load through this same env.Parse call.
|
||||||
|
Log logz.Config // EINHERJAR_LOG_*
|
||||||
Server server.Config // EINHERJAR_SERVER_*
|
Server server.Config // EINHERJAR_SERVER_*
|
||||||
PG postgres.Config // EINHERJAR_PG_*
|
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).
|
// renderEnvExample builds .env.example from the framework's real component-config
|
||||||
# Every variable the config package reads lives here, documented. Keep in sync.
|
// 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_*) ────────────────────────────────────────────────────────────
|
b.WriteString("# ── App (APP_*) ────────────────────────────────────────────────────────────\n")
|
||||||
APP_ENV=local
|
b.WriteString("APP_ENV=local\n")
|
||||||
APP_CORS_ORIGINS=*
|
b.WriteString("APP_CORS_ORIGINS=*\n\n")
|
||||||
|
|
||||||
# ── Einherjar: HTTP server (EINHERJAR_SERVER_*) ───────────────────────────
|
writeEnvSection(&b, "Einherjar: logging", envspec.FindStruct(idx, "core", "logz", "Config"), app)
|
||||||
EINHERJAR_SERVER_ADDR=:8080
|
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_*) ────────────────────────────────
|
// envDevDefaults gives required vars (which carry no framework default) a value
|
||||||
EINHERJAR_PG_HOST=localhost
|
// that runs against the local devtools stack out of the box.
|
||||||
EINHERJAR_PG_PORT=5432
|
var envDevDefaults = map[string]string{
|
||||||
EINHERJAR_PG_USER=postgres
|
"EINHERJAR_PG_HOST": "localhost",
|
||||||
EINHERJAR_PG_PASSWORD=postgres
|
"EINHERJAR_PG_USER": "postgres",
|
||||||
EINHERJAR_PG_DATABASE=%%APP%%
|
"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)
|
registerGetADR(s, idx)
|
||||||
registerGetExample(s, idx)
|
registerGetExample(s, idx)
|
||||||
registerGetScaffold(s, idx)
|
registerGetScaffold(s, idx)
|
||||||
|
registerGetConfigEnv(s, idx)
|
||||||
|
registerCheckEnv(s, idx)
|
||||||
registerValidateSnippet(s, idx)
|
registerValidateSnippet(s, idx)
|
||||||
registerGetCompliance(s, idx)
|
registerGetCompliance(s, idx)
|
||||||
registerGetChangelog(s, idx)
|
registerGetChangelog(s, idx)
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package tools
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
|
||||||
|
"code.nochebuena.dev/einherjar/mcp/internal/envspec"
|
||||||
"code.nochebuena.dev/einherjar/mcp/internal/index"
|
"code.nochebuena.dev/einherjar/mcp/internal/index"
|
||||||
"code.nochebuena.dev/einherjar/mcp/internal/rules"
|
"code.nochebuena.dev/einherjar/mcp/internal/rules"
|
||||||
"github.com/modelcontextprotocol/go-sdk/mcp"
|
"github.com/modelcontextprotocol/go-sdk/mcp"
|
||||||
@@ -17,7 +18,10 @@ type validateSnippetOutput struct {
|
|||||||
Summary string `json:"summary"`
|
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{
|
mcp.AddTool(s, &mcp.Tool{
|
||||||
Name: "validate_snippet",
|
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.",
|
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