From 96a8d38be2fbd2463fffd122ae3b64b2135717b5 Mon Sep 17 00:00:00 2001 From: Jakub Zych Date: Wed, 16 Sep 2026 12:57:18 +0200 Subject: [PATCH] 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) + } +}