From 96a8d38be2fbd2463fffd122ae3b64b2135717b5 Mon Sep 17 00:00:00 2001 From: Jakub Zych Date: Wed, 16 Sep 2026 12:57:18 +0200 Subject: [PATCH 1/3] feat(01-01): boot hello app and dispatch greeter:hello - Add party plugin registry with Register-before-Boot activation - Add backpack, compass, bonfire, and pact capability interfaces - Add examples/hello with two compiled plugin modules --- .gitignore | 1 + backpack/app.go | 13 ++ backpack/app_test.go | 37 +++++ bonfire/command.go | 110 +++++++++++++ bonfire/root.go | 58 +++++++ compass/config.go | 72 +++++++++ compass/config_test.go | 23 +++ examples/hello/config/app.yaml | 1 + examples/hello/go.mod | 30 ++++ examples/hello/go.sum | 37 +++++ examples/hello/hello_test.go | 17 ++ examples/hello/main.go | 43 +++++ examples/hello/plugins.gen.go | 14 ++ examples/hello/plugins/base/go.mod | 19 +++ examples/hello/plugins/base/go.sum | 29 ++++ examples/hello/plugins/base/plugin.go | 19 +++ examples/hello/plugins/greeter/go.mod | 22 +++ examples/hello/plugins/greeter/go.sum | 37 +++++ examples/hello/plugins/greeter/plugin.go | 43 +++++ examples/hello/summer.yaml | 7 + go.mod | 18 +++ go.sum | 37 +++++ go.work | 10 ++ pact/capabilities.go | 34 ++++ party/registry.go | 195 +++++++++++++++++++++++ party/registry_test.go | 56 +++++++ 26 files changed, 982 insertions(+) create mode 100644 backpack/app.go create mode 100644 backpack/app_test.go create mode 100644 bonfire/command.go create mode 100644 bonfire/root.go create mode 100644 compass/config.go create mode 100644 compass/config_test.go create mode 100644 examples/hello/config/app.yaml create mode 100644 examples/hello/go.mod create mode 100644 examples/hello/go.sum create mode 100644 examples/hello/hello_test.go create mode 100644 examples/hello/main.go create mode 100644 examples/hello/plugins.gen.go create mode 100644 examples/hello/plugins/base/go.mod create mode 100644 examples/hello/plugins/base/go.sum create mode 100644 examples/hello/plugins/base/plugin.go create mode 100644 examples/hello/plugins/greeter/go.mod create mode 100644 examples/hello/plugins/greeter/go.sum create mode 100644 examples/hello/plugins/greeter/plugin.go create mode 100644 examples/hello/summer.yaml create mode 100644 go.sum create mode 100644 go.work create mode 100644 pact/capabilities.go create mode 100644 party/registry.go create mode 100644 party/registry_test.go diff --git a/.gitignore b/.gitignore index 3437085..f346266 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,6 @@ # Go /bin/ +/examples/hello/bin/ /dist/ *.test *.out diff --git a/backpack/app.go b/backpack/app.go new file mode 100644 index 0000000..dbf96aa --- /dev/null +++ b/backpack/app.go @@ -0,0 +1,13 @@ +package backpack + +import "git.golem15.com/golem15/summercms/compass" + +// App is the per-instance application container. It must not import party. +type App struct { + Config *compass.Config +} + +// New returns an App that owns cfg. +func New(cfg *compass.Config) *App { + return &App{Config: cfg} +} diff --git a/backpack/app_test.go b/backpack/app_test.go new file mode 100644 index 0000000..2f8097a --- /dev/null +++ b/backpack/app_test.go @@ -0,0 +1,37 @@ +package backpack + +import ( + "go/parser" + "go/token" + "os" + "strconv" + "strings" + "testing" +) + +func TestDoesNotImportParty(t *testing.T) { + entries, err := os.ReadDir(".") + if err != nil { + t.Fatal(err) + } + fset := token.NewFileSet() + for _, entry := range entries { + name := entry.Name() + if entry.IsDir() || !strings.HasSuffix(name, ".go") || strings.HasSuffix(name, "_test.go") { + continue + } + file, err := parser.ParseFile(fset, name, nil, parser.ImportsOnly) + if err != nil { + t.Fatal(err) + } + for _, spec := range file.Imports { + path, err := strconv.Unquote(spec.Path.Value) + if err != nil { + t.Fatal(err) + } + if path == "git.golem15.com/golem15/summercms/party" { + t.Fatalf("%s imports party", name) + } + } + } +} diff --git a/bonfire/command.go b/bonfire/command.go new file mode 100644 index 0000000..94c2df8 --- /dev/null +++ b/bonfire/command.go @@ -0,0 +1,110 @@ +package bonfire + +import ( + "context" + "fmt" + "io" + + "github.com/spf13/cobra" +) + +// Command is a console command value collected from plugins or the tool. +type Command struct { + Name string + Description string + Flags []Flag + Args []Arg + Run func(ctx context.Context, in Input, out Output) error +} + +// Flag describes a string flag passed to a command. +type Flag struct { + Name string + Shorthand string + Description string + Default string +} + +// Arg describes a positional argument. +type Arg struct { + Name string + Description string + Required bool +} + +// Input is the parsed argument and flag view handed to Command.Run. +type Input interface { + Args() []string + Argument(name string) (string, bool) + Flag(name string) (string, bool) +} + +// Output is an injected writer used by commands instead of package-level stdout. +type Output interface { + io.Writer + Println(a ...any) + Printf(format string, a ...any) +} + +type cobraInput struct { + cmd *cobra.Command + spec Command + args []string +} + +func (in cobraInput) Args() []string { return in.args } + +func (in cobraInput) Argument(name string) (string, bool) { + for i, arg := range in.spec.Args { + if arg.Name == name { + if i < len(in.args) { + return in.args[i], true + } + return "", false + } + } + return "", false +} + +func (in cobraInput) Flag(name string) (string, bool) { + if in.cmd == nil || in.cmd.Flags() == nil { + return "", false + } + if !in.cmd.Flags().Changed(name) { + val, err := in.cmd.Flags().GetString(name) + if err != nil || val == "" { + return "", false + } + return val, true + } + val, err := in.cmd.Flags().GetString(name) + if err != nil { + return "", false + } + return val, true +} + +type writerOutput struct { + w io.Writer +} + +func (o writerOutput) Write(p []byte) (int, error) { + if o.w == nil { + return 0, io.ErrClosedPipe + } + return o.w.Write(p) +} + +func (o writerOutput) Println(a ...any) { + if o.w == nil { + return + } + fmt.Fprintln(o.w, a...) +} + +func (o writerOutput) Printf(format string, a ...any) { + if o.w == nil { + return + } + fmt.Fprintf(o.w, format, a...) +} diff --git a/bonfire/root.go b/bonfire/root.go new file mode 100644 index 0000000..05c7326 --- /dev/null +++ b/bonfire/root.go @@ -0,0 +1,58 @@ +package bonfire + +import ( + "io" + "os" + + "github.com/spf13/cobra" +) + +// NewRoot builds the shared cobra root used by the summer tool and app binaries. +// It accepts command values rather than a party registry to avoid an import cycle. +func NewRoot(name string, commands []Command, out io.Writer) *cobra.Command { + if out == nil { + out = os.Stdout + } + root := &cobra.Command{ + Use: name, + SilenceUsage: true, + SilenceErrors: true, + CompletionOptions: cobra.CompletionOptions{ + DisableDefaultCmd: true, + }, + } + root.SetOut(out) + root.SetErr(out) + for _, command := range commands { + root.AddCommand(wrap(command, out)) + } + return root +} + +func wrap(c Command, out io.Writer) *cobra.Command { + cmd := &cobra.Command{ + Use: c.Name, + Short: c.Description, + Args: cobra.ArbitraryArgs, + RunE: func(cmd *cobra.Command, args []string) error { + if c.Run == nil { + return nil + } + writer := out + if cmd != nil { + if w := cmd.OutOrStdout(); w != nil { + writer = w + } + } + return c.Run(cmd.Context(), cobraInput{cmd: cmd, spec: c, args: args}, writerOutput{w: writer}) + }, + } + for _, flag := range c.Flags { + if flag.Shorthand != "" { + cmd.Flags().StringP(flag.Name, flag.Shorthand, flag.Default, flag.Description) + continue + } + cmd.Flags().String(flag.Name, flag.Default, flag.Description) + } + return cmd +} diff --git a/compass/config.go b/compass/config.go new file mode 100644 index 0000000..31c5cf8 --- /dev/null +++ b/compass/config.go @@ -0,0 +1,72 @@ +package compass + +import ( + "fmt" + "os" + "path/filepath" + "strings" + + "github.com/knadh/koanf/parsers/yaml" + "github.com/knadh/koanf/v2" +) + +// Config is a YAML configuration tree with dot-path access. +type Config struct { + k *koanf.Koanf +} + +// Load reads config/
.yaml files from dir. Each filename stem becomes +// the top-level section, so config/app.yaml key name is app.name. +func Load(dir string) (*Config, error) { + k := koanf.New(".") + entries, err := os.ReadDir(dir) + if err != nil { + return nil, fmt.Errorf("compass: read config dir %s: %w", dir, err) + } + parser := yaml.Parser() + for _, entry := range entries { + if entry.IsDir() { + continue + } + name := entry.Name() + section, ok := sectionName(name) + if !ok { + continue + } + path := filepath.Join(dir, name) + raw, err := os.ReadFile(path) + if err != nil { + return nil, fmt.Errorf("compass: read %s: %w", path, err) + } + parsed, err := parser.Unmarshal(raw) + if err != nil { + return nil, fmt.Errorf("compass: parse %s: %w", path, 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) + } + } + return &Config{k: k}, 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 "" + } + return c.k.String(path) +} + +func sectionName(filename string) (string, bool) { + switch { + case strings.HasSuffix(filename, ".yaml"): + return strings.TrimSuffix(filename, ".yaml"), true + case strings.HasSuffix(filename, ".yml"): + return strings.TrimSuffix(filename, ".yml"), true + default: + return "", false + } +} diff --git a/compass/config_test.go b/compass/config_test.go new file mode 100644 index 0000000..7255f9d --- /dev/null +++ b/compass/config_test.go @@ -0,0 +1,23 @@ +package compass + +import ( + "os" + "path/filepath" + "testing" +) + +func TestLoadStringFromAppSection(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "app.yaml") + if err := os.WriteFile(path, []byte("name: hello-app\n"), 0o644); err != nil { + t.Fatal(err) + } + + cfg, err := Load(dir) + if err != nil { + t.Fatalf("Load: %v", err) + } + if got := cfg.String("app.name"); got != "hello-app" { + t.Fatalf("String(app.name) = %q, want hello-app", got) + } +} diff --git a/examples/hello/config/app.yaml b/examples/hello/config/app.yaml new file mode 100644 index 0000000..a0784e0 --- /dev/null +++ b/examples/hello/config/app.yaml @@ -0,0 +1 @@ +name: hello-app diff --git a/examples/hello/go.mod b/examples/hello/go.mod new file mode 100644 index 0000000..738782a --- /dev/null +++ b/examples/hello/go.mod @@ -0,0 +1,30 @@ +module git.golem15.com/golem15/summercms/examples/hello + +go 1.27.0 + +toolchain go1.27.0 + +require ( + git.golem15.com/golem15/summercms v0.0.0 + git.golem15.com/golem15/summercms/examples/hello/plugins/base v0.0.0 + git.golem15.com/golem15/summercms/examples/hello/plugins/greeter v0.0.0 +) + +require ( + 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/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 +) + +replace ( + git.golem15.com/golem15/summercms => ../.. + git.golem15.com/golem15/summercms/examples/hello/plugins/base => ./plugins/base + git.golem15.com/golem15/summercms/examples/hello/plugins/greeter => ./plugins/greeter +) diff --git a/examples/hello/go.sum b/examples/hello/go.sum new file mode 100644 index 0000000..af22983 --- /dev/null +++ b/examples/hello/go.sum @@ -0,0 +1,37 @@ +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/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/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= +github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/mitchellh/copystructure v1.2.0 h1:vpKXTN4ewci03Vljg/q9QvCGUDttBOGBIa15WveJJGw= +github.com/mitchellh/copystructure v1.2.0/go.mod h1:qLl+cE2AmVv+CoeAwDPye/v+N2HKCj9FbZEVFJRxO9s= +github.com/mitchellh/reflectwalk v1.0.2 h1:G2LzWKi524PWgd3mLHV8Y5k7s6XUvT0Gef6zxSIeXaQ= +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= +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= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/examples/hello/hello_test.go b/examples/hello/hello_test.go new file mode 100644 index 0000000..c59d1d6 --- /dev/null +++ b/examples/hello/hello_test.go @@ -0,0 +1,17 @@ +package main + +import ( + "bytes" + "strings" + "testing" +) + +func TestGreeterHelloPrintsAppName(t *testing.T) { + var buf bytes.Buffer + if err := run([]string{"greeter:hello"}, &buf); err != nil { + t.Fatalf("greeter:hello: %v", err) + } + if got := strings.TrimSpace(buf.String()); got != "hello-app" { + t.Fatalf("output = %q, want %q", got, "hello-app") + } +} diff --git a/examples/hello/main.go b/examples/hello/main.go new file mode 100644 index 0000000..7bfb69b --- /dev/null +++ b/examples/hello/main.go @@ -0,0 +1,43 @@ +// Code generated by summer build. DO NOT EDIT. + +package main + +import ( + "fmt" + "io" + "os" + + "git.golem15.com/golem15/summercms/backpack" + "git.golem15.com/golem15/summercms/bonfire" + "git.golem15.com/golem15/summercms/compass" + "git.golem15.com/golem15/summercms/pact" + "git.golem15.com/golem15/summercms/party" +) + +func main() { + if err := run(os.Args[1:], os.Stdout); err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } +} + +func run(args []string, out io.Writer) error { + cfg, err := compass.Load("config") + if err != nil { + return err + } + app := backpack.New(cfg) + plugins, err := party.Activate(app, PluginIDs) + if err != nil { + return err + } + var commands []bonfire.Command + for _, plugin := range plugins { + if hasCommands, ok := plugin.(pact.HasCommands); ok { + commands = append(commands, hasCommands.Commands()...) + } + } + root := bonfire.NewRoot("hello", commands, out) + root.SetArgs(args) + return root.Execute() +} diff --git a/examples/hello/plugins.gen.go b/examples/hello/plugins.gen.go new file mode 100644 index 0000000..f2a78bc --- /dev/null +++ b/examples/hello/plugins.gen.go @@ -0,0 +1,14 @@ +// Code generated by summer build. DO NOT EDIT. + +package main + +import ( + _ "git.golem15.com/golem15/summercms/examples/hello/plugins/base" + _ "git.golem15.com/golem15/summercms/examples/hello/plugins/greeter" +) + +// PluginIDs is the ordered plugin list from summer.yaml. +var PluginIDs = []string{ + "golem15.hello", + "golem15.greeter", +} diff --git a/examples/hello/plugins/base/go.mod b/examples/hello/plugins/base/go.mod new file mode 100644 index 0000000..bfca136 --- /dev/null +++ b/examples/hello/plugins/base/go.mod @@ -0,0 +1,19 @@ +module git.golem15.com/golem15/summercms/examples/hello/plugins/base + +go 1.27.0 + +toolchain go1.27.0 + +require git.golem15.com/golem15/summercms v0.0.0 + +require ( + github.com/go-viper/mapstructure/v2 v2.4.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/v2 v2.3.6 // indirect + github.com/mitchellh/copystructure v1.2.0 // indirect + github.com/mitchellh/reflectwalk v1.0.2 // indirect + go.yaml.in/yaml/v3 v3.0.4 // indirect +) + +replace git.golem15.com/golem15/summercms => ../../../.. diff --git a/examples/hello/plugins/base/go.sum b/examples/hello/plugins/base/go.sum new file mode 100644 index 0000000..06a36ce --- /dev/null +++ b/examples/hello/plugins/base/go.sum @@ -0,0 +1,29 @@ +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/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/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/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= +github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/mitchellh/copystructure v1.2.0 h1:vpKXTN4ewci03Vljg/q9QvCGUDttBOGBIa15WveJJGw= +github.com/mitchellh/copystructure v1.2.0/go.mod h1:qLl+cE2AmVv+CoeAwDPye/v+N2HKCj9FbZEVFJRxO9s= +github.com/mitchellh/reflectwalk v1.0.2 h1:G2LzWKi524PWgd3mLHV8Y5k7s6XUvT0Gef6zxSIeXaQ= +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/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= +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= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/examples/hello/plugins/base/plugin.go b/examples/hello/plugins/base/plugin.go new file mode 100644 index 0000000..b0a84e7 --- /dev/null +++ b/examples/hello/plugins/base/plugin.go @@ -0,0 +1,19 @@ +package base + +import ( + "git.golem15.com/golem15/summercms/backpack" + "git.golem15.com/golem15/summercms/party" +) + +// Plugin is the golem15.hello plugin. +type Plugin struct{} + +func (p *Plugin) ID() string { return "golem15.hello" } +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 init() { + party.Register(&Plugin{}) +} diff --git a/examples/hello/plugins/greeter/go.mod b/examples/hello/plugins/greeter/go.mod new file mode 100644 index 0000000..dc86573 --- /dev/null +++ b/examples/hello/plugins/greeter/go.mod @@ -0,0 +1,22 @@ +module git.golem15.com/golem15/summercms/examples/hello/plugins/greeter + +go 1.27.0 + +toolchain go1.27.0 + +require git.golem15.com/golem15/summercms v0.0.0 + +require ( + 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/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 +) + +replace git.golem15.com/golem15/summercms => ../../../.. diff --git a/examples/hello/plugins/greeter/go.sum b/examples/hello/plugins/greeter/go.sum new file mode 100644 index 0000000..af22983 --- /dev/null +++ b/examples/hello/plugins/greeter/go.sum @@ -0,0 +1,37 @@ +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/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/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= +github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/mitchellh/copystructure v1.2.0 h1:vpKXTN4ewci03Vljg/q9QvCGUDttBOGBIa15WveJJGw= +github.com/mitchellh/copystructure v1.2.0/go.mod h1:qLl+cE2AmVv+CoeAwDPye/v+N2HKCj9FbZEVFJRxO9s= +github.com/mitchellh/reflectwalk v1.0.2 h1:G2LzWKi524PWgd3mLHV8Y5k7s6XUvT0Gef6zxSIeXaQ= +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= +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= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/examples/hello/plugins/greeter/plugin.go b/examples/hello/plugins/greeter/plugin.go new file mode 100644 index 0000000..683da00 --- /dev/null +++ b/examples/hello/plugins/greeter/plugin.go @@ -0,0 +1,43 @@ +package greeter + +import ( + "context" + + "git.golem15.com/golem15/summercms/backpack" + "git.golem15.com/golem15/summercms/bonfire" + "git.golem15.com/golem15/summercms/party" +) + +// Plugin is the golem15.greeter plugin. It requires golem15.hello. +type Plugin struct { + app *backpack.App +} + +func (p *Plugin) ID() string { return "golem15.greeter" } +func (p *Plugin) Requires() []string { return []string{"golem15.hello"} } + +func (p *Plugin) Register(*backpack.App) error { return nil } + +func (p *Plugin) Boot(app *backpack.App) error { + p.app = app + return nil +} + +func (p *Plugin) Commands() []bonfire.Command { + return []bonfire.Command{{ + Name: "greeter:hello", + Description: "Print the configured application name", + Run: func(ctx context.Context, in bonfire.Input, out bonfire.Output) error { + name := "" + if p.app != nil && p.app.Config != nil { + name = p.app.Config.String("app.name") + } + out.Println(name) + return nil + }, + }} +} + +func init() { + party.Register(&Plugin{}) +} diff --git a/examples/hello/summer.yaml b/examples/hello/summer.yaml new file mode 100644 index 0000000..6405c98 --- /dev/null +++ b/examples/hello/summer.yaml @@ -0,0 +1,7 @@ +module: git.golem15.com/golem15/summercms/examples/hello +binary: hello +plugins: + - id: golem15.hello + module: git.golem15.com/golem15/summercms/examples/hello/plugins/base + - id: golem15.greeter + module: git.golem15.com/golem15/summercms/examples/hello/plugins/greeter diff --git a/go.mod b/go.mod index ea329a2..cd470eb 100644 --- a/go.mod +++ b/go.mod @@ -1,3 +1,21 @@ module git.golem15.com/golem15/summercms go 1.27.0 + +toolchain go1.27.0 + +require ( + github.com/knadh/koanf/parsers/yaml v1.1.1 + github.com/knadh/koanf/v2 v2.3.6 + github.com/spf13/cobra v1.10.2 +) + +require ( + 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/mitchellh/copystructure v1.2.0 // indirect + 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 +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..af22983 --- /dev/null +++ b/go.sum @@ -0,0 +1,37 @@ +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/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/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= +github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/mitchellh/copystructure v1.2.0 h1:vpKXTN4ewci03Vljg/q9QvCGUDttBOGBIa15WveJJGw= +github.com/mitchellh/copystructure v1.2.0/go.mod h1:qLl+cE2AmVv+CoeAwDPye/v+N2HKCj9FbZEVFJRxO9s= +github.com/mitchellh/reflectwalk v1.0.2 h1:G2LzWKi524PWgd3mLHV8Y5k7s6XUvT0Gef6zxSIeXaQ= +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= +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= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/go.work b/go.work new file mode 100644 index 0000000..31d9812 --- /dev/null +++ b/go.work @@ -0,0 +1,10 @@ +go 1.27.0 + +toolchain go1.27.0 + +use ( + . + ./examples/hello + ./examples/hello/plugins/base + ./examples/hello/plugins/greeter +) diff --git a/pact/capabilities.go b/pact/capabilities.go new file mode 100644 index 0000000..45a454e --- /dev/null +++ b/pact/capabilities.go @@ -0,0 +1,34 @@ +package pact + +import ( + "io/fs" + + "git.golem15.com/golem15/summercms/bonfire" +) + +// HasCommands is implemented by plugins that register console commands. +type HasCommands interface { + Commands() []bonfire.Command +} + +// HasConfig is implemented by plugins that ship default YAML configuration. +// ConfigFS should contain files such as config/config.yaml, merged later at +// the plugin ID path. +type HasConfig interface { + ConfigFS() fs.FS +} + +// Reserved KERN-03 capability names, declared when their first consumers exist: +// +// HasModels +// HasMigrations +// HasRoutes +// HasMiddleware +// HasJobs +// HasListeners +// HasAdminControllers +// HasNavigation +// HasPermissions +// HasSchedule +// HasMailTemplates +// HasLang diff --git a/party/registry.go b/party/registry.go new file mode 100644 index 0000000..e035989 --- /dev/null +++ b/party/registry.go @@ -0,0 +1,195 @@ +package party + +import ( + "fmt" + "strings" + "sync" + + "git.golem15.com/golem15/summercms/backpack" +) + +// Plugin is the required descriptor every compiled plugin implements. +type Plugin interface { + ID() string + Requires() []string + Register(app *backpack.App) error + Boot(app *backpack.App) error +} + +var ( + mu sync.Mutex + plugins []Plugin +) + +// Register appends a plugin to the process-wide registry. Plugins call this +// from init() after a blank import; Activate selects and orders them. +func Register(p Plugin) { + if p == nil { + return + } + mu.Lock() + defer mu.Unlock() + plugins = append(plugins, p) +} + +func snapshot() []Plugin { + mu.Lock() + defer mu.Unlock() + out := make([]Plugin, len(plugins)) + copy(out, plugins) + return out +} + +// Activate resolves ids in manifest order, topologically sorts Requires(), +// then runs every Register before any Boot. +func Activate(app *backpack.App, ids []string) ([]Plugin, error) { + if app == nil { + return nil, fmt.Errorf("party: app is nil") + } + return activate(snapshot(), app, ids) +} + +func activate(registered []Plugin, app *backpack.App, ids []string) ([]Plugin, error) { + byID := make(map[string]Plugin, len(registered)) + for _, p := range registered { + if p == nil { + continue + } + id := p.ID() + if id == "" { + return nil, fmt.Errorf("party: registered plugin has empty id") + } + if _, exists := byID[id]; exists { + return nil, fmt.Errorf("party: duplicate plugin id %q", id) + } + byID[id] = p + } + + seen := make(map[string]struct{}, len(ids)) + selected := make([]Plugin, 0, len(ids)) + index := make(map[string]int, len(ids)) + for _, id := range ids { + if id == "" { + return nil, fmt.Errorf("party: empty plugin id in manifest") + } + if _, dup := seen[id]; dup { + return nil, fmt.Errorf("party: duplicate plugin id %q", id) + } + seen[id] = struct{}{} + p, ok := byID[id] + if !ok { + return nil, fmt.Errorf("party: plugin %q is not registered", id) + } + index[id] = len(selected) + selected = append(selected, p) + } + + for _, p := range selected { + for _, req := range p.Requires() { + if _, ok := seen[req]; !ok { + return nil, fmt.Errorf("party: plugin %q requires %q, which is not registered", p.ID(), req) + } + } + } + + ordered, err := topoSort(selected, index) + if err != nil { + return nil, err + } + + for _, p := range ordered { + if err := p.Register(app); err != nil { + return nil, fmt.Errorf("party: register %s: %w", p.ID(), err) + } + } + for _, p := range ordered { + if err := p.Boot(app); err != nil { + return nil, fmt.Errorf("party: boot %s: %w", p.ID(), err) + } + } + return ordered, nil +} + +func topoSort(plugins []Plugin, index map[string]int) ([]Plugin, error) { + byID := make(map[string]Plugin, len(plugins)) + indeg := make(map[string]int, len(plugins)) + dependents := make(map[string][]string, len(plugins)) + for _, p := range plugins { + byID[p.ID()] = p + indeg[p.ID()] = 0 + } + for _, p := range plugins { + seenReq := make(map[string]struct{}) + for _, req := range p.Requires() { + if _, ok := seenReq[req]; ok { + continue + } + seenReq[req] = struct{}{} + if _, ok := byID[req]; !ok { + continue + } + indeg[p.ID()]++ + dependents[req] = append(dependents[req], p.ID()) + } + } + + ready := make([]string, 0, len(plugins)) + for _, p := range plugins { + if indeg[p.ID()] == 0 { + ready = append(ready, p.ID()) + } + } + + out := make([]Plugin, 0, len(plugins)) + for len(ready) > 0 { + id := ready[0] + ready = ready[1:] + out = append(out, byID[id]) + next := append([]string(nil), dependents[id]...) + sortByIndex(next, index) + for _, dep := range next { + indeg[dep]-- + if indeg[dep] == 0 { + ready = insertByIndex(ready, dep, index) + } + } + } + if len(out) != len(plugins) { + leftover := make([]string, 0) + done := make(map[string]struct{}, len(out)) + for _, p := range out { + done[p.ID()] = struct{}{} + } + for _, p := range plugins { + if _, ok := done[p.ID()]; !ok { + leftover = append(leftover, p.ID()) + } + } + return nil, fmt.Errorf("party: dependency cycle involving %s", strings.Join(leftover, ", ")) + } + return out, nil +} + +func sortByIndex(ids []string, index map[string]int) { + for i := 1; i < len(ids); i++ { + j := i + for j > 0 && index[ids[j]] < index[ids[j-1]] { + ids[j], ids[j-1] = ids[j-1], ids[j] + j-- + } + } +} + +func insertByIndex(ready []string, id string, index map[string]int) []string { + at := len(ready) + for i, existing := range ready { + if index[id] < index[existing] { + at = i + break + } + } + ready = append(ready, "") + copy(ready[at+1:], ready[at:]) + ready[at] = id + return ready +} diff --git a/party/registry_test.go b/party/registry_test.go new file mode 100644 index 0000000..d928fc6 --- /dev/null +++ b/party/registry_test.go @@ -0,0 +1,56 @@ +package party + +import ( + "strings" + "testing" + + "git.golem15.com/golem15/summercms/backpack" + "git.golem15.com/golem15/summercms/compass" +) + +type tracePlugin struct { + id string + req []string + log *[]string +} + +func (p *tracePlugin) ID() string { return p.id } +func (p *tracePlugin) Requires() []string { return p.req } + +func (p *tracePlugin) Register(*backpack.App) error { + *p.log = append(*p.log, p.id+":register") + return nil +} + +func (p *tracePlugin) Boot(*backpack.App) error { + *p.log = append(*p.log, p.id+":boot") + return nil +} + +func TestActivateRunsAllRegisterBeforeAnyBoot(t *testing.T) { + var log []string + hello := &tracePlugin{id: "golem15.hello", log: &log} + greeter := &tracePlugin{id: "golem15.greeter", req: []string{"golem15.hello"}, log: &log} + app := backpack.New(&compass.Config{}) + + got, err := activate([]Plugin{greeter, hello}, app, []string{"golem15.greeter", "golem15.hello"}) + if err != nil { + t.Fatalf("activate: %v", err) + } + if len(got) != 2 { + t.Fatalf("got %d plugins, want 2", len(got)) + } + if got[0].ID() != "golem15.hello" || got[1].ID() != "golem15.greeter" { + t.Fatalf("order = %s, %s", got[0].ID(), got[1].ID()) + } + + want := []string{ + "golem15.hello:register", + "golem15.greeter:register", + "golem15.hello:boot", + "golem15.greeter:boot", + } + if strings.Join(log, ",") != strings.Join(want, ",") { + t.Fatalf("lifecycle = %v, want %v", log, want) + } +} From 2078f91a2b749b9dadadea21c16215ad638f925a Mon Sep 17 00:00:00 2001 From: Jakub Zych Date: Wed, 16 Sep 2026 13:01:24 +0200 Subject: [PATCH 2/3] feat(01-01): generate and build hello app from manifest - Add summer build with validated summer.yaml codegen - Generate stable main.go and plugins.gen.go then go build - Smoke-test the built hello binary greeter:hello command --- cmd/summer/main.go | 33 ++++ cmd/summer/main_test.go | 41 +++++ examples/hello/hello_test.go | 46 ++++++ internal/build/build.go | 308 +++++++++++++++++++++++++++++++++++ internal/build/build_test.go | 129 +++++++++++++++ 5 files changed, 557 insertions(+) create mode 100644 cmd/summer/main.go create mode 100644 cmd/summer/main_test.go create mode 100644 internal/build/build.go create mode 100644 internal/build/build_test.go diff --git a/cmd/summer/main.go b/cmd/summer/main.go new file mode 100644 index 0000000..620be23 --- /dev/null +++ b/cmd/summer/main.go @@ -0,0 +1,33 @@ +package main + +import ( + "context" + "fmt" + "os" + + "git.golem15.com/golem15/summercms/bonfire" + "git.golem15.com/golem15/summercms/internal/build" +) + +func main() { + root := bonfire.NewRoot("summer", []bonfire.Command{buildCommand()}, os.Stdout) + root.SetArgs(os.Args[1:]) + if err := root.Execute(); err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } +} + +func buildCommand() bonfire.Command { + return bonfire.Command{ + Name: "build", + Description: "Generate plugin imports and build the app binary", + Run: func(ctx context.Context, in bonfire.Input, out bonfire.Output) error { + dir, err := os.Getwd() + if err != nil { + return err + } + return build.App(ctx, dir, out) + }, + } +} diff --git a/cmd/summer/main_test.go b/cmd/summer/main_test.go new file mode 100644 index 0000000..24f9372 --- /dev/null +++ b/cmd/summer/main_test.go @@ -0,0 +1,41 @@ +package main + +import ( + "go/parser" + "go/token" + "os" + "path/filepath" + "strconv" + "strings" + "testing" +) + +func TestToolDoesNotImportExamplePlugins(t *testing.T) { + roots := []string{".", filepath.Join("..", "..", "internal", "build")} + fset := token.NewFileSet() + for _, root := range roots { + entries, err := os.ReadDir(root) + if err != nil { + t.Fatal(err) + } + for _, entry := range entries { + name := entry.Name() + if entry.IsDir() || !strings.HasSuffix(name, ".go") { + continue + } + file, err := parser.ParseFile(fset, filepath.Join(root, name), nil, parser.ImportsOnly) + if err != nil { + t.Fatal(err) + } + for _, spec := range file.Imports { + path, err := strconv.Unquote(spec.Path.Value) + if err != nil { + t.Fatal(err) + } + if strings.Contains(path, "examples/hello") { + t.Fatalf("%s imports %s", name, path) + } + } + } + } +} diff --git a/examples/hello/hello_test.go b/examples/hello/hello_test.go index c59d1d6..cf694dd 100644 --- a/examples/hello/hello_test.go +++ b/examples/hello/hello_test.go @@ -2,6 +2,9 @@ package main import ( "bytes" + "crypto/sha256" + "os" + "os/exec" "strings" "testing" ) @@ -15,3 +18,46 @@ func TestGreeterHelloPrintsAppName(t *testing.T) { t.Fatalf("output = %q, want %q", got, "hello-app") } } + +func TestBuiltBinaryGreeterHello(t *testing.T) { + first := runSummerBuild(t) + h1 := generatedHash(t) + second := runSummerBuild(t) + h2 := generatedHash(t) + if h1 != h2 { + t.Fatalf("generated files changed on second build\nfirst:\n%s\nsecond:\n%s", first, second) + } + + cmd := exec.Command("./bin/hello", "greeter:hello") + out, err := cmd.CombinedOutput() + if err != nil { + t.Fatalf("bin/hello greeter:hello: %v\n%s", err, out) + } + if got := strings.TrimSpace(string(out)); got != "hello-app" { + t.Fatalf("output = %q, want %q", got, "hello-app") + } +} + +func runSummerBuild(t *testing.T) string { + t.Helper() + cmd := exec.Command("go", "run", "../../cmd/summer", "build") + cmd.Dir = "." + out, err := cmd.CombinedOutput() + if err != nil { + t.Fatalf("summer build: %v\n%s", err, out) + } + return string(out) +} + +func generatedHash(t *testing.T) [32]byte { + t.Helper() + var buf bytes.Buffer + for _, name := range []string{"main.go", "plugins.gen.go"} { + body, err := os.ReadFile(name) + if err != nil { + t.Fatal(err) + } + buf.Write(body) + } + return sha256.Sum256(buf.Bytes()) +} diff --git a/internal/build/build.go b/internal/build/build.go new file mode 100644 index 0000000..2409912 --- /dev/null +++ b/internal/build/build.go @@ -0,0 +1,308 @@ +package build + +import ( + "bytes" + "context" + "errors" + "fmt" + "go/format" + "io" + "os" + "os/exec" + "path/filepath" + "strconv" + "strings" + "time" + "unicode" + + "github.com/knadh/koanf/parsers/yaml" +) + +const manifestName = "summer.yaml" + +// Manifest is the ordered app plugin list used to generate imports. +type Manifest struct { + Module string + Binary string + Plugins []Plugin +} + +// Plugin is one compiled plugin module in manifest order. +type Plugin struct { + ID string + Module string +} + +// LoadManifest reads and validates summer.yaml at path. +func LoadManifest(path string) (Manifest, error) { + raw, err := os.ReadFile(path) + if err != nil { + return Manifest{}, fmt.Errorf("build: read %s: %w", path, err) + } + tree, err := yaml.Parser().Unmarshal(raw) + if err != nil { + return Manifest{}, fmt.Errorf("build: parse %s: %w", path, err) + } + m := Manifest{ + Module: strings.TrimSpace(asString(tree["module"])), + Binary: strings.TrimSpace(asString(tree["binary"])), + } + if err := validateModulePath(m.Module); err != nil { + return Manifest{}, fmt.Errorf("build: module: %w", err) + } + if err := validateBinary(m.Binary); err != nil { + return Manifest{}, fmt.Errorf("build: binary: %w", err) + } + + seenID := make(map[string]struct{}) + seenMod := make(map[string]struct{}) + for i, item := range asSlice(tree["plugins"]) { + entry := asMap(item) + if entry == nil { + return Manifest{}, fmt.Errorf("build: plugins[%d] must be a mapping with id and module", i) + } + p := Plugin{ + ID: strings.TrimSpace(asString(entry["id"])), + Module: strings.TrimSpace(asString(entry["module"])), + } + if p.ID == "" { + return Manifest{}, fmt.Errorf("build: plugins[%d] is missing id", i) + } + if strings.ContainsAny(p.ID, " \t\r\n\"'`") { + return Manifest{}, fmt.Errorf("build: plugins[%d] has invalid id %q", i, p.ID) + } + if _, dup := seenID[p.ID]; dup { + return Manifest{}, fmt.Errorf("build: duplicate plugin id %q", p.ID) + } + if err := validateModulePath(p.Module); err != nil { + return Manifest{}, fmt.Errorf("build: plugins[%d] module: %w", i, err) + } + if _, dup := seenMod[p.Module]; dup { + return Manifest{}, fmt.Errorf("build: duplicate plugin module %q", p.Module) + } + seenID[p.ID] = struct{}{} + seenMod[p.Module] = struct{}{} + m.Plugins = append(m.Plugins, p) + } + return m, nil +} + +// App generates app sources from summer.yaml and runs go build in appDir. +func App(ctx context.Context, appDir string, out io.Writer) error { + if appDir == "" { + return fmt.Errorf("build: app directory is empty") + } + if !filepath.IsAbs(appDir) { + abs, err := filepath.Abs(appDir) + if err != nil { + return fmt.Errorf("build: resolve app directory: %w", err) + } + appDir = abs + } + if out == nil { + out = os.Stdout + } + m, err := LoadManifest(filepath.Join(appDir, manifestName)) + if err != nil { + return err + } + if err := generate(appDir, m); err != nil { + return err + } + + binRel := filepath.Join("bin", m.Binary) + if err := os.MkdirAll(filepath.Join(appDir, "bin"), 0o755); err != nil { + return fmt.Errorf("build: create bin dir: %w", err) + } + + cmd := exec.CommandContext(ctx, "go", "build", "-o", binRel, ".") + cmd.Dir = appDir + cmd.Stdout = out + cmd.Stderr = out + start := time.Now() + if err := cmd.Run(); err != nil { + return fmt.Errorf("build: go build: %w", err) + } + fmt.Fprintf(out, "built %s in %s\n", m.Binary, time.Since(start).Round(time.Millisecond)) + return nil +} + +func generate(appDir string, m Manifest) error { + mainSrc, err := generateMain(m) + if err != nil { + return err + } + pluginsSrc, err := generatePluginsGen(m) + if err != nil { + return err + } + if err := writeIfChanged(filepath.Join(appDir, "main.go"), mainSrc); err != nil { + return err + } + if err := writeIfChanged(filepath.Join(appDir, "plugins.gen.go"), pluginsSrc); err != nil { + return err + } + return nil +} + +func generateMain(m Manifest) ([]byte, error) { + var b strings.Builder + b.WriteString("// Code generated by summer build. DO NOT EDIT.\n\n") + b.WriteString("package main\n\n") + b.WriteString("import (\n") + b.WriteString("\t\"fmt\"\n") + b.WriteString("\t\"io\"\n") + b.WriteString("\t\"os\"\n\n") + b.WriteString("\t\"git.golem15.com/golem15/summercms/backpack\"\n") + b.WriteString("\t\"git.golem15.com/golem15/summercms/bonfire\"\n") + b.WriteString("\t\"git.golem15.com/golem15/summercms/compass\"\n") + b.WriteString("\t\"git.golem15.com/golem15/summercms/pact\"\n") + b.WriteString("\t\"git.golem15.com/golem15/summercms/party\"\n") + b.WriteString(")\n\n") + b.WriteString("func main() {\n") + b.WriteString("\tif err := run(os.Args[1:], os.Stdout); err != nil {\n") + b.WriteString("\t\tfmt.Fprintln(os.Stderr, err)\n") + b.WriteString("\t\tos.Exit(1)\n") + b.WriteString("\t}\n") + b.WriteString("}\n\n") + b.WriteString("func run(args []string, out io.Writer) error {\n") + b.WriteString("\tcfg, err := compass.Load(\"config\")\n") + b.WriteString("\tif err != nil {\n") + b.WriteString("\t\treturn err\n") + b.WriteString("\t}\n") + b.WriteString("\tapp := backpack.New(cfg)\n") + b.WriteString("\tplugins, err := party.Activate(app, PluginIDs)\n") + b.WriteString("\tif err != nil {\n") + b.WriteString("\t\treturn err\n") + b.WriteString("\t}\n") + b.WriteString("\tvar commands []bonfire.Command\n") + b.WriteString("\tfor _, plugin := range plugins {\n") + b.WriteString("\t\tif hasCommands, ok := plugin.(pact.HasCommands); ok {\n") + b.WriteString("\t\t\tcommands = append(commands, hasCommands.Commands()...)\n") + b.WriteString("\t\t}\n") + b.WriteString("\t}\n") + fmt.Fprintf(&b, "\troot := bonfire.NewRoot(%s, commands, out)\n", strconv.Quote(m.Binary)) + b.WriteString("\troot.SetArgs(args)\n") + b.WriteString("\treturn root.Execute()\n") + b.WriteString("}\n") + return formatSource([]byte(b.String())) +} + +func generatePluginsGen(m Manifest) ([]byte, error) { + var b strings.Builder + b.WriteString("// Code generated by summer build. DO NOT EDIT.\n\n") + b.WriteString("package main\n\n") + if len(m.Plugins) > 0 { + b.WriteString("import (\n") + for _, p := range m.Plugins { + fmt.Fprintf(&b, "\t_ %s\n", strconv.Quote(p.Module)) + } + b.WriteString(")\n\n") + } + b.WriteString("// PluginIDs is the ordered plugin list from summer.yaml.\n") + b.WriteString("var PluginIDs = []string{\n") + for _, p := range m.Plugins { + fmt.Fprintf(&b, "\t%s,\n", strconv.Quote(p.ID)) + } + b.WriteString("}\n") + return formatSource([]byte(b.String())) +} + +func formatSource(src []byte) ([]byte, error) { + formatted, err := format.Source(src) + if err != nil { + return nil, fmt.Errorf("build: format generated go: %w", err) + } + return formatted, nil +} + +func writeIfChanged(path string, content []byte) error { + existing, err := os.ReadFile(path) + if err == nil && bytes.Equal(existing, content) { + return nil + } + if err != nil && !errors.Is(err, os.ErrNotExist) { + return fmt.Errorf("build: read %s: %w", path, err) + } + if err := os.WriteFile(path, content, 0o644); err != nil { + return fmt.Errorf("build: write %s: %w", path, err) + } + return nil +} + +func validateBinary(name string) error { + if name == "" { + return fmt.Errorf("is empty") + } + if name != filepath.Base(name) || strings.ContainsRune(name, os.PathSeparator) { + return fmt.Errorf("%q must be a bare file name", name) + } + if name == "." || name == ".." { + return fmt.Errorf("%q is not a valid binary name", name) + } + for _, r := range name { + if r > unicode.MaxASCII || !(unicode.IsLetter(r) || unicode.IsDigit(r) || r == '.' || r == '_' || r == '-') { + return fmt.Errorf("%q contains invalid characters", name) + } + } + return nil +} + +func validateModulePath(path string) error { + if path == "" { + return fmt.Errorf("is empty") + } + if strings.HasPrefix(path, "-") { + return fmt.Errorf("%q must not start with -", path) + } + if strings.ContainsAny(path, " \t\r\n\\\"'`$;&|*?<>()[]{}") { + return fmt.Errorf("%q contains invalid characters", path) + } + if filepath.IsAbs(path) || strings.Contains(path, `\`) { + return fmt.Errorf("%q is not a valid module path", path) + } + for _, seg := range strings.Split(path, "/") { + if seg == "" || seg == "." || seg == ".." { + return fmt.Errorf("%q has an invalid path segment", path) + } + if strings.Contains(seg, "@") { + return fmt.Errorf("%q must not contain a version suffix", path) + } + } + return nil +} + +func asString(v any) string { + s, _ := v.(string) + return s +} + +func asSlice(v any) []any { + switch s := v.(type) { + case []any: + return s + case nil: + return nil + default: + return nil + } +} + +func asMap(v any) map[string]any { + switch m := v.(type) { + case map[string]any: + return m + case map[any]any: + out := make(map[string]any, len(m)) + for k, val := range m { + ks, ok := k.(string) + if !ok { + continue + } + out[ks] = val + } + return out + default: + return nil + } +} diff --git a/internal/build/build_test.go b/internal/build/build_test.go new file mode 100644 index 0000000..28af067 --- /dev/null +++ b/internal/build/build_test.go @@ -0,0 +1,129 @@ +package build + +import ( + "bytes" + "os" + "path/filepath" + "strings" + "testing" +) + +func TestLoadHelloManifest(t *testing.T) { + m, err := LoadManifest(filepath.Join("..", "..", "examples", "hello", "summer.yaml")) + if err != nil { + t.Fatal(err) + } + if m.Binary != "hello" { + t.Fatalf("binary = %q", m.Binary) + } + if len(m.Plugins) != 2 || m.Plugins[0].ID != "golem15.hello" || m.Plugins[1].ID != "golem15.greeter" { + t.Fatalf("plugins = %+v", m.Plugins) + } +} + +func TestParseManifestRejectsDuplicatesAndInvalidPaths(t *testing.T) { + dir := t.TempDir() + + t.Run("duplicate id", func(t *testing.T) { + path := filepath.Join(dir, "dup.yaml") + write(t, path, "module: example.com/app\nbinary: app\nplugins:\n - id: a.b\n module: example.com/one\n - id: a.b\n module: example.com/two\n") + if _, err := LoadManifest(path); err == nil || !strings.Contains(err.Error(), "a.b") { + t.Fatalf("want duplicate id error, got %v", err) + } + }) + + t.Run("invalid module", func(t *testing.T) { + path := filepath.Join(dir, "bad.yaml") + write(t, path, "module: example.com/app\nbinary: app\nplugins:\n - id: a.b\n module: \"example.com/one; rm -rf /\"\n") + if _, err := LoadManifest(path); err == nil { + t.Fatal("want invalid module error") + } + }) + + t.Run("path traversal module", func(t *testing.T) { + path := filepath.Join(dir, "dotdot.yaml") + write(t, path, "module: example.com/app\nbinary: app\nplugins:\n - id: a.b\n module: example.com/../evil\n") + if _, err := LoadManifest(path); err == nil { + t.Fatal("want invalid module error") + } + }) +} + +func TestGenerateStableQuotedImportsInManifestOrder(t *testing.T) { + m := Manifest{ + Module: "example.com/app", + Binary: "hello", + Plugins: []Plugin{ + {ID: "golem15.hello", Module: "example.com/app/plugins/base"}, + {ID: "golem15.greeter", Module: "example.com/app/plugins/greeter"}, + }, + } + first, err := generatePluginsGen(m) + if err != nil { + t.Fatal(err) + } + second, err := generatePluginsGen(m) + if err != nil { + t.Fatal(err) + } + if !bytes.Equal(first, second) { + t.Fatal("generated plugins.gen.go is not byte-stable") + } + src := string(first) + baseAt := strings.Index(src, `example.com/app/plugins/base`) + greeterAt := strings.Index(src, `example.com/app/plugins/greeter`) + if baseAt < 0 || greeterAt < 0 || baseAt > greeterAt { + t.Fatalf("imports not in manifest order:\n%s", src) + } + if !strings.Contains(src, "\t_ \"example.com/app/plugins/base\"") { + t.Fatalf("import is not Go-quoted:\n%s", src) + } + + mainSrc, err := generateMain(m) + if err != nil { + t.Fatal(err) + } + again, err := generateMain(m) + if err != nil { + t.Fatal(err) + } + if !bytes.Equal(mainSrc, again) { + t.Fatal("generated main.go is not byte-stable") + } + if !bytes.Contains(mainSrc, []byte(`bonfire.NewRoot("hello"`)) { + t.Fatalf("main does not use manifest binary:\n%s", mainSrc) + } + if bytes.Contains(mainSrc, []byte("examples/hello")) { + t.Fatal("generated main hard-codes examples/hello") + } +} + +func TestWriteIfChangedSkipsIdenticalBytes(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "out.go") + content := []byte("package main\n") + if err := writeIfChanged(path, content); err != nil { + t.Fatal(err) + } + info, err := os.Stat(path) + if err != nil { + t.Fatal(err) + } + if err := writeIfChanged(path, content); err != nil { + t.Fatal(err) + } + again, err := os.Stat(path) + if err != nil { + t.Fatal(err) + } + if !info.ModTime().Equal(again.ModTime()) { + t.Fatal("identical content rewrote the file") + } +} + +func write(t *testing.T, path, body string) { + t.Helper() + if err := os.WriteFile(path, []byte(body), 0o644); err != nil { + t.Fatal(err) + } +} From 201973d741fce03f1c3228050443801d5f94f697 Mon Sep 17 00:00:00 2001 From: Jakub Zych Date: Wed, 16 Sep 2026 13:03:39 +0200 Subject: [PATCH 3/3] docs(01-01): complete hello app kernel plan - Record task commits, deviations, and verification evidence --- .../01-01-SUMMARY.md | 160 ++++++++++++++++++ 1 file changed, 160 insertions(+) create mode 100644 .planning/phases/01-framework-kernel-foundation/01-01-SUMMARY.md diff --git a/.planning/phases/01-framework-kernel-foundation/01-01-SUMMARY.md b/.planning/phases/01-framework-kernel-foundation/01-01-SUMMARY.md new file mode 100644 index 0000000..49e80d7 --- /dev/null +++ b/.planning/phases/01-framework-kernel-foundation/01-01-SUMMARY.md @@ -0,0 +1,160 @@ +--- +phase: 01-framework-kernel-foundation +plan: 01 +subsystem: kernel +tags: [go, cobra, koanf, plugins, cli, workspace] + +requires: [] +provides: + - Manifest-driven code generation and app build + - Required plugin interface and activation + - Shared cobra command construction + - Ordered hello plugin manifest +affects: [01-02, 01-03, 01-04] + +tech-stack: + added: + - github.com/spf13/cobra v1.10.2 + - github.com/knadh/koanf/v2 v2.3.6 + - github.com/knadh/koanf/parsers/yaml v1.1.1 + patterns: + - Compiled plugins self-register in init(); Activate orders by Requires() + - Shared bonfire.NewRoot used by tool and app + - summer.yaml ordered plugin list drives generated imports + +key-files: + created: + - party/registry.go + - backpack/app.go + - pact/capabilities.go + - compass/config.go + - bonfire/command.go + - bonfire/root.go + - internal/build/build.go + - cmd/summer/main.go + - examples/hello/summer.yaml + - examples/hello/main.go + - examples/hello/plugins.gen.go + - go.work + modified: + - go.mod + - go.sum + - .gitignore + +key-decisions: + - "Plugin lives in party with Register/Boot(*backpack.App); backpack does not import party" + - "bonfire.NewRoot takes []bonfire.Command, not a party registry" + - "HasConfig.ConfigFS() named to match Plan 02" + - "Example modules use local replace for unpublished git.golem15.com/golem15/summercms v0.0.0" + - "Pin toolchain go1.27.0 in go.work and every example go.mod" + +patterns-established: + - "Pattern: blank-import plugins.gen.go plus explicit PluginIDs slice; never use init order for lifecycle" + - "Pattern: summer build validates module paths, quotes imports, and execs go build with separate args" + +requirements-completed: [KERN-01, KERN-02, KERN-03, KERN-04, CLI-01] + +duration: 15min +completed: 2026-09-16 +--- + +# Phase 1 Plan 1: Hello App Kernel Summary + +**Compiled-plugin hello app boots via party.Activate and runs greeter:hello through a shared cobra/bonfire kernel, built by `summer build` from an ordered summer.yaml manifest** + +## Performance + +- **Duration:** 15 min +- **Started:** 2026-09-16T10:47:05Z +- **Completed:** 2026-09-16T11:02:48Z +- **Tasks:** 2 +- **Files modified:** 30 + +## Accomplishments + +- Framework kernel packages (`party`, `backpack`, `pact`, `compass`, `bonfire`) boot two compiled hello plugins with all Register calls before any Boot +- Permanent `examples/hello` app prints `app.name` from `config/app.yaml` via `greeter:hello` +- `cmd/summer build` generates stable `main.go`/`plugins.gen.go` from `summer.yaml` and produces `bin/hello` without importing example plugins + +## Task Commits + +Each task was committed atomically: + +1. **Task 1: Make the hello app boot and dispatch a plugin command** - `96a8d38` (feat) +2. **Task 2: Generate and build the hello app from its ordered manifest** - `2078f91` (feat) + +**Plan metadata:** pending (docs: complete plan) + +## Files Created/Modified + +- `party/registry.go` - Plugin interface, init registry, topo-sorted Activate +- `backpack/app.go` - App container holding compass config; no party import +- `pact/capabilities.go` - HasCommands, HasConfig; reserved KERN-03 names documented +- `compass/config.go` - Load config/
.yaml and String dot-path lookup +- `bonfire/command.go` / `bonfire/root.go` - Command values, Input/Output, shared cobra root +- `internal/build/build.go` - Manifest parse, validated codegen, `go build` via CommandContext +- `cmd/summer/main.go` - Framework tool entry using the same NewRoot +- `examples/hello/` - Workspace app, two plugin modules, summer.yaml, generated main +- `go.mod` / `go.work` - cobra, koanf, toolchain go1.27.0, four workspace modules + +## Decisions Made + +- Keep `Plugin` in `party` taking `*backpack.App` so `backpack` never imports `party` +- `bonfire.NewRoot(name, []Command, io.Writer)` is the single command kernel for tool and app +- Name `HasConfig.ConfigFS()` now so Plan 02 can load embedded plugin defaults without a rename +- Add `replace` lines in example `go.mod` files; D-04 allows go.work or local replace, and `v0.0.0` is not fetchable from the private module host +- Re-pin `toolchain go1.27.0` after `go mod tidy`, which drops a matching toolchain line + +## Deviations from Plan + +### Auto-fixed Issues + +**1. [Rule 3 - Blocking] Local replace for unpublished framework module** +- **Found during:** Task 1 (module tidy) +- **Issue:** `go mod tidy` in example modules tried to fetch `git.golem15.com/golem15/summercms@v0.0.0` over HTTPS and failed (private host, no tag) +- **Fix:** Added `replace git.golem15.com/golem15/summercms => ` in hello/plugin go.mod files, which D-04 already allows +- **Files modified:** `examples/hello/go.mod`, `examples/hello/plugins/base/go.mod`, `examples/hello/plugins/greeter/go.mod` +- **Verification:** `go vet`/`go test` in root and hello +- **Committed in:** `96a8d38` (Task 1) + +**2. [Rule 2 - Missing Critical] Restore toolchain directive after tidy** +- **Found during:** Task 1 +- **Issue:** `go mod tidy` removed `toolchain go1.27.0` from go.mod files because the local toolchain already matched +- **Fix:** Re-added the directive to root and all example modules plus `go.work` (D-05) +- **Files modified:** `go.mod`, `examples/hello/go.mod`, plugin go.mod files, `go.work` +- **Verification:** files contain `toolchain go1.27.0` +- **Committed in:** `96a8d38` (Task 1) + +--- + +**Total deviations:** 2 auto-fixed (1 blocking, 1 missing critical) +**Impact on plan:** Required for a buildable workspace of unpublished modules and the D-05 toolchain pin. No scope creep. + +## Issues Encountered + +- Spawn HEAD was `master` on this isolated Grok clone (`.git` is a directory, not a linked worktree). Created `worktree-agent-01a0a9d2` from `552cadb` without rewriting `refs/heads/master`. Commits live on the agent branch. + +## Authentication Gates + +None. + +## User Setup Required + +None - no external service configuration required. + +## Next Phase Readiness + +Ready for `01-02-PLAN.md` (layered compass config, optional plugin composition, services, typed events). Hello already boots and `summer build` is the shared generate-and-compile function later plans extend. + +## Self-Check: PASSED + +- Created kernel, hello, and build files exist on disk +- Commits `96a8d38` and `2078f91` exist +- Root and hello `go vet ./...` and `go test ./...` passed +- Two `summer build` runs left `main.go`/`plugins.gen.go` hashes unchanged; `./bin/hello greeter:hello` printed `hello-app` +- `go list -deps ./cmd/summer` has no hello plugin packages +- STATE.md and ROADMAP.md were not updated in this worktree + +--- +*Phase: 01-framework-kernel-foundation* +*Completed: 2026-09-16*