chore: merge executor worktree (worktree-agent-01a0a9d2)

This commit is contained in:
Jakub Zych
2026-09-16 13:04:33 +02:00
31 changed files with 1699 additions and 0 deletions

1
.gitignore vendored
View File

@@ -1,5 +1,6 @@
# Go # Go
/bin/ /bin/
/examples/hello/bin/
/dist/ /dist/
*.test *.test
*.out *.out

View File

@@ -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/<section>.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 => <relative>` 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*

13
backpack/app.go Normal file
View File

@@ -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}
}

37
backpack/app_test.go Normal file
View File

@@ -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)
}
}
}
}

110
bonfire/command.go Normal file
View File

@@ -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...)
}

58
bonfire/root.go Normal file
View File

@@ -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
}

33
cmd/summer/main.go Normal file
View File

@@ -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)
},
}
}

41
cmd/summer/main_test.go Normal file
View File

@@ -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)
}
}
}
}
}

72
compass/config.go Normal file
View File

@@ -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/<section>.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
}
}

23
compass/config_test.go Normal file
View File

@@ -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)
}
}

View File

@@ -0,0 +1 @@
name: hello-app

30
examples/hello/go.mod Normal file
View File

@@ -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
)

37
examples/hello/go.sum Normal file
View File

@@ -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=

View File

@@ -0,0 +1,63 @@
package main
import (
"bytes"
"crypto/sha256"
"os"
"os/exec"
"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")
}
}
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())
}

43
examples/hello/main.go Normal file
View File

@@ -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()
}

View File

@@ -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",
}

View File

@@ -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 => ../../../..

View File

@@ -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=

View File

@@ -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{})
}

View File

@@ -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 => ../../../..

View File

@@ -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=

View File

@@ -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{})
}

View File

@@ -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

18
go.mod
View File

@@ -1,3 +1,21 @@
module git.golem15.com/golem15/summercms module git.golem15.com/golem15/summercms
go 1.27.0 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
)

37
go.sum Normal file
View File

@@ -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=

10
go.work Normal file
View File

@@ -0,0 +1,10 @@
go 1.27.0
toolchain go1.27.0
use (
.
./examples/hello
./examples/hello/plugins/base
./examples/hello/plugins/greeter
)

308
internal/build/build.go Normal file
View File

@@ -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
}
}

View File

@@ -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)
}
}

34
pact/capabilities.go Normal file
View File

@@ -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

195
party/registry.go Normal file
View File

@@ -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
}

56
party/registry_test.go Normal file
View File

@@ -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)
}
}