diff --git a/compass/config.go b/compass/config.go index 31c5cf8..ed180dc 100644 --- a/compass/config.go +++ b/compass/config.go @@ -2,62 +2,348 @@ package compass import ( "fmt" + "io/fs" "os" "path/filepath" + "sort" "strings" + "sync" "github.com/knadh/koanf/parsers/yaml" + "github.com/knadh/koanf/providers/confmap" + "github.com/knadh/koanf/providers/file" "github.com/knadh/koanf/v2" ) -// Config is a YAML configuration tree with dot-path access. -type Config struct { - k *koanf.Koanf +// Options controls how configuration is loaded. +type Options struct { + // Dir is the app config directory, typically "config". + Dir string + // Env is the environment name. Empty means detect from SUMMER_ENV, + // defaulting to "production". An explicit value wins over SUMMER_ENV. + Env string + // Environ is the KEY=VALUE list used for SUMMER_ variables and + // SUMMER_ENV detection. Nil means os.Environ(). A .env file fills + // only keys absent from this list and never mutates the process + // environment. + Environ []string } -// Load reads config/
.yaml files from dir. Each filename stem becomes -// the top-level section, so config/app.yaml key name is app.name. +type pluginFS struct { + id string + fsys fs.FS +} + +// Config is a YAML configuration tree with layered overrides and +// dot-path access. +type Config struct { + mu sync.RWMutex + opts Options + dir string + env string + environ []string + plugins []pluginFS + k *koanf.Koanf + runtime *koanf.Koanf +} + +// Load reads config from dir using SUMMER_ENV (default production). func Load(dir string) (*Config, error) { + return Open(Options{Dir: dir}) +} + +// Open loads configuration using opts. +func Open(opts Options) (*Config, error) { + if opts.Dir == "" { + return nil, fmt.Errorf("compass: config directory is empty") + } + c := &Config{ + opts: opts, + dir: opts.Dir, + runtime: koanf.New("."), + } + if err := c.refreshSources(); err != nil { + return nil, err + } + if err := c.rebuild(); err != nil { + return nil, err + } + return c, nil +} + +// Environment returns the active environment name. +func (c *Config) Environment() string { + if c == nil { + return "" + } + c.mu.RLock() + defer c.mu.RUnlock() + return c.env +} + +// MergePlugin merges an embedded plugin config filesystem at the bare +// plugin ID path. config/config.yaml keys become .. +func (c *Config) MergePlugin(id string, fsys fs.FS) error { + if c == nil { + return fmt.Errorf("compass: config is nil") + } + if id == "" { + return fmt.Errorf("compass: plugin id is empty") + } + c.mu.Lock() + defer c.mu.Unlock() + replaced := false + for i, p := range c.plugins { + if p.id == id { + c.plugins[i] = pluginFS{id: id, fsys: fsys} + replaced = true + break + } + } + if !replaced { + c.plugins = append(c.plugins, pluginFS{id: id, fsys: fsys}) + } + if c.runtime == nil { + c.runtime = koanf.New(".") + } + return c.rebuild() +} + +// Lookup returns the value at path and whether it exists. +func (c *Config) Lookup(path string) (any, bool) { + v := c.view() + if v == nil || !v.Exists(path) { + return nil, false + } + return v.Get(path), true +} + +// String returns the string value at path, or "" if unset. +func (c *Config) String(path string) string { + v := c.view() + if v == nil { + return "" + } + return v.String(path) +} + +// Int returns the int value at path, or 0 if unset. +func (c *Config) Int(path string) int { + v := c.view() + if v == nil { + return 0 + } + return v.Int(path) +} + +// Bool returns the bool value at path, or false if unset. +func (c *Config) Bool(path string) bool { + v := c.view() + if v == nil { + return false + } + return v.Bool(path) +} + +// Has reports whether path exists in the merged tree. +func (c *Config) Has(path string) bool { + v := c.view() + if v == nil { + return false + } + return v.Exists(path) +} + +// LoadSection unmarshals the map at path into out using `koanf` tags. +func (c *Config) LoadSection(path string, out any) error { + v := c.view() + if v == nil { + return fmt.Errorf("compass: config is empty") + } + return v.UnmarshalWithConf(path, out, koanf.UnmarshalConf{Tag: "koanf"}) +} + +func (c *Config) view() *koanf.Koanf { + if c == nil { + return nil + } + c.mu.RLock() + defer c.mu.RUnlock() + if c.k == nil { + return nil + } + out := c.k.Copy() + if c.runtime != nil { + _ = out.Merge(c.runtime) + } + return out +} + +func (c *Config) refreshSources() error { + base := c.opts.Environ + if c.opts.Environ == nil { + base = os.Environ() + } + dotenv, err := readDotEnv(filepath.Join(filepath.Dir(c.dir), ".env")) + if err != nil { + return err + } + c.environ = mergeEnviron(base, dotenv) + + envName := c.opts.Env + if envName == "" { + envName = lookupEnv(c.environ, "SUMMER_ENV") + } + if envName == "" { + envName = "production" + } + envName, err = sanitizeEnv(envName) + if err != nil { + return err + } + c.env = envName + return nil +} + +func (c *Config) rebuild() error { k := koanf.New(".") + for _, p := range c.plugins { + if err := mergePluginFS(k, p.id, p.fsys); err != nil { + return err + } + } + if c.dir != "" { + if err := loadSortedSections(k, c.dir, false); err != nil { + return err + } + envDir := filepath.Join(c.dir, "env", c.env) + if err := loadSortedSections(k, envDir, true); err != nil { + return err + } + } + if err := loadEnvVars(k, c.environ); err != nil { + return err + } + if c.dir != "" && c.env != "" { + overrides := filepath.Join(c.dir, "env", c.env, "overrides.yaml") + if err := loadOverrides(k, overrides); err != nil { + return err + } + } + c.k = k + return nil +} + +func loadSortedSections(k *koanf.Koanf, dir string, skipOverrides bool) error { entries, err := os.ReadDir(dir) if err != nil { - return nil, fmt.Errorf("compass: read config dir %s: %w", dir, err) + if os.IsNotExist(err) { + return nil + } + return fmt.Errorf("compass: read config dir: %w", err) } - parser := yaml.Parser() + names := make([]string, 0, len(entries)) for _, entry := range entries { if entry.IsDir() { continue } name := entry.Name() - section, ok := sectionName(name) - if !ok { + if skipOverrides && isOverridesFile(name) { continue } + if _, ok := sectionName(name); !ok { + continue + } + names = append(names, name) + } + sort.Strings(names) + parser := yaml.Parser() + for _, name := range names { + section, _ := sectionName(name) path := filepath.Join(dir, name) - raw, err := os.ReadFile(path) + tmp := koanf.New(".") + if err := tmp.Load(file.Provider(path), parser); err != nil { + return fmt.Errorf("compass: parse %s: %w", filepath.Base(path), err) + } + if err := k.MergeAt(tmp, section); err != nil { + return fmt.Errorf("compass: merge %s: %w", filepath.Base(path), err) + } + } + return nil +} + +func loadOverrides(k *koanf.Koanf, path string) error { + if _, err := os.Stat(path); err != nil { + if os.IsNotExist(err) { + return nil + } + return fmt.Errorf("compass: stat overrides: %w", err) + } + if err := k.Load(file.Provider(path), yaml.Parser()); err != nil { + return fmt.Errorf("compass: parse overrides: %w", err) + } + return nil +} + +func mergePluginFS(k *koanf.Koanf, id string, fsys fs.FS) error { + if fsys == nil { + return nil + } + var files []string + err := fs.WalkDir(fsys, ".", func(path string, d fs.DirEntry, err error) error { if err != nil { - return nil, fmt.Errorf("compass: read %s: %w", path, err) + return err + } + if d.IsDir() { + return nil + } + if _, ok := sectionName(filepath.Base(path)); !ok { + return nil + } + files = append(files, path) + return nil + }) + if err != nil { + return fmt.Errorf("compass: read plugin config %s: %w", id, err) + } + sort.Strings(files) + parser := yaml.Parser() + for _, path := range files { + raw, err := fs.ReadFile(fsys, path) + if err != nil { + return fmt.Errorf("compass: read plugin config %s: %w", id, err) } parsed, err := parser.Unmarshal(raw) if err != nil { - return nil, fmt.Errorf("compass: parse %s: %w", path, err) + return fmt.Errorf("compass: parse plugin config %s: %w", id, err) } if parsed == nil { parsed = map[string]any{} } - if err := k.Set(section, parsed); err != nil { - return nil, fmt.Errorf("compass: set section %s: %w", section, err) + tmp := koanf.New(".") + if err := tmp.Load(confmap.Provider(parsed, ""), nil); err != nil { + return fmt.Errorf("compass: load plugin config %s: %w", id, err) + } + at := pluginMergePath(id, path) + if err := k.MergeAt(tmp, at); err != nil { + return fmt.Errorf("compass: merge plugin config %s: %w", id, err) } } - return &Config{k: k}, nil + return nil } -// String returns the string value at path, or "" if unset. -func (c *Config) String(path string) string { - if c == nil || c.k == nil { - return "" +func pluginMergePath(id, path string) string { + rel := filepath.ToSlash(path) + base := filepath.Base(rel) + section, _ := sectionName(base) + if section == "config" { + return id } - return c.k.String(path) + return id + "." + section +} + +func isOverridesFile(name string) bool { + return name == "overrides.yaml" || name == "overrides.yml" } func sectionName(filename string) (string, bool) { diff --git a/compass/env.go b/compass/env.go new file mode 100644 index 0000000..68afeda --- /dev/null +++ b/compass/env.go @@ -0,0 +1,124 @@ +package compass + +import ( + "fmt" + "os" + "strings" + "unicode" + + "github.com/knadh/koanf/providers/env/v2" + "github.com/knadh/koanf/v2" +) + +const envPrefix = "SUMMER_" + +func loadEnvVars(k *koanf.Koanf, environ []string) error { + if k == nil { + return nil + } + return k.Load(env.Provider(".", env.Opt{ + Prefix: envPrefix, + EnvironFunc: func() []string { + if environ == nil { + return []string{} + } + return environ + }, + TransformFunc: transformSummerKey, + }), nil) +} + +func transformSummerKey(key, value string) (string, any) { + if key == "SUMMER_ENV" { + return "", nil + } + rest, ok := strings.CutPrefix(key, envPrefix) + if !ok || rest == "" { + return "", nil + } + parts := strings.Split(rest, "__") + for i, part := range parts { + parts[i] = strings.ToLower(part) + } + return strings.Join(parts, "."), value +} + +func readDotEnv(path string) ([]string, error) { + raw, err := os.ReadFile(path) + if err != nil { + if os.IsNotExist(err) { + return nil, nil + } + return nil, fmt.Errorf("compass: read .env: %w", err) + } + var out []string + for _, line := range strings.Split(string(raw), "\n") { + line = strings.TrimSpace(line) + if line == "" || strings.HasPrefix(line, "#") { + continue + } + if after, ok := strings.CutPrefix(line, "export "); ok { + line = strings.TrimSpace(after) + } + key, val, ok := strings.Cut(line, "=") + if !ok { + continue + } + key = strings.TrimSpace(key) + if key == "" { + continue + } + val = strings.TrimSpace(val) + if len(val) >= 2 { + if (val[0] == '"' && val[len(val)-1] == '"') || (val[0] == '\'' && val[len(val)-1] == '\'') { + val = val[1 : len(val)-1] + } + } + out = append(out, key+"="+val) + } + return out, nil +} + +func mergeEnviron(real, dotenv []string) []string { + present := make(map[string]struct{}, len(real)) + out := make([]string, 0, len(real)+len(dotenv)) + for _, kv := range real { + key, _, _ := strings.Cut(kv, "=") + present[key] = struct{}{} + out = append(out, kv) + } + for _, kv := range dotenv { + key, _, _ := strings.Cut(kv, "=") + if _, ok := present[key]; ok { + continue + } + out = append(out, kv) + } + return out +} + +func lookupEnv(environ []string, key string) string { + for i := len(environ) - 1; i >= 0; i-- { + k, v, ok := strings.Cut(environ[i], "=") + if ok && k == key { + return v + } + } + return "" +} + +func sanitizeEnv(env string) (string, error) { + if env == "" { + return "", fmt.Errorf("compass: environment name is empty") + } + if env == "." || env == ".." || strings.Contains(env, "..") || strings.ContainsAny(env, `/\`) { + return "", fmt.Errorf("compass: invalid environment name") + } + for _, r := range env { + if unicode.IsLetter(r) || unicode.IsDigit(r) || r == '-' || r == '_' { + continue + } + return "", fmt.Errorf("compass: invalid environment name") + } + return env, nil +} diff --git a/compass/persist.go b/compass/persist.go new file mode 100644 index 0000000..a942976 --- /dev/null +++ b/compass/persist.go @@ -0,0 +1,107 @@ +package compass + +import ( + "fmt" + "os" + "path/filepath" + "strings" + + "github.com/knadh/koanf/parsers/yaml" + "github.com/knadh/koanf/v2" +) + +// Set stores an in-memory override that wins over every file and env layer. +func (c *Config) Set(path string, value any) error { + if c == nil { + return fmt.Errorf("compass: config is nil") + } + if path == "" { + return fmt.Errorf("compass: empty config path") + } + c.mu.Lock() + defer c.mu.Unlock() + if c.runtime == nil { + c.runtime = koanf.New(".") + } + return c.runtime.Set(path, value) +} + +// Persist writes runtime overrides to config/env//overrides.yaml +// using an atomic replace and restrictive file permissions. +func (c *Config) Persist() error { + if c == nil { + return fmt.Errorf("compass: config is nil") + } + c.mu.Lock() + defer c.mu.Unlock() + if c.dir == "" { + return fmt.Errorf("compass: config directory is empty") + } + env, err := sanitizeEnv(c.env) + if err != nil { + return err + } + destDir, dest, err := overridesPath(c.dir, env) + if err != nil { + return err + } + if err := os.MkdirAll(destDir, 0o700); err != nil { + return fmt.Errorf("compass: create overrides dir: %w", err) + } + raw := []byte("{}\n") + if c.runtime != nil { + body, err := c.runtime.Marshal(yaml.Parser()) + if err != nil { + return fmt.Errorf("compass: marshal overrides: %w", err) + } + raw = body + } + tmp := dest + ".tmp" + if err := os.WriteFile(tmp, raw, 0o600); err != nil { + return fmt.Errorf("compass: write overrides: %w", err) + } + if err := os.Rename(tmp, dest); err != nil { + _ = os.Remove(tmp) + return fmt.Errorf("compass: persist overrides: %w", err) + } + return nil +} + +// Reload rebuilds the tree from disk and plugin sources and clears runtime Set. +func (c *Config) Reload() error { + if c == nil { + return fmt.Errorf("compass: config is nil") + } + c.mu.Lock() + defer c.mu.Unlock() + c.runtime = koanf.New(".") + if err := c.refreshSources(); err != nil { + return err + } + return c.rebuild() +} + +func overridesPath(dir, env string) (destDir, dest string, err error) { + root, err := filepath.Abs(dir) + if err != nil { + return "", "", fmt.Errorf("compass: resolve config dir: %w", err) + } + root = filepath.Clean(root) + destDir = filepath.Clean(filepath.Join(root, "env", env)) + if !within(root, destDir) { + return "", "", fmt.Errorf("compass: persist path escapes config directory") + } + dest = filepath.Clean(filepath.Join(destDir, "overrides.yaml")) + if !within(destDir, dest) { + return "", "", fmt.Errorf("compass: persist path escapes config directory") + } + return destDir, dest, nil +} + +func within(root, path string) bool { + rel, err := filepath.Rel(root, path) + if err != nil { + return false + } + return rel == "." || (rel != ".." && !strings.HasPrefix(rel, ".."+string(os.PathSeparator))) +} diff --git a/examples/hello/config/app.yaml b/examples/hello/config/app.yaml index a0784e0..f8a6b87 100644 --- a/examples/hello/config/app.yaml +++ b/examples/hello/config/app.yaml @@ -1 +1,2 @@ name: hello-app +debug: false diff --git a/examples/hello/config/env/development/app.yaml b/examples/hello/config/env/development/app.yaml new file mode 100644 index 0000000..8c851ef --- /dev/null +++ b/examples/hello/config/env/development/app.yaml @@ -0,0 +1,2 @@ +name: hello-dev +debug: true diff --git a/examples/hello/go.mod b/examples/hello/go.mod index 738782a..460742b 100644 --- a/examples/hello/go.mod +++ b/examples/hello/go.mod @@ -11,16 +11,21 @@ require ( ) require ( + github.com/fsnotify/fsnotify v1.9.0 // indirect github.com/go-viper/mapstructure/v2 v2.4.0 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/knadh/koanf/maps v0.1.2 // indirect github.com/knadh/koanf/parsers/yaml v1.1.1 // indirect + github.com/knadh/koanf/providers/confmap v1.0.1 // indirect + github.com/knadh/koanf/providers/env/v2 v2.0.1 // indirect + github.com/knadh/koanf/providers/file v1.2.1 // indirect github.com/knadh/koanf/v2 v2.3.6 // indirect github.com/mitchellh/copystructure v1.2.0 // indirect github.com/mitchellh/reflectwalk v1.0.2 // indirect github.com/spf13/cobra v1.10.2 // indirect github.com/spf13/pflag v1.0.9 // indirect go.yaml.in/yaml/v3 v3.0.4 // indirect + golang.org/x/sys v0.32.0 // indirect ) replace ( diff --git a/examples/hello/go.sum b/examples/hello/go.sum index af22983..ffb529b 100644 --- a/examples/hello/go.sum +++ b/examples/hello/go.sum @@ -1,6 +1,8 @@ github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k= +github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs= github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= @@ -9,6 +11,12 @@ github.com/knadh/koanf/maps v0.1.2 h1:RBfmAW5CnZT+PJ1CVc1QSJKf4Xu9kxfQgYVQSu8hpb github.com/knadh/koanf/maps v0.1.2/go.mod h1:npD/QZY3V6ghQDdcQzl1W4ICNVTkohC8E73eI2xW4yI= github.com/knadh/koanf/parsers/yaml v1.1.1 h1:u70vV5IyaM0HvONh8HoqBC97oTgO33KcpZbTLiKVinU= github.com/knadh/koanf/parsers/yaml v1.1.1/go.mod h1:HHmcHXUrp9cOPcuC+2wrr44GTUB0EC+PyfN3HZD9tFg= +github.com/knadh/koanf/providers/confmap v1.0.1 h1:L15hbvMqlvhwUuCtL9BkL+rqiMAjk6cZc8O9XoDtE3A= +github.com/knadh/koanf/providers/confmap v1.0.1/go.mod h1:txHYHiI2hAtF0/0sCmcuol4IDcuQbKTybiB1nOcUo1A= +github.com/knadh/koanf/providers/env/v2 v2.0.1 h1:a3KagndPqhcWHQv6Pz4OZmwkI/yMeTjkiZye6ZCkyW0= +github.com/knadh/koanf/providers/env/v2 v2.0.1/go.mod h1:1g01PE+Ve1gBfWNNw2wmULRP0tc8RJrjn5p2N/jNCIc= +github.com/knadh/koanf/providers/file v1.2.1 h1:bEWbtQwYrA+W2DtdBrQWyXqJaJSG3KrP3AESOJYp9wM= +github.com/knadh/koanf/providers/file v1.2.1/go.mod h1:bp1PM5f83Q+TOUu10J/0ApLBd9uIzg+n9UgthfY+nRA= github.com/knadh/koanf/v2 v2.3.6 h1:JoQPSJmvS4aP0xNc8xMDr5tcrkSEInL23/Il7pITAKo= github.com/knadh/koanf/v2 v2.3.6/go.mod h1:gRb40VRAbd4iJMYYD5IxZ6hfuopFcXBpc9bbQpZwo28= github.com/kr/pretty v0.2.1 h1:Fmg33tUaq4/8ym9TJN1x7sLJnHVwhP33CNkpYV/7rwI= @@ -30,6 +38,8 @@ github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcU github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= +golang.org/x/sys v0.32.0 h1:s77OFDvIQeibCmezSnk/q6iAfkdiQaJi4VzroCFrN20= +golang.org/x/sys v0.32.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo= gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= diff --git a/examples/hello/plugins/base/config/config.yaml b/examples/hello/plugins/base/config/config.yaml new file mode 100644 index 0000000..14bdebf --- /dev/null +++ b/examples/hello/plugins/base/config/config.yaml @@ -0,0 +1 @@ +posts_per_page: 10 diff --git a/examples/hello/plugins/base/go.mod b/examples/hello/plugins/base/go.mod index bfca136..1089b90 100644 --- a/examples/hello/plugins/base/go.mod +++ b/examples/hello/plugins/base/go.mod @@ -7,13 +7,21 @@ toolchain go1.27.0 require git.golem15.com/golem15/summercms v0.0.0 require ( + github.com/fsnotify/fsnotify v1.9.0 // indirect github.com/go-viper/mapstructure/v2 v2.4.0 // indirect + github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/knadh/koanf/maps v0.1.2 // indirect github.com/knadh/koanf/parsers/yaml v1.1.1 // indirect + github.com/knadh/koanf/providers/confmap v1.0.1 // indirect + github.com/knadh/koanf/providers/env/v2 v2.0.1 // indirect + github.com/knadh/koanf/providers/file v1.2.1 // indirect github.com/knadh/koanf/v2 v2.3.6 // indirect github.com/mitchellh/copystructure v1.2.0 // indirect github.com/mitchellh/reflectwalk v1.0.2 // indirect + github.com/spf13/cobra v1.10.2 // indirect + github.com/spf13/pflag v1.0.9 // indirect go.yaml.in/yaml/v3 v3.0.4 // indirect + golang.org/x/sys v0.32.0 // indirect ) replace git.golem15.com/golem15/summercms => ../../../.. diff --git a/examples/hello/plugins/base/go.sum b/examples/hello/plugins/base/go.sum index 06a36ce..ffb529b 100644 --- a/examples/hello/plugins/base/go.sum +++ b/examples/hello/plugins/base/go.sum @@ -1,11 +1,22 @@ +github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k= +github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs= github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= +github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= +github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= github.com/knadh/koanf/maps v0.1.2 h1:RBfmAW5CnZT+PJ1CVc1QSJKf4Xu9kxfQgYVQSu8hpbo= github.com/knadh/koanf/maps v0.1.2/go.mod h1:npD/QZY3V6ghQDdcQzl1W4ICNVTkohC8E73eI2xW4yI= github.com/knadh/koanf/parsers/yaml v1.1.1 h1:u70vV5IyaM0HvONh8HoqBC97oTgO33KcpZbTLiKVinU= github.com/knadh/koanf/parsers/yaml v1.1.1/go.mod h1:HHmcHXUrp9cOPcuC+2wrr44GTUB0EC+PyfN3HZD9tFg= +github.com/knadh/koanf/providers/confmap v1.0.1 h1:L15hbvMqlvhwUuCtL9BkL+rqiMAjk6cZc8O9XoDtE3A= +github.com/knadh/koanf/providers/confmap v1.0.1/go.mod h1:txHYHiI2hAtF0/0sCmcuol4IDcuQbKTybiB1nOcUo1A= +github.com/knadh/koanf/providers/env/v2 v2.0.1 h1:a3KagndPqhcWHQv6Pz4OZmwkI/yMeTjkiZye6ZCkyW0= +github.com/knadh/koanf/providers/env/v2 v2.0.1/go.mod h1:1g01PE+Ve1gBfWNNw2wmULRP0tc8RJrjn5p2N/jNCIc= +github.com/knadh/koanf/providers/file v1.2.1 h1:bEWbtQwYrA+W2DtdBrQWyXqJaJSG3KrP3AESOJYp9wM= +github.com/knadh/koanf/providers/file v1.2.1/go.mod h1:bp1PM5f83Q+TOUu10J/0ApLBd9uIzg+n9UgthfY+nRA= github.com/knadh/koanf/v2 v2.3.6 h1:JoQPSJmvS4aP0xNc8xMDr5tcrkSEInL23/Il7pITAKo= github.com/knadh/koanf/v2 v2.3.6/go.mod h1:gRb40VRAbd4iJMYYD5IxZ6hfuopFcXBpc9bbQpZwo28= github.com/kr/pretty v0.2.1 h1:Fmg33tUaq4/8ym9TJN1x7sLJnHVwhP33CNkpYV/7rwI= @@ -18,10 +29,17 @@ github.com/mitchellh/reflectwalk v1.0.2 h1:G2LzWKi524PWgd3mLHV8Y5k7s6XUvT0Gef6zx github.com/mitchellh/reflectwalk v1.0.2/go.mod h1:mSTlrgnPZtwu0c4WaC2kGObEpuNDbx0jmZXqmk4esnw= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU= +github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4= +github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY= +github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk= github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= +golang.org/x/sys v0.32.0 h1:s77OFDvIQeibCmezSnk/q6iAfkdiQaJi4VzroCFrN20= +golang.org/x/sys v0.32.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo= gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= diff --git a/examples/hello/plugins/base/plugin.go b/examples/hello/plugins/base/plugin.go index b0a84e7..c39e819 100644 --- a/examples/hello/plugins/base/plugin.go +++ b/examples/hello/plugins/base/plugin.go @@ -1,10 +1,16 @@ package base import ( + "embed" + "io/fs" + "git.golem15.com/golem15/summercms/backpack" "git.golem15.com/golem15/summercms/party" ) +//go:embed config +var configFS embed.FS + // Plugin is the golem15.hello plugin. type Plugin struct{} @@ -14,6 +20,8 @@ func (p *Plugin) Requires() []string { return nil } func (p *Plugin) Register(*backpack.App) error { return nil } func (p *Plugin) Boot(*backpack.App) error { return nil } +func (p *Plugin) ConfigFS() fs.FS { return configFS } + func init() { party.Register(&Plugin{}) } diff --git a/examples/hello/plugins/greeter/go.mod b/examples/hello/plugins/greeter/go.mod index dc86573..d009409 100644 --- a/examples/hello/plugins/greeter/go.mod +++ b/examples/hello/plugins/greeter/go.mod @@ -7,16 +7,21 @@ toolchain go1.27.0 require git.golem15.com/golem15/summercms v0.0.0 require ( + github.com/fsnotify/fsnotify v1.9.0 // indirect github.com/go-viper/mapstructure/v2 v2.4.0 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/knadh/koanf/maps v0.1.2 // indirect github.com/knadh/koanf/parsers/yaml v1.1.1 // indirect + github.com/knadh/koanf/providers/confmap v1.0.1 // indirect + github.com/knadh/koanf/providers/env/v2 v2.0.1 // indirect + github.com/knadh/koanf/providers/file v1.2.1 // indirect github.com/knadh/koanf/v2 v2.3.6 // indirect github.com/mitchellh/copystructure v1.2.0 // indirect github.com/mitchellh/reflectwalk v1.0.2 // indirect github.com/spf13/cobra v1.10.2 // indirect github.com/spf13/pflag v1.0.9 // indirect go.yaml.in/yaml/v3 v3.0.4 // indirect + golang.org/x/sys v0.32.0 // indirect ) replace git.golem15.com/golem15/summercms => ../../../.. diff --git a/examples/hello/plugins/greeter/go.sum b/examples/hello/plugins/greeter/go.sum index af22983..ffb529b 100644 --- a/examples/hello/plugins/greeter/go.sum +++ b/examples/hello/plugins/greeter/go.sum @@ -1,6 +1,8 @@ github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k= +github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs= github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= @@ -9,6 +11,12 @@ github.com/knadh/koanf/maps v0.1.2 h1:RBfmAW5CnZT+PJ1CVc1QSJKf4Xu9kxfQgYVQSu8hpb github.com/knadh/koanf/maps v0.1.2/go.mod h1:npD/QZY3V6ghQDdcQzl1W4ICNVTkohC8E73eI2xW4yI= github.com/knadh/koanf/parsers/yaml v1.1.1 h1:u70vV5IyaM0HvONh8HoqBC97oTgO33KcpZbTLiKVinU= github.com/knadh/koanf/parsers/yaml v1.1.1/go.mod h1:HHmcHXUrp9cOPcuC+2wrr44GTUB0EC+PyfN3HZD9tFg= +github.com/knadh/koanf/providers/confmap v1.0.1 h1:L15hbvMqlvhwUuCtL9BkL+rqiMAjk6cZc8O9XoDtE3A= +github.com/knadh/koanf/providers/confmap v1.0.1/go.mod h1:txHYHiI2hAtF0/0sCmcuol4IDcuQbKTybiB1nOcUo1A= +github.com/knadh/koanf/providers/env/v2 v2.0.1 h1:a3KagndPqhcWHQv6Pz4OZmwkI/yMeTjkiZye6ZCkyW0= +github.com/knadh/koanf/providers/env/v2 v2.0.1/go.mod h1:1g01PE+Ve1gBfWNNw2wmULRP0tc8RJrjn5p2N/jNCIc= +github.com/knadh/koanf/providers/file v1.2.1 h1:bEWbtQwYrA+W2DtdBrQWyXqJaJSG3KrP3AESOJYp9wM= +github.com/knadh/koanf/providers/file v1.2.1/go.mod h1:bp1PM5f83Q+TOUu10J/0ApLBd9uIzg+n9UgthfY+nRA= github.com/knadh/koanf/v2 v2.3.6 h1:JoQPSJmvS4aP0xNc8xMDr5tcrkSEInL23/Il7pITAKo= github.com/knadh/koanf/v2 v2.3.6/go.mod h1:gRb40VRAbd4iJMYYD5IxZ6hfuopFcXBpc9bbQpZwo28= github.com/kr/pretty v0.2.1 h1:Fmg33tUaq4/8ym9TJN1x7sLJnHVwhP33CNkpYV/7rwI= @@ -30,6 +38,8 @@ github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcU github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= +golang.org/x/sys v0.32.0 h1:s77OFDvIQeibCmezSnk/q6iAfkdiQaJi4VzroCFrN20= +golang.org/x/sys v0.32.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo= gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= diff --git a/examples/hello/plugins/greeter/plugin.go b/examples/hello/plugins/greeter/plugin.go index 683da00..ab28d3c 100644 --- a/examples/hello/plugins/greeter/plugin.go +++ b/examples/hello/plugins/greeter/plugin.go @@ -29,10 +29,14 @@ func (p *Plugin) Commands() []bonfire.Command { Description: "Print the configured application name", Run: func(ctx context.Context, in bonfire.Input, out bonfire.Output) error { name := "" + posts := 0 + debug := false if p.app != nil && p.app.Config != nil { name = p.app.Config.String("app.name") + posts = p.app.Config.Int("golem15.hello.posts_per_page") + debug = p.app.Config.Bool("app.debug") } - out.Println(name) + out.Printf("name=%s posts_per_page=%d debug=%t\n", name, posts, debug) return nil }, }} diff --git a/go.mod b/go.mod index cd470eb..8824788 100644 --- a/go.mod +++ b/go.mod @@ -6,11 +6,15 @@ toolchain go1.27.0 require ( github.com/knadh/koanf/parsers/yaml v1.1.1 + github.com/knadh/koanf/providers/confmap v1.0.1 + github.com/knadh/koanf/providers/env/v2 v2.0.1 + github.com/knadh/koanf/providers/file v1.2.1 github.com/knadh/koanf/v2 v2.3.6 github.com/spf13/cobra v1.10.2 ) require ( + github.com/fsnotify/fsnotify v1.9.0 // indirect github.com/go-viper/mapstructure/v2 v2.4.0 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/knadh/koanf/maps v0.1.2 // indirect @@ -18,4 +22,5 @@ require ( github.com/mitchellh/reflectwalk v1.0.2 // indirect github.com/spf13/pflag v1.0.9 // indirect go.yaml.in/yaml/v3 v3.0.4 // indirect + golang.org/x/sys v0.32.0 // indirect ) diff --git a/go.sum b/go.sum index af22983..ffb529b 100644 --- a/go.sum +++ b/go.sum @@ -1,6 +1,8 @@ github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k= +github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs= github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= @@ -9,6 +11,12 @@ github.com/knadh/koanf/maps v0.1.2 h1:RBfmAW5CnZT+PJ1CVc1QSJKf4Xu9kxfQgYVQSu8hpb github.com/knadh/koanf/maps v0.1.2/go.mod h1:npD/QZY3V6ghQDdcQzl1W4ICNVTkohC8E73eI2xW4yI= github.com/knadh/koanf/parsers/yaml v1.1.1 h1:u70vV5IyaM0HvONh8HoqBC97oTgO33KcpZbTLiKVinU= github.com/knadh/koanf/parsers/yaml v1.1.1/go.mod h1:HHmcHXUrp9cOPcuC+2wrr44GTUB0EC+PyfN3HZD9tFg= +github.com/knadh/koanf/providers/confmap v1.0.1 h1:L15hbvMqlvhwUuCtL9BkL+rqiMAjk6cZc8O9XoDtE3A= +github.com/knadh/koanf/providers/confmap v1.0.1/go.mod h1:txHYHiI2hAtF0/0sCmcuol4IDcuQbKTybiB1nOcUo1A= +github.com/knadh/koanf/providers/env/v2 v2.0.1 h1:a3KagndPqhcWHQv6Pz4OZmwkI/yMeTjkiZye6ZCkyW0= +github.com/knadh/koanf/providers/env/v2 v2.0.1/go.mod h1:1g01PE+Ve1gBfWNNw2wmULRP0tc8RJrjn5p2N/jNCIc= +github.com/knadh/koanf/providers/file v1.2.1 h1:bEWbtQwYrA+W2DtdBrQWyXqJaJSG3KrP3AESOJYp9wM= +github.com/knadh/koanf/providers/file v1.2.1/go.mod h1:bp1PM5f83Q+TOUu10J/0ApLBd9uIzg+n9UgthfY+nRA= github.com/knadh/koanf/v2 v2.3.6 h1:JoQPSJmvS4aP0xNc8xMDr5tcrkSEInL23/Il7pITAKo= github.com/knadh/koanf/v2 v2.3.6/go.mod h1:gRb40VRAbd4iJMYYD5IxZ6hfuopFcXBpc9bbQpZwo28= github.com/kr/pretty v0.2.1 h1:Fmg33tUaq4/8ym9TJN1x7sLJnHVwhP33CNkpYV/7rwI= @@ -30,6 +38,8 @@ github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcU github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= +golang.org/x/sys v0.32.0 h1:s77OFDvIQeibCmezSnk/q6iAfkdiQaJi4VzroCFrN20= +golang.org/x/sys v0.32.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo= gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= diff --git a/party/registry.go b/party/registry.go index e035989..8f5abe4 100644 --- a/party/registry.go +++ b/party/registry.go @@ -6,6 +6,7 @@ import ( "sync" "git.golem15.com/golem15/summercms/backpack" + "git.golem15.com/golem15/summercms/pact" ) // Plugin is the required descriptor every compiled plugin implements. @@ -97,6 +98,13 @@ func activate(registered []Plugin, app *backpack.App, ids []string) ([]Plugin, e return nil, err } + for _, p := range ordered { + if hc, ok := p.(pact.HasConfig); ok && app.Config != nil { + if err := app.Config.MergePlugin(p.ID(), hc.ConfigFS()); err != nil { + return nil, fmt.Errorf("party: config %s: %w", p.ID(), err) + } + } + } for _, p := range ordered { if err := p.Register(app); err != nil { return nil, fmt.Errorf("party: register %s: %w", p.ID(), err)