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

This commit is contained in:
Jakub Zych
2026-09-16 14:02:00 +02:00
22 changed files with 2590 additions and 229 deletions

View File

@@ -0,0 +1,164 @@
---
phase: 01-framework-kernel-foundation
plan: 03
subsystem: kernel
tags: [go, cobra, fsnotify, cli, scaffolding, watch]
requires:
- phase: 01-framework-kernel-foundation
provides: Manifest-driven summer build, shared bonfire.NewRoot, hello app workspace
provides:
- make:plugin and plugin:add scaffolding against summer.yaml
- Deterministic bonfire Output widgets and prompts with non-TTY fallback
- Built-in summer dev watch/rebuild/restart loop
affects: [01-04, 04]
tech-stack:
added:
- golang.org/x/term v0.46.0
- github.com/fsnotify/fsnotify v1.10.1
patterns:
- summer.yaml is the sole ordered plugin list; scaffold then plugin:add then build
- One injected bonfire.Output decides TTY/color once per process
- summer dev calls the same build.App used by summer build
key-files:
created:
- internal/build/manifest.go
- internal/build/scaffold.go
- bonfire/output.go
- bonfire/widgets.go
- bonfire/prompts.go
- internal/dev/watch.go
modified:
- internal/build/build.go
- cmd/summer/main.go
- bonfire/root.go
- bonfire/command.go
- examples/hello/plugins/greeter/plugin.go
- go.mod
- go.sum
key-decisions:
- "plugin:add is idempotent on the same id+module and rejects conflicting ids"
- "Child go commands override inherited GOWORK so summer build in a nested app does not use a parent workspace"
- "Confirm is silent on non-TTY and uses its default so tests never hang"
- "Watch ignores app-root main.go and plugins.gen.go to prevent generate loops"
patterns-established:
- "Pattern: make:plugin writes plugins/<name>/{go.mod,plugin.go,config/}; plugin:add wires manifest, go.work and go.mod replace"
- "Pattern: plugin command names are namespace:verb; kernel names build and dev are the only colon-less exceptions"
- "Pattern: summer dev prints rebuild: <duration> after each successful build.App and reaps the child on cancel"
requirements-completed: [KERN-04, KERN-09, CLI-01]
duration: 26min
completed: 2026-09-16
---
# Phase 1 Plan 3: Plugin Scaffold, CLI Output and Watch Loop Summary
**make:plugin / plugin:add wire a compiling module into summer.yaml, bonfire degrades spinner/progress/table/prompts without a TTY, and summer dev rebuilds through build.App with measured restart latency**
## Performance
- **Duration:** 26 min
- **Started:** 2026-09-16T11:33:58Z
- **Completed:** 2026-09-16T12:00:25Z
- **Tasks:** 3
- **Files modified:** 21
## Accomplishments
- `summer make:plugin` scaffolds `plugins/<name>` with `go.mod` (`toolchain go1.27.0`), `plugin.go` and empty `config/`; `plugin:add` appends one ordered manifest entry, `go work use`, and a portable app `go.mod` require/replace
- Shared cobra adapter injects one `bonfire.Output` built from stdin/stdout/stderr; non-TTY spinner is `[...] message`, progress is `[N/M] pct%` at 10% steps, tables are TSV, and `NO_COLOR` / `TERM=dumb` disable ANSI
- `summer dev` watches sources, debounces, calls `build.App`, prints `rebuild: <duration>`, keeps the last child on build failure, and reaps it on cancel without looping on generated `main.go` / `plugins.gen.go`
## Task Commits
Each task was committed atomically:
1. **Task 1: Add and rebuild a compiling plugin from the tool** - `9b1a25d` (feat)
2. **Task 2: Give tool and plugin commands deterministic rich output** - `a6004e4` (feat)
3. **Task 3: Rebuild and restart the hello binary during source edits** - `270b7f1` (feat)
**Plan metadata:** pending (docs: complete plan)
## Files Created/Modified
- `internal/build/manifest.go` - Load/Save summer.yaml, lowercase vendor.plugin IDs
- `internal/build/scaffold.go` - MakePlugin / AddPlugin with path containment checks
- `internal/build/build.go` - generate-and-compile plus GOWORK isolation for child `go` commands
- `cmd/summer/main.go` - `make:plugin`, `plugin:add`, `build`, `dev` tool commands
- `bonfire/output.go` / `widgets.go` / `prompts.go` - injected Output, spinner, progress, table, prompts
- `bonfire/root.go` / `command.go` - cobra adapter, named `ErrCommandName` for plugin names without `:`
- `internal/dev/watch.go` - fsnotify watch loop with debounce, serialize, restart, reap
- `examples/hello/plugins/greeter/plugin.go` - table plus confirm on `greeter:hello` without hanging on closed stdin
## Decisions Made
- Keep generated plugin paths under `plugins/<name>` derived from the ID's plugin segment
- Treat `build` and `dev` as the only kernel command names allowed without a colon
- Detect widgets from stdout TTY and prompts from stdin+stdout TTY so piped tests use defaults
- `secret` uses `term.ReadPassword` on a TTY file and a plain stdin line otherwise; answers are never written back
- Watch production path always calls `build.App`; tests inject Build/Start so debounce and ignore behavior stay fast
## Deviations from Plan
### Auto-fixed Issues
**1. [Rule 3 - Blocking] Child go commands inherited test GOFLAGS/GOWORK**
- **Found during:** Task 1 (plugin compile on a temp hello copy)
- **Issue:** `go test` sets `-mod=readonly` and a parent workspace; `go build` of a temp app then targeted the wrong module
- **Fix:** Strip inherited `GOWORK`/`GOFLAGS` and set `GOWORK` to the nearest app `go.work` (or `off`)
- **Files modified:** `internal/build/scaffold.go`, `internal/build/build.go`
- **Verification:** `go test ./internal/build`
- **Committed in:** `9b1a25d` (Task 1)
**2. [Rule 3 - Blocking] NewRoot now returns a named command-name error**
- **Found during:** Task 2
- **Issue:** Plugin names without `namespace:verb` must fail registration; callers and generated `main.go` still treated NewRoot as a single return
- **Fix:** `NewRoot`/`NewRootIO` return `(*cobra.Command, error)`; generateMain and hello callers updated
- **Files modified:** `bonfire/root.go`, `internal/build/build.go`, `examples/hello/main.go`, `examples/hello/hello_test.go`, `cmd/summer/main.go`
- **Verification:** `go test ./bonfire ./cmd/summer` and hello tests
- **Committed in:** `a6004e4` (Task 2)
**3. [Rule 3 - Blocking] Greeter test Output stub no longer satisfied the interface**
- **Found during:** Task 2
- **Issue:** Expanding Output broke `examples/hello/plugins/greeter/plugin_test.go`'s three-method stub
- **Fix:** Run the command against `bonfire.NewOutput` with injected streams
- **Files modified:** `examples/hello/plugins/greeter/plugin_test.go`
- **Verification:** `go test` in the greeter module
- **Committed in:** `a6004e4` (Task 2)
---
**Total deviations:** 3 auto-fixed (3 blocking)
**Impact on plan:** Required for temp-app builds, named registration errors, and a compiling greeter test. No scope creep. Choice prompts use a numbered list rather than TTY arrow keys; non-TTY behavior matches the plan.
## Issues Encountered
- `go mod tidy` in a workspace still drops `toolchain go1.27.0` unless it is rewritten; Task 1 restores it after plugin tidy (same D-05 pattern as Plans 01 and 02).
## Authentication Gates
None.
## User Setup Required
None - no external service configuration required.
## Next Phase Readiness
Ready for `01-04-PLAN.md` (phase unit-test coverage). Scaffolding, rich CLI output and `summer dev` exist on the hello path. Do not add HTTP/DB/auth here.
## Self-Check: PASSED
- Created scaffold, output, watch, and SUMMARY files exist on disk
- Commits `9b1a25d`, `a6004e4` and `270b7f1` exist
- Root and hello `go vet ./...` and `go test ./...` passed
- STATE.md and ROADMAP.md were not updated in this worktree
---
*Phase: 01-framework-kernel-foundation*
*Completed: 2026-09-16*

View File

@@ -2,12 +2,15 @@ package bonfire
import (
"context"
"fmt"
"io"
"errors"
"strings"
"github.com/spf13/cobra"
)
// ErrCommandName is returned when a plugin command is missing namespace:verb.
var ErrCommandName = errors.New("bonfire: plugin command name must be namespace:verb")
// Command is a console command value collected from plugins or the tool.
type Command struct {
Name string
@@ -39,13 +42,6 @@ type Input interface {
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
@@ -84,27 +80,39 @@ func (in cobraInput) Flag(name string) (string, bool) {
return val, true
}
type writerOutput struct {
w io.Writer
func validCommandName(name string) bool {
switch name {
case "build", "dev":
return true
}
ns, verb, ok := strings.Cut(name, ":")
return ok && ns != "" && verb != "" && !strings.Contains(verb, " ")
}
func (o writerOutput) Write(p []byte) (int, error) {
if o.w == nil {
return 0, io.ErrClosedPipe
func commandUsage(c Command) string {
parts := []string{c.Name}
for _, a := range c.Args {
if a.Required {
parts = append(parts, "<"+a.Name+">")
} else {
parts = append(parts, "["+a.Name+"]")
}
}
return o.w.Write(p)
return strings.Join(parts, " ")
}
func (o writerOutput) Println(a ...any) {
if o.w == nil {
return
func positionalArgs(args []Arg) cobra.PositionalArgs {
required := 0
for _, a := range args {
if a.Required {
required++
}
}
fmt.Fprintln(o.w, a...)
}
func (o writerOutput) Printf(format string, a ...any) {
if o.w == nil {
return
if required == 0 {
return cobra.ArbitraryArgs
}
fmt.Fprintf(o.w, format, a...)
if required == len(args) {
return cobra.ExactArgs(required)
}
return cobra.MinimumNArgs(required)
}

170
bonfire/output.go Normal file
View File

@@ -0,0 +1,170 @@
package bonfire
import (
"fmt"
"io"
"os"
"golang.org/x/term"
)
const (
ansiReset = "\x1b[0m"
ansiRed = "\x1b[31m"
ansiGreen = "\x1b[32m"
ansiYellow = "\x1b[33m"
ansiCyan = "\x1b[36m"
ansiDim = "\x1b[90m"
symCheck = "✓"
symCross = "✗"
symWarn = "⚠"
symInfo = "ℹ"
symArrow = "→"
symPrompt = "?"
)
// Output is the injected console used by commands. Tests supply streams.
type Output interface {
io.Writer
Println(a ...any)
Printf(format string, a ...any)
Info(message string)
Success(message string)
Error(message string)
Warning(message string)
Table(headers []string, rows [][]string)
Spinner(message string, fn func() error) error
Progress(total int, fn func(Progress) error) error
Ask(question, def string) (string, error)
Confirm(question string, def bool) (bool, error)
Choice(question string, choices []string, def int) (string, error)
Secret(question string) (string, error)
}
// Progress is a single progress bar advanced from a command.
type Progress interface {
Advance(step int)
}
type console struct {
in io.Reader
out io.Writer
err io.Writer
lookup func(string) string
tty bool
interactive bool
color bool
}
// NewOutput builds Output from injected streams and the process color policy.
func NewOutput(in io.Reader, out, errW io.Writer) Output {
return newConsole(in, out, errW, os.Getenv, nil, nil)
}
func newConsole(in io.Reader, out, errW io.Writer, lookup func(string) string, tty, interactive *bool) *console {
if in == nil {
in = os.Stdin
}
if out == nil {
out = os.Stdout
}
if errW == nil {
errW = out
}
if lookup == nil {
lookup = os.Getenv
}
c := &console{in: in, out: out, err: errW, lookup: lookup}
if tty != nil {
c.tty = *tty
} else {
c.tty = isTerminal(out)
}
if interactive != nil {
c.interactive = *interactive
} else {
c.interactive = isTerminal(in) && c.tty
}
c.color = supportsColor(lookup, c.tty)
return c
}
func supportsColor(lookup func(string) string, tty bool) bool {
if lookup == nil {
lookup = os.Getenv
}
if lookup("NO_COLOR") != "" || lookup("TERM") == "dumb" {
return false
}
if lookup("FORCE_COLOR") != "" {
return true
}
return tty
}
func isTerminal(rw any) bool {
type fd interface{ Fd() uintptr }
f, ok := rw.(fd)
if !ok {
return false
}
return term.IsTerminal(int(f.Fd()))
}
func (c *console) Write(p []byte) (int, error) {
if c.out == nil {
return 0, io.ErrClosedPipe
}
return c.out.Write(p)
}
func (c *console) Println(a ...any) {
if c.out == nil {
return
}
fmt.Fprintln(c.out, a...)
}
func (c *console) Printf(format string, a ...any) {
if c.out == nil {
return
}
fmt.Fprintf(c.out, format, a...)
}
func (c *console) Info(message string) {
c.Println(c.style(ansiCyan, symInfo) + " " + message)
}
func (c *console) Success(message string) {
c.Println(c.style(ansiGreen, symCheck) + " " + message)
}
func (c *console) Error(message string) {
w := c.err
if w == nil {
w = c.out
}
if w == nil {
return
}
fmt.Fprintln(w, c.style(ansiRed, symCross)+" "+message)
}
func (c *console) Warning(message string) {
c.Println(c.style(ansiYellow, symWarn) + " " + message)
}
func (c *console) style(code, s string) string {
if !c.color {
return s
}
return code + s + ansiReset
}
func (c *console) dim(s string) string {
return c.style(ansiDim, s)
}
func boolPtr(v bool) *bool { return &v }

157
bonfire/output_test.go Normal file
View File

@@ -0,0 +1,157 @@
package bonfire
import (
"bytes"
"errors"
"strings"
"testing"
)
func TestNonTTYSpinner(t *testing.T) {
var buf bytes.Buffer
out := NewOutput(strings.NewReader(""), &buf, &buf)
if err := out.Spinner("building", func() error { return nil }); err != nil {
t.Fatal(err)
}
if got := buf.String(); got != "[...] building\n" {
t.Fatalf("spinner = %q", got)
}
}
func TestNonTTYProgress(t *testing.T) {
var buf bytes.Buffer
out := NewOutput(strings.NewReader(""), &buf, &buf)
if err := out.Progress(10, func(p Progress) error {
for i := 0; i < 10; i++ {
p.Advance(1)
}
return nil
}); err != nil {
t.Fatal(err)
}
got := buf.String()
for _, want := range []string{"[1/10] 10%", "[5/10] 50%", "[10/10] 100%"} {
if !strings.Contains(got, want) {
t.Fatalf("missing %q in %q", want, got)
}
}
if strings.Contains(got, "\x1b[") {
t.Fatalf("non-tty progress has ansi: %q", got)
}
}
func TestNonTTYTable(t *testing.T) {
var buf bytes.Buffer
out := NewOutput(strings.NewReader(""), &buf, &buf)
out.Table([]string{"plugin", "status"}, [][]string{{"golem15.blog", "active"}})
got := buf.String()
if got != "plugin\tstatus\ngolem15.blog\tactive\n" {
t.Fatalf("table = %q", got)
}
}
func TestColorPolicy(t *testing.T) {
t.Run("NO_COLOR", func(t *testing.T) {
var buf bytes.Buffer
out := newConsole(strings.NewReader(""), &buf, &buf, func(k string) string {
if k == "NO_COLOR" {
return "1"
}
if k == "FORCE_COLOR" {
return "1"
}
return ""
}, boolPtr(true), boolPtr(true))
out.Success("ok")
if strings.Contains(buf.String(), "\x1b[") {
t.Fatalf("NO_COLOR leaked ansi: %q", buf.String())
}
})
t.Run("TERM=dumb", func(t *testing.T) {
var buf bytes.Buffer
out := newConsole(strings.NewReader(""), &buf, &buf, func(k string) string {
if k == "TERM" {
return "dumb"
}
if k == "FORCE_COLOR" {
return "1"
}
return ""
}, boolPtr(true), boolPtr(true))
out.Info("hi")
if strings.Contains(buf.String(), "\x1b[") {
t.Fatalf("TERM=dumb leaked ansi: %q", buf.String())
}
})
t.Run("FORCE_COLOR", func(t *testing.T) {
var buf bytes.Buffer
out := newConsole(strings.NewReader(""), &buf, &buf, func(k string) string {
if k == "FORCE_COLOR" {
return "1"
}
return ""
}, boolPtr(false), boolPtr(false))
out.Success("ok")
if !strings.Contains(buf.String(), "\x1b[") {
t.Fatalf("FORCE_COLOR did not enable ansi: %q", buf.String())
}
})
t.Run("default non-tty", func(t *testing.T) {
var buf bytes.Buffer
out := NewOutput(strings.NewReader(""), &buf, &buf)
out.Success("ok")
if strings.Contains(buf.String(), "\x1b[") {
t.Fatalf("non-tty default has ansi: %q", buf.String())
}
})
}
func TestPluginCommandRequiresColon(t *testing.T) {
var buf bytes.Buffer
_, err := NewRoot("app", []Command{{Name: "hello", Description: "bad"}}, &buf)
if err == nil || !errors.Is(err, ErrCommandName) {
t.Fatalf("want ErrCommandName, got %v", err)
}
if !strings.Contains(err.Error(), "hello") {
t.Fatalf("error should name the command: %v", err)
}
}
func TestKernelAndNamespacedCommands(t *testing.T) {
var buf bytes.Buffer
root, err := NewRoot("summer", []Command{
{Name: "build", Description: "Build the app"},
{Name: "dev", Description: "Watch and rebuild"},
{Name: "make:plugin", Description: "Scaffold a plugin from a vendor.plugin id"},
{Name: "greeter:hello", Description: "Say hello"},
}, &buf)
if err != nil {
t.Fatal(err)
}
root.SetArgs([]string{"make:plugin", "--help"})
if err := root.Execute(); err != nil {
t.Fatal(err)
}
if !strings.Contains(buf.String(), "vendor.plugin") && !strings.Contains(buf.String(), "make:plugin") {
t.Fatalf("help = %q", buf.String())
}
}
func TestHelpUsesSharedAdapter(t *testing.T) {
var buf bytes.Buffer
root, err := NewRoot("hello", []Command{{
Name: "greeter:hello",
Description: "Print the configured application name",
}}, &buf)
if err != nil {
t.Fatal(err)
}
root.SetArgs([]string{"greeter:hello", "--help"})
if err := root.Execute(); err != nil {
t.Fatal(err)
}
got := buf.String()
if !strings.Contains(got, "greeter:hello") || !strings.Contains(got, "Print the configured application name") {
t.Fatalf("help = %q", got)
}
}

133
bonfire/prompts.go Normal file
View File

@@ -0,0 +1,133 @@
package bonfire
import (
"bufio"
"fmt"
"io"
"os"
"strconv"
"strings"
"golang.org/x/term"
)
func (c *console) Ask(question, def string) (string, error) {
c.writeQuestion(question, def)
line, err := c.readLine()
if err != nil {
if err == io.EOF {
return def, nil
}
return "", err
}
if line == "" {
return def, nil
}
return line, nil
}
func (c *console) Confirm(question string, def bool) (bool, error) {
if !c.interactive {
return def, nil
}
hint := "y/N"
if def {
hint = "Y/n"
}
line, err := c.Ask(question+" ["+hint+"]", "")
if err != nil {
return false, err
}
if line == "" {
return def, nil
}
switch strings.ToLower(line) {
case "y", "yes":
return true, nil
case "n", "no":
return false, nil
default:
return def, nil
}
}
func (c *console) Choice(question string, choices []string, def int) (string, error) {
if len(choices) == 0 {
return "", fmt.Errorf("bonfire: choice has no options")
}
if def < 0 || def >= len(choices) {
def = 0
}
c.Println(c.style(ansiCyan, symPrompt) + " " + question)
for i, choice := range choices {
mark := " "
if i == def {
mark = "*"
}
c.Printf(" %s %d) %s\n", mark, i+1, choice)
}
line, err := c.readLine()
if err != nil {
if err == io.EOF {
return choices[def], nil
}
return "", err
}
if line == "" {
return choices[def], nil
}
n, convErr := strconv.Atoi(line)
if convErr != nil || n < 1 || n > len(choices) {
return choices[def], nil
}
return choices[n-1], nil
}
// Secret reads a hidden password on a TTY via term.ReadPassword.
// When stdin is not a terminal it reads a plain line and never logs the value.
func (c *console) Secret(question string) (string, error) {
c.writeQuestion(question, "")
if f, ok := c.in.(*os.File); ok && term.IsTerminal(int(f.Fd())) {
b, err := term.ReadPassword(int(f.Fd()))
c.Println()
if err != nil {
return "", err
}
return string(b), nil
}
line, err := c.readLine()
if err != nil {
if err == io.EOF {
return "", nil
}
return "", err
}
return line, nil
}
func (c *console) writeQuestion(question, def string) {
suffix := ""
if def != "" {
suffix = " " + c.dim("("+def+")")
}
c.Printf("%s %s%s %s ", c.style(ansiCyan, symPrompt), question, suffix, symArrow)
}
func (c *console) readLine() (string, error) {
if c.in == nil {
return "", io.EOF
}
reader, ok := c.in.(*bufio.Reader)
if !ok {
reader = bufio.NewReader(c.in)
}
line, err := reader.ReadString('\n')
line = strings.TrimRight(line, "\r\n")
if err != nil && err != io.EOF {
return "", err
}
if err == io.EOF && line == "" {
return "", io.EOF
}
return strings.TrimSpace(line), nil
}

92
bonfire/prompts_test.go Normal file
View File

@@ -0,0 +1,92 @@
package bonfire
import (
"bytes"
"io"
"strings"
"testing"
)
func TestConfirmUsesDefaultWhenNonTTY(t *testing.T) {
var buf bytes.Buffer
out := NewOutput(strings.NewReader("n\n"), &buf, &buf)
got, err := out.Confirm("continue?", true)
if err != nil {
t.Fatal(err)
}
if !got {
t.Fatal("non-tty confirm should use default true without reading stdin")
}
if buf.Len() != 0 {
t.Fatalf("confirm wrote %q", buf.String())
}
}
func TestAskReadsStdinLine(t *testing.T) {
var buf bytes.Buffer
out := NewOutput(strings.NewReader("alice\n"), &buf, &buf)
got, err := out.Ask("name", "bob")
if err != nil {
t.Fatal(err)
}
if got != "alice" {
t.Fatalf("ask = %q", got)
}
}
func TestAskEOFUsesDefault(t *testing.T) {
var buf bytes.Buffer
out := NewOutput(strings.NewReader(""), &buf, &buf)
got, err := out.Ask("name", "bob")
if err != nil {
t.Fatal(err)
}
if got != "bob" {
t.Fatalf("ask eof = %q", got)
}
}
func TestChoiceReadsStdinLine(t *testing.T) {
var buf bytes.Buffer
out := NewOutput(strings.NewReader("2\n"), &buf, &buf)
got, err := out.Choice("pick", []string{"a", "b", "c"}, 0)
if err != nil {
t.Fatal(err)
}
if got != "b" {
t.Fatalf("choice = %q", got)
}
}
func TestSecretReadsPlainStdinLine(t *testing.T) {
var buf bytes.Buffer
out := NewOutput(strings.NewReader("s3cret\n"), &buf, &buf)
got, err := out.Secret("token")
if err != nil {
t.Fatal(err)
}
if got != "s3cret" {
t.Fatalf("secret = %q", got)
}
if strings.Contains(buf.String(), "s3cret") {
t.Fatal("secret value was written to output")
}
}
func TestClosedStdinDoesNotHang(t *testing.T) {
var buf bytes.Buffer
out := NewOutput(io.NopCloser(strings.NewReader("")), &buf, &buf)
if _, err := out.Ask("name", "def"); err != nil {
t.Fatal(err)
}
ok, err := out.Confirm("ok?", false)
if err != nil {
t.Fatal(err)
}
if ok {
t.Fatal("confirm default false")
}
if _, err := out.Secret("pw"); err != nil {
t.Fatal(err)
}
}

View File

@@ -1,6 +1,7 @@
package bonfire
import (
"fmt"
"io"
"os"
@@ -9,10 +10,22 @@ import (
// 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 {
func NewRoot(name string, commands []Command, out io.Writer) (*cobra.Command, error) {
return NewRootIO(name, commands, os.Stdin, out, out)
}
// NewRootIO is NewRoot with injected stdin/stdout/stderr for tests.
func NewRootIO(name string, commands []Command, in io.Reader, out, errW io.Writer) (*cobra.Command, error) {
if out == nil {
out = os.Stdout
}
if errW == nil {
errW = out
}
if in == nil {
in = os.Stdin
}
output := NewOutput(in, out, errW)
root := &cobra.Command{
Use: name,
SilenceUsage: true,
@@ -21,30 +34,32 @@ func NewRoot(name string, commands []Command, out io.Writer) *cobra.Command {
DisableDefaultCmd: true,
},
}
root.SetIn(in)
root.SetOut(out)
root.SetErr(out)
root.SetErr(errW)
for _, command := range commands {
root.AddCommand(wrap(command, out))
cmd, err := wrap(command, output)
if err != nil {
return nil, err
}
root.AddCommand(cmd)
}
return root
return root, nil
}
func wrap(c Command, out io.Writer) *cobra.Command {
func wrap(c Command, out Output) (*cobra.Command, error) {
if !validCommandName(c.Name) {
return nil, fmt.Errorf("%w: %q", ErrCommandName, c.Name)
}
cmd := &cobra.Command{
Use: c.Name,
Use: commandUsage(c),
Short: c.Description,
Args: cobra.ArbitraryArgs,
Args: positionalArgs(c.Args),
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})
return c.Run(cmd.Context(), cobraInput{cmd: cmd, spec: c, args: args}, out)
},
}
for _, flag := range c.Flags {
@@ -54,5 +69,5 @@ func wrap(c Command, out io.Writer) *cobra.Command {
}
cmd.Flags().String(flag.Name, flag.Default, flag.Description)
}
return cmd
return cmd, nil
}

210
bonfire/widgets.go Normal file
View File

@@ -0,0 +1,210 @@
package bonfire
import (
"fmt"
"strings"
"sync"
"time"
"unicode/utf8"
)
var spinnerFrames = []rune{'⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'}
const (
boxTopLeft = "┌"
boxTopRight = "┐"
boxBottomLeft = "└"
boxBottomRight = "┘"
boxHorizontal = "─"
boxVertical = "│"
boxTeeRight = "├"
boxTeeLeft = "┤"
boxTeeDown = "┬"
boxTeeUp = "┴"
boxCross = "┼"
)
func (c *console) Spinner(message string, fn func() error) error {
if fn == nil {
fn = func() error { return nil }
}
if !c.tty {
c.Printf("[...] %s\n", message)
return fn()
}
done := make(chan struct{})
var wg sync.WaitGroup
wg.Add(1)
go func() {
defer wg.Done()
i := 0
ticker := time.NewTicker(80 * time.Millisecond)
defer ticker.Stop()
c.Printf("\r%s %s", c.style(ansiCyan, string(spinnerFrames[0])), message)
for {
select {
case <-done:
return
case <-ticker.C:
i++
frame := spinnerFrames[i%len(spinnerFrames)]
c.Printf("\r%s %s", c.style(ansiCyan, string(frame)), message)
}
}
}()
err := fn()
close(done)
wg.Wait()
if err != nil {
c.Printf("\r%s %s\n", c.style(ansiRed, symCross), message)
return err
}
c.Printf("\r%s %s\n", c.style(ansiGreen, symCheck), message)
return nil
}
func (c *console) Progress(total int, fn func(Progress) error) error {
if total < 1 {
total = 1
}
bar := &progressBar{c: c, total: total, lastPct: -1}
if fn == nil {
return nil
}
err := fn(bar)
if c.tty && bar.current >= bar.total {
c.Println()
}
return err
}
type progressBar struct {
c *console
total int
current int
lastPct int
}
func (p *progressBar) Advance(step int) {
if step < 1 {
step = 1
}
p.current += step
if p.current > p.total {
p.current = p.total
}
pct := 0
if p.total > 0 {
pct = p.current * 100 / p.total
}
if p.c.tty {
p.renderTTY(pct)
return
}
if p.lastPct >= 0 && pct/10 == p.lastPct/10 && pct != 100 {
return
}
if pct == 0 && p.lastPct < 0 {
p.lastPct = 0
return
}
if pct%10 == 0 || pct == 100 {
p.c.Printf("[%d/%d] %d%%\n", p.current, p.total, pct)
p.lastPct = pct
}
}
func (p *progressBar) renderTTY(pct int) {
width := 40
filled := 0
if p.total > 0 {
filled = p.current * width / p.total
}
if filled > width {
filled = width
}
bar := strings.Repeat("█", filled) + strings.Repeat(" ", width-filled)
color := progressColor(pct)
p.c.Printf("\r %s %5.1f%% %d/%d", p.c.style(color, bar), float64(pct), p.current, p.total)
}
func progressColor(pct int) string {
switch {
case pct >= 100:
return "\x1b[38;5;34m"
case pct >= 50:
return "\x1b[38;5;36m"
default:
return "\x1b[38;5;37m"
}
}
func (c *console) Table(headers []string, rows [][]string) {
if !c.tty {
c.Println(strings.Join(headers, "\t"))
for _, row := range rows {
c.Println(strings.Join(padRow(row, len(headers)), "\t"))
}
return
}
cols := len(headers)
all := make([][]string, 0, 1+len(rows))
all = append(all, headers)
for _, row := range rows {
all = append(all, padRow(row, cols))
}
widths := make([]int, cols)
for _, row := range all {
for i, cell := range row {
if n := utf8.RuneCountInString(cell); n > widths[i] {
widths[i] = n
}
}
}
c.Println(boxLine(widths, boxTopLeft, boxTeeDown, boxTopRight))
c.Println(boxRow(headers, widths, true, c))
c.Println(boxLine(widths, boxTeeRight, boxCross, boxTeeLeft))
for _, row := range rows {
c.Println(boxRow(padRow(row, cols), widths, false, c))
}
c.Println(boxLine(widths, boxBottomLeft, boxTeeUp, boxBottomRight))
}
func padRow(row []string, n int) []string {
out := make([]string, n)
for i := 0; i < n; i++ {
if i < len(row) {
out[i] = row[i]
}
}
return out
}
func boxLine(widths []int, left, mid, right string) string {
var b strings.Builder
b.WriteString(left)
for i, w := range widths {
if i > 0 {
b.WriteString(mid)
}
b.WriteString(strings.Repeat(boxHorizontal, w+2))
}
b.WriteString(right)
return b.String()
}
func boxRow(cells []string, widths []int, header bool, c *console) string {
var b strings.Builder
b.WriteString(boxVertical)
for i, cell := range cells {
pad := widths[i] - utf8.RuneCountInString(cell)
text := cell
if header {
text = c.style("\x1b[1m", cell)
}
fmt.Fprintf(&b, " %s%s %s", text, strings.Repeat(" ", pad), boxVertical)
}
return b.String()
}

View File

@@ -7,10 +7,15 @@ import (
"git.golem15.com/golem15/summercms/bonfire"
"git.golem15.com/golem15/summercms/internal/build"
"git.golem15.com/golem15/summercms/internal/dev"
)
func main() {
root := bonfire.NewRoot("summer", []bonfire.Command{buildCommand()}, os.Stdout)
root, err := bonfire.NewRoot("summer", toolCommands(), os.Stdout)
if err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
root.SetArgs(os.Args[1:])
if err := root.Execute(); err != nil {
fmt.Fprintln(os.Stderr, err)
@@ -18,6 +23,15 @@ func main() {
}
}
func toolCommands() []bonfire.Command {
return []bonfire.Command{
buildCommand(),
makePluginCommand(),
addPluginCommand(),
devCommand(),
}
}
func buildCommand() bonfire.Command {
return bonfire.Command{
Name: "build",
@@ -31,3 +45,72 @@ func buildCommand() bonfire.Command {
},
}
}
func makePluginCommand() bonfire.Command {
return bonfire.Command{
Name: "make:plugin",
Description: "Scaffold a compiling plugin module from a vendor.plugin id",
Args: []bonfire.Arg{{
Name: "id",
Description: "Plugin ID in vendor.plugin form",
Required: true,
}},
Run: func(ctx context.Context, in bonfire.Input, out bonfire.Output) error {
id, ok := in.Argument("id")
if !ok || id == "" {
return fmt.Errorf("make:plugin requires a vendor.plugin id")
}
dir, err := os.Getwd()
if err != nil {
return err
}
created, err := build.MakePlugin(ctx, dir, id)
if err != nil {
return err
}
out.Printf("created %s\n", created)
return nil
},
}
}
func devCommand() bonfire.Command {
return bonfire.Command{
Name: "dev",
Description: "Watch sources and rebuild 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 dev.Watch(ctx, dir, out)
},
}
}
func addPluginCommand() bonfire.Command {
return bonfire.Command{
Name: "plugin:add",
Description: "Register a local plugin module in the app manifest and workspace",
Args: []bonfire.Arg{{
Name: "dir",
Description: "Local plugin directory",
Required: true,
}},
Run: func(ctx context.Context, in bonfire.Input, out bonfire.Output) error {
pluginDir, ok := in.Argument("dir")
if !ok || pluginDir == "" {
return fmt.Errorf("plugin:add requires a local plugin directory")
}
dir, err := os.Getwd()
if err != nil {
return err
}
if err := build.AddPlugin(ctx, dir, pluginDir); err != nil {
return err
}
out.Printf("added %s\n", pluginDir)
return nil
},
}
}

View File

@@ -1,15 +1,44 @@
package main
import (
"bytes"
"go/parser"
"go/token"
"os"
"path/filepath"
"slices"
"strconv"
"strings"
"testing"
"git.golem15.com/golem15/summercms/bonfire"
)
func TestToolCommandNames(t *testing.T) {
var names []string
for _, c := range toolCommands() {
names = append(names, c.Name)
}
for _, want := range []string{"build", "make:plugin", "plugin:add", "dev"} {
if !slices.Contains(names, want) {
t.Fatalf("missing %s in %v", want, names)
}
}
var buf bytes.Buffer
root, err := bonfire.NewRoot("summer", toolCommands(), &buf)
if err != nil {
t.Fatal(err)
}
root.SetArgs([]string{"make:plugin", "--help"})
if err := root.Execute(); err != nil {
t.Fatal(err)
}
if !strings.Contains(buf.String(), "vendor.plugin") {
t.Fatalf("make:plugin help missing argument text:\n%s", buf.String())
}
}
func TestToolDoesNotImportExamplePlugins(t *testing.T) {
roots := []string{".", filepath.Join("..", "..", "internal", "build")}
fset := token.NewFileSet()

View File

@@ -18,17 +18,13 @@ import (
func TestGreeterHelloPrintsLayeredConfig(t *testing.T) {
got := runHello(t, nil)
want := "name=hello-app posts_per_page=10 debug=false extra=hello-from-optional events=ok collected=greeter handled=true"
if got != want {
t.Fatalf("output = %q, want %q", got, want)
}
assertGreeting(t, got, want)
}
func TestGreeterHelloSeesDevelopmentOverlay(t *testing.T) {
got := runHello(t, map[string]string{"SUMMER_ENV": "development"})
want := "name=hello-dev posts_per_page=10 debug=true extra=hello-from-optional events=ok collected=greeter handled=true"
if got != want {
t.Fatalf("output = %q, want %q", got, want)
}
assertGreeting(t, got, want)
}
func TestGreeterHelloSeesEnvVarOverrideOfPluginDefault(t *testing.T) {
@@ -36,9 +32,7 @@ func TestGreeterHelloSeesEnvVarOverrideOfPluginDefault(t *testing.T) {
"SUMMER_GOLEM15__HELLO__POSTS_PER_PAGE": "25",
})
want := "name=hello-app posts_per_page=25 debug=false extra=hello-from-optional events=ok collected=greeter handled=true"
if got != want {
t.Fatalf("output = %q, want %q", got, want)
}
assertGreeting(t, got, want)
}
func runHello(t *testing.T, env map[string]string) string {
@@ -56,9 +50,7 @@ func runHello(t *testing.T, env map[string]string) string {
func TestGreeterRunsWithoutOptionalPlugin(t *testing.T) {
got := runHelloIDs(t, []string{"golem15.hello", "golem15.greeter"}, nil)
want := "name=hello-app posts_per_page=10 debug=false extra= events=ok collected=greeter handled=true"
if got != want {
t.Fatalf("output = %q, want %q", got, want)
}
assertGreeting(t, got, want)
}
func runHelloIDs(t *testing.T, ids []string, env map[string]string) string {
@@ -82,7 +74,10 @@ func runHelloIDs(t *testing.T, ids []string, env map[string]string) string {
}
}
var buf bytes.Buffer
root := bonfire.NewRoot("hello", commands, &buf)
root, err := bonfire.NewRoot("hello", commands, &buf)
if err != nil {
t.Fatalf("NewRoot: %v", err)
}
root.SetArgs([]string{"greeter:hello"})
if err := root.Execute(); err != nil {
t.Fatalf("execute: %v", err)
@@ -104,9 +99,27 @@ func TestBuiltBinaryGreeterHello(t *testing.T) {
if err != nil {
t.Fatalf("bin/hello greeter:hello: %v\n%s", err, out)
}
if got := strings.TrimSpace(string(out)); got != "name=hello-app posts_per_page=10 debug=false extra=hello-from-optional events=ok collected=greeter handled=true" {
t.Fatalf("output = %q, want layered production config with optional extra and events", got)
got := strings.TrimSpace(string(out))
want := "name=hello-app posts_per_page=10 debug=false extra=hello-from-optional events=ok collected=greeter handled=true"
assertGreeting(t, got, want)
}
func assertGreeting(t *testing.T, got, want string) {
t.Helper()
if !strings.Contains(got, "plugin\tstatus") || !strings.Contains(got, "golem15.greeter\tactive") {
t.Fatalf("missing non-tty table:\n%s", got)
}
if lastLine(got) != want {
t.Fatalf("greeting = %q, want %q\nfull:\n%s", lastLine(got), want, got)
}
}
func lastLine(s string) string {
s = strings.TrimSpace(s)
if i := strings.LastIndex(s, "\n"); i >= 0 {
return s[i+1:]
}
return s
}
func runSummerBuild(t *testing.T) string {

View File

@@ -37,7 +37,10 @@ func run(args []string, out io.Writer) error {
commands = append(commands, hasCommands.Commands()...)
}
}
root := bonfire.NewRoot("hello", commands, out)
root, err := bonfire.NewRoot("hello", commands, out)
if err != nil {
return err
}
root.SetArgs(args)
return root.Execute()
}

View File

@@ -82,6 +82,10 @@ func (p *Plugin) Commands() []bonfire.Command {
}
handled = ok
}
out.Table([]string{"plugin", "status"}, [][]string{{"golem15.greeter", "active"}})
if _, err := out.Confirm("show greeting?", true); err != nil {
return err
}
out.Printf("name=%s posts_per_page=%d debug=%t extra=%s events=%s collected=%s handled=%t\n", name, posts, debug, p.extra, events, collected, handled)
return nil
},

View File

@@ -3,8 +3,6 @@ package greeter
import (
"bytes"
"context"
"fmt"
"io"
"os"
"strings"
"testing"
@@ -15,17 +13,6 @@ import (
"git.golem15.com/golem15/summercms/pact"
)
type testOut struct{ bytes.Buffer }
func (o *testOut) Write(p []byte) (int, error) { return o.Buffer.Write(p) }
func (o *testOut) Println(a ...any) { fmt.Fprintln(&o.Buffer, a...) }
func (o *testOut) Printf(format string, a ...any) {
fmt.Fprintf(&o.Buffer, format, a...)
}
var _ bonfire.Output = (*testOut)(nil)
var _ io.Writer = (*testOut)(nil)
type extraMsg struct{ msg string }
func (e extraMsg) Message() string { return e.msg }
@@ -78,9 +65,10 @@ func runHelloCmd(t *testing.T, p *Plugin) string {
if len(cmds) == 0 {
t.Fatal("no commands")
}
var out testOut
if err := cmds[0].Run(context.Background(), nil, &out); err != nil {
var buf bytes.Buffer
out := bonfire.NewOutput(strings.NewReader(""), &buf, &buf)
if err := cmds[0].Run(context.Background(), nil, out); err != nil {
t.Fatalf("Run: %v", err)
}
return strings.TrimSpace(out.String())
return strings.TrimSpace(buf.String())
}

5
go.mod
View File

@@ -5,16 +5,17 @@ go 1.27.0
toolchain go1.27.0
require (
github.com/fsnotify/fsnotify v1.10.1
github.com/knadh/koanf/parsers/yaml v1.1.1
github.com/knadh/koanf/providers/confmap v1.0.1
github.com/knadh/koanf/providers/env/v2 v2.0.1
github.com/knadh/koanf/providers/file v1.2.1
github.com/knadh/koanf/v2 v2.3.6
github.com/spf13/cobra v1.10.2
golang.org/x/term v0.46.0
)
require (
github.com/fsnotify/fsnotify v1.9.0 // indirect
github.com/go-viper/mapstructure/v2 v2.4.0 // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/knadh/koanf/maps v0.1.2 // indirect
@@ -22,5 +23,5 @@ require (
github.com/mitchellh/reflectwalk v1.0.2 // indirect
github.com/spf13/pflag v1.0.9 // indirect
go.yaml.in/yaml/v3 v3.0.4 // indirect
golang.org/x/sys v0.32.0 // indirect
golang.org/x/sys v0.48.0 // indirect
)

10
go.sum
View File

@@ -1,8 +1,8 @@
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k=
github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
github.com/fsnotify/fsnotify v1.10.1 h1:b0/UzAf9yR5rhf3RPm9gf3ehBPpf0oZKIjtpKrx59Ho=
github.com/fsnotify/fsnotify v1.10.1/go.mod h1:TLheqan6HD6GBK6PrDWyDPBaEV8LspOxvPSjC+bVfgo=
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=
@@ -38,8 +38,10 @@ github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcU
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
golang.org/x/sys v0.32.0 h1:s77OFDvIQeibCmezSnk/q6iAfkdiQaJi4VzroCFrN20=
golang.org/x/sys v0.32.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
golang.org/x/sys v0.48.0 h1:bbX/i/6MgT9BVLM9RT1thmxL04yeTAhbEz4SyadbXoo=
golang.org/x/sys v0.48.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og=
golang.org/x/term v0.46.0 h1:3+OXuTbaKDgwk8jTi3aSLHRlmWqHEUDUtxnbFigO4YE=
golang.org/x/term v0.46.0/go.mod h1:+K02xbkittuwc0Am4abfA3Fc+XRGXkvBXNO88NCXPoc=
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=

View File

@@ -13,80 +13,8 @@ import (
"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 == "" {
@@ -119,6 +47,7 @@ func App(ctx context.Context, appDir string, out io.Writer) error {
cmd.Dir = appDir
cmd.Stdout = out
cmd.Stderr = out
applyGoWork(cmd, appDir)
start := time.Now()
if err := cmd.Run(); err != nil {
return fmt.Errorf("build: go build: %w", err)
@@ -181,7 +110,10 @@ func generateMain(m Manifest) ([]byte, error) {
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))
fmt.Fprintf(&b, "\troot, err := bonfire.NewRoot(%s, commands, out)\n", strconv.Quote(m.Binary))
b.WriteString("\tif err != nil {\n")
b.WriteString("\t\treturn err\n")
b.WriteString("\t}\n")
b.WriteString("\troot.SetArgs(args)\n")
b.WriteString("\treturn root.Execute()\n")
b.WriteString("}\n")
@@ -229,80 +161,3 @@ func writeIfChanged(path string, content []byte) error {
}
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

@@ -3,6 +3,7 @@ package build
import (
"bytes"
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
@@ -121,6 +122,296 @@ func TestWriteIfChangedSkipsIdenticalBytes(t *testing.T) {
}
}
func TestMakePluginRejectsInvalidID(t *testing.T) {
dir := t.TempDir()
write(t, filepath.Join(dir, "summer.yaml"), "module: example.com/app\nbinary: app\nplugins:\n")
write(t, filepath.Join(dir, "go.mod"), "module example.com/app\n\ngo 1.27.0\n")
for _, id := range []string{"Golem15.Demo", "golem15", "golem15.demo/../tmp", "../evil.plugin"} {
if _, err := MakePlugin(t.Context(), dir, id); err == nil {
t.Fatalf("id %q: want error", id)
}
}
}
func TestMakeAndAddPluginOnHelloCopy(t *testing.T) {
dir := copyHelloApp(t)
ctx := t.Context()
pluginDir, err := MakePlugin(ctx, dir, "golem15.demo")
if err != nil {
t.Fatal(err)
}
wantDir := filepath.Join(dir, "plugins", "demo")
if pluginDir != wantDir {
t.Fatalf("plugin dir = %s, want %s", pluginDir, wantDir)
}
assertScaffoldFiles(t, pluginDir)
if err := AddPlugin(ctx, dir, filepath.Join("plugins", "demo")); err != nil {
t.Fatal(err)
}
if err := AddPlugin(ctx, dir, filepath.Join("plugins", "demo")); err != nil {
t.Fatalf("repeat plugin:add: %v", err)
}
m, err := LoadManifest(filepath.Join(dir, "summer.yaml"))
if err != nil {
t.Fatal(err)
}
if len(m.Plugins) != 4 {
t.Fatalf("plugins = %+v", m.Plugins)
}
last := m.Plugins[len(m.Plugins)-1]
if last.ID != "golem15.demo" {
t.Fatalf("last plugin id = %q", last.ID)
}
if last.Module != m.Module+"/plugins/demo" {
t.Fatalf("last plugin module = %q", last.Module)
}
demoCount := 0
for _, p := range m.Plugins {
if p.ID == "golem15.demo" {
demoCount++
}
}
if demoCount != 1 {
t.Fatalf("golem15.demo count = %d", demoCount)
}
work, err := os.ReadFile(filepath.Join(dir, "go.work"))
if err != nil {
t.Fatal(err)
}
if !bytes.Contains(work, []byte("plugins/demo")) {
t.Fatalf("go.work missing plugins/demo:\n%s", work)
}
appMod, err := os.ReadFile(filepath.Join(dir, "go.mod"))
if err != nil {
t.Fatal(err)
}
if !bytes.Contains(appMod, []byte(last.Module)) {
t.Fatalf("app go.mod missing %s:\n%s", last.Module, appMod)
}
var first, second bytes.Buffer
if err := App(ctx, dir, &first); err != nil {
t.Fatalf("first build: %v\n%s", err, first.String())
}
if !strings.Contains(first.String(), "built hello in ") {
t.Fatalf("missing elapsed build time:\n%s", first.String())
}
main1, err := os.ReadFile(filepath.Join(dir, "main.go"))
if err != nil {
t.Fatal(err)
}
gen1, err := os.ReadFile(filepath.Join(dir, "plugins.gen.go"))
if err != nil {
t.Fatal(err)
}
if !bytes.Contains(gen1, []byte(last.Module)) {
t.Fatalf("plugins.gen.go missing %s:\n%s", last.Module, gen1)
}
if !bytes.Contains(gen1, []byte(`"golem15.demo"`)) {
t.Fatalf("plugins.gen.go missing id:\n%s", gen1)
}
if err := App(ctx, dir, &second); err != nil {
t.Fatalf("second build: %v\n%s", err, second.String())
}
main2, err := os.ReadFile(filepath.Join(dir, "main.go"))
if err != nil {
t.Fatal(err)
}
gen2, err := os.ReadFile(filepath.Join(dir, "plugins.gen.go"))
if err != nil {
t.Fatal(err)
}
if !bytes.Equal(main1, main2) {
t.Fatal("main.go is not byte-stable")
}
if !bytes.Equal(gen1, gen2) {
t.Fatal("plugins.gen.go is not byte-stable")
}
}
func TestAddPluginRejectsConflictingID(t *testing.T) {
dir := copyHelloApp(t)
ctx := t.Context()
if _, err := MakePlugin(ctx, dir, "golem15.demo"); err != nil {
t.Fatal(err)
}
src, err := os.ReadFile(filepath.Join(dir, "plugins", "demo", "plugin.go"))
if err != nil {
t.Fatal(err)
}
patched := bytes.ReplaceAll(src, []byte(`return "golem15.demo"`), []byte(`return "golem15.hello"`))
if err := os.WriteFile(filepath.Join(dir, "plugins", "demo", "plugin.go"), patched, 0o644); err != nil {
t.Fatal(err)
}
err = AddPlugin(ctx, dir, filepath.Join(dir, "plugins", "demo"))
if err == nil || !strings.Contains(err.Error(), "golem15.hello") {
t.Fatalf("want conflicting id error, got %v", err)
}
}
func TestAddPluginRejectsPathTraversal(t *testing.T) {
dir := copyHelloApp(t)
outside := t.TempDir()
if err := AddPlugin(t.Context(), dir, outside); err == nil {
t.Fatal("want outside-root error")
}
}
func TestParseManifestRejectsUppercaseID(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "bad.yaml")
write(t, path, "module: example.com/app\nbinary: app\nplugins:\n - id: Golem15.Demo\n module: example.com/one\n")
if _, err := LoadManifest(path); err == nil || !strings.Contains(err.Error(), "vendor.plugin") {
t.Fatalf("want id format error, got %v", err)
}
}
func assertScaffoldFiles(t *testing.T, pluginDir string) {
t.Helper()
if _, err := os.Stat(filepath.Join(pluginDir, "go.mod")); err != nil {
t.Fatal(err)
}
if _, err := os.Stat(filepath.Join(pluginDir, "plugin.go")); err != nil {
t.Fatal(err)
}
if _, err := os.Stat(filepath.Join(pluginDir, "config")); err != nil {
t.Fatal(err)
}
body, err := os.ReadFile(filepath.Join(pluginDir, "plugin.go"))
if err != nil {
t.Fatal(err)
}
for _, want := range []string{
"func (p *Plugin) ID()",
"func (p *Plugin) Requires()",
"func (p *Plugin) Register",
"func (p *Plugin) Boot",
"party.Register(&Plugin{})",
`return "golem15.demo"`,
} {
if !bytes.Contains(body, []byte(want)) {
t.Fatalf("plugin.go missing %s:\n%s", want, body)
}
}
mod, err := os.ReadFile(filepath.Join(pluginDir, "go.mod"))
if err != nil {
t.Fatal(err)
}
if !bytes.Contains(mod, []byte("toolchain go1.27.0")) {
t.Fatalf("plugin go.mod missing toolchain:\n%s", mod)
}
if !bytes.Contains(mod, []byte("/plugins/demo")) {
t.Fatalf("plugin go.mod missing module path:\n%s", mod)
}
var extra []string
if err := filepath.WalkDir(pluginDir, func(path string, d os.DirEntry, err error) error {
if err != nil {
return err
}
if d.IsDir() {
return nil
}
rel, err := filepath.Rel(pluginDir, path)
if err != nil {
return err
}
switch rel {
case "go.mod", "go.sum", "plugin.go", filepath.Join("config", ".gitkeep"):
return nil
default:
extra = append(extra, rel)
return nil
}
}); err != nil {
t.Fatal(err)
}
if len(extra) > 0 {
t.Fatalf("unexpected scaffold files: %v", extra)
}
cmd := execGo(t, pluginDir, "build", "-o", filepath.Join(pluginDir, "demo.bin"), ".")
if out, err := cmd.CombinedOutput(); err != nil {
t.Fatalf("plugin does not compile: %v\n%s", err, out)
}
}
func copyHelloApp(t *testing.T) string {
t.Helper()
src, err := filepath.Abs(filepath.Join("..", "..", "examples", "hello"))
if err != nil {
t.Fatal(err)
}
framework, err := filepath.Abs(filepath.Join("..", ".."))
if err != nil {
t.Fatal(err)
}
dst := t.TempDir()
if err := filepath.WalkDir(src, func(path string, d os.DirEntry, err error) error {
if err != nil {
return err
}
rel, err := filepath.Rel(src, path)
if err != nil {
return err
}
if rel == "bin" || strings.HasPrefix(rel, "bin"+string(os.PathSeparator)) {
if d.IsDir() {
return filepath.SkipDir
}
return nil
}
target := filepath.Join(dst, rel)
if d.IsDir() {
return os.MkdirAll(target, 0o755)
}
data, err := os.ReadFile(path)
if err != nil {
return err
}
if filepath.Base(path) == "go.mod" {
data = rewriteFrameworkReplace(data, framework)
}
if err := os.MkdirAll(filepath.Dir(target), 0o755); err != nil {
return err
}
return os.WriteFile(target, data, 0o644)
}); err != nil {
t.Fatal(err)
}
return dst
}
func rewriteFrameworkReplace(data []byte, framework string) []byte {
lines := strings.Split(string(data), "\n")
for i, line := range lines {
trimmed := strings.TrimSpace(line)
switch {
case strings.HasPrefix(trimmed, "replace "+frameworkModule+" =>"):
indent := line[:len(line)-len(strings.TrimLeft(line, " \t"))]
lines[i] = indent + "replace " + frameworkModule + " => " + framework
case strings.HasPrefix(trimmed, frameworkModule+" =>"):
indent := line[:len(line)-len(strings.TrimLeft(line, " \t"))]
lines[i] = indent + frameworkModule + " => " + framework
}
}
return []byte(strings.Join(lines, "\n"))
}
func execGo(t *testing.T, dir string, args ...string) *exec.Cmd {
t.Helper()
cmd := exec.CommandContext(t.Context(), "go", args...)
cmd.Dir = dir
applyGoWork(cmd, dir)
return cmd
}
func write(t *testing.T, path, body string) {
t.Helper()
if err := os.WriteFile(path, []byte(body), 0o644); err != nil {

219
internal/build/manifest.go Normal file
View File

@@ -0,0 +1,219 @@
package build
import (
"fmt"
"os"
"path/filepath"
"regexp"
"strings"
"unicode"
"github.com/knadh/koanf/parsers/yaml"
)
const manifestName = "summer.yaml"
var pluginIDRe = regexp.MustCompile(`^[a-z][a-z0-9]*\.[a-z][a-z0-9]*$`)
// 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 err := ValidatePluginID(p.ID); err != nil {
return Manifest{}, fmt.Errorf("build: plugins[%d]: %w", i, err)
}
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
}
// SaveManifest writes summer.yaml with plugins in slice order.
func SaveManifest(path string, m Manifest) error {
if err := validateModulePath(m.Module); err != nil {
return fmt.Errorf("build: module: %w", err)
}
if err := validateBinary(m.Binary); err != nil {
return fmt.Errorf("build: binary: %w", err)
}
var b strings.Builder
fmt.Fprintf(&b, "module: %s\n", m.Module)
fmt.Fprintf(&b, "binary: %s\n", m.Binary)
b.WriteString("plugins:\n")
for i, p := range m.Plugins {
if err := ValidatePluginID(p.ID); err != nil {
return fmt.Errorf("build: plugins[%d]: %w", i, err)
}
if err := validateModulePath(p.Module); err != nil {
return fmt.Errorf("build: plugins[%d] module: %w", i, err)
}
fmt.Fprintf(&b, " - id: %s\n", p.ID)
fmt.Fprintf(&b, " module: %s\n", p.Module)
}
if err := os.WriteFile(path, []byte(b.String()), 0o644); err != nil {
return fmt.Errorf("build: write %s: %w", path, err)
}
return nil
}
// ValidatePluginID requires a lowercase vendor.plugin identifier.
func ValidatePluginID(id string) error {
if id == "" {
return fmt.Errorf("plugin id is empty")
}
if strings.ContainsAny(id, " \t\r\n\"'`/\\") {
return fmt.Errorf("plugin id %q contains invalid characters", id)
}
if !pluginIDRe.MatchString(id) {
return fmt.Errorf("plugin id %q must be lowercase vendor.plugin", id)
}
return nil
}
func findAppDir(start string) (string, error) {
if start == "" {
return "", fmt.Errorf("build: app directory is empty")
}
dir, err := filepath.Abs(start)
if err != nil {
return "", fmt.Errorf("build: resolve app directory: %w", err)
}
for {
if _, err := os.Stat(filepath.Join(dir, manifestName)); err == nil {
return dir, nil
}
parent := filepath.Dir(dir)
if parent == dir {
return "", fmt.Errorf("build: no %s found from %s", manifestName, start)
}
dir = parent
}
}
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
}
}

476
internal/build/scaffold.go Normal file
View File

@@ -0,0 +1,476 @@
package build
import (
"bytes"
"context"
"fmt"
"go/ast"
"go/format"
"go/parser"
"go/token"
"os"
"os/exec"
"path/filepath"
"strconv"
"strings"
)
const (
frameworkModule = "git.golem15.com/golem15/summercms"
goVersionLine = "go 1.27.0"
toolchainLine = "toolchain go1.27.0"
)
// MakePlugin scaffolds a compiling plugin module under plugins/<name>.
func MakePlugin(ctx context.Context, startDir, id string) (string, error) {
if err := ValidatePluginID(id); err != nil {
return "", fmt.Errorf("build: %w", err)
}
appDir, err := findAppDir(startDir)
if err != nil {
return "", err
}
name := pluginName(id)
pluginDir := filepath.Join(appDir, "plugins", name)
if !underRoot(appDir, pluginDir) {
return "", fmt.Errorf("build: plugin path %q escapes the app root", pluginDir)
}
if _, err := os.Stat(pluginDir); err == nil {
return "", fmt.Errorf("build: plugin directory %s already exists", pluginDir)
} else if !os.IsNotExist(err) {
return "", fmt.Errorf("build: stat %s: %w", pluginDir, err)
}
m, err := LoadManifest(filepath.Join(appDir, manifestName))
if err != nil {
return "", err
}
modPath := m.Module + "/plugins/" + name
if err := validateModulePath(modPath); err != nil {
return "", fmt.Errorf("build: plugin module: %w", err)
}
if err := os.MkdirAll(filepath.Join(pluginDir, "config"), 0o755); err != nil {
return "", fmt.Errorf("build: create plugin dir: %w", err)
}
if err := os.WriteFile(filepath.Join(pluginDir, "config", ".gitkeep"), []byte{}, 0o644); err != nil {
return "", fmt.Errorf("build: write config placeholder: %w", err)
}
src, err := pluginGoSource(name, id)
if err != nil {
return "", err
}
if err := os.WriteFile(filepath.Join(pluginDir, "plugin.go"), src, 0o644); err != nil {
return "", fmt.Errorf("build: write plugin.go: %w", err)
}
replace, err := frameworkReplaceFor(pluginDir, appDir)
if err != nil {
return "", err
}
if err := os.WriteFile(filepath.Join(pluginDir, "go.mod"), pluginGoMod(modPath, replace), 0o644); err != nil {
return "", fmt.Errorf("build: write go.mod: %w", err)
}
if err := runGo(ctx, pluginDir, "mod", "tidy"); err != nil {
return "", err
}
if err := ensureToolchain(filepath.Join(pluginDir, "go.mod")); err != nil {
return "", err
}
return pluginDir, nil
}
// AddPlugin registers a local plugin module in summer.yaml, go.work and the app go.mod.
func AddPlugin(ctx context.Context, startDir, pluginDir string) error {
appDir, err := findAppDir(startDir)
if err != nil {
return err
}
if pluginDir == "" {
return fmt.Errorf("build: plugin directory is empty")
}
if !filepath.IsAbs(pluginDir) {
pluginDir = filepath.Join(startDir, pluginDir)
}
pluginDir, err = filepath.Abs(pluginDir)
if err != nil {
return fmt.Errorf("build: resolve plugin directory: %w", err)
}
if !underRoot(appDir, pluginDir) {
return fmt.Errorf("build: plugin directory %q is outside the app root", pluginDir)
}
modPath, err := readModulePath(filepath.Join(pluginDir, "go.mod"))
if err != nil {
return err
}
if err := validateModulePath(modPath); err != nil {
return fmt.Errorf("build: plugin module: %w", err)
}
id, err := pluginIDFromGo(filepath.Join(pluginDir, "plugin.go"))
if err != nil {
return err
}
if err := ValidatePluginID(id); err != nil {
return fmt.Errorf("build: %w", err)
}
manifestPath := filepath.Join(appDir, manifestName)
m, err := LoadManifest(manifestPath)
if err != nil {
return err
}
already := false
for _, p := range m.Plugins {
if p.ID == id && p.Module == modPath {
already = true
continue
}
if p.ID == id {
return fmt.Errorf("build: plugin id %q is already registered as %s", id, p.Module)
}
if p.Module == modPath {
return fmt.Errorf("build: plugin module %q is already registered as %s", modPath, p.ID)
}
}
if !already {
m.Plugins = append(m.Plugins, Plugin{ID: id, Module: modPath})
}
if err := editAppModule(ctx, appDir, pluginDir, modPath); err != nil {
return err
}
if err := ensureGoWork(ctx, appDir, m, pluginDir); err != nil {
return err
}
if already {
return nil
}
return SaveManifest(manifestPath, m)
}
func pluginName(id string) string {
_, name, _ := strings.Cut(id, ".")
return name
}
func pluginGoSource(pkg, id string) ([]byte, error) {
var b strings.Builder
b.WriteString("package " + pkg + "\n\n")
b.WriteString("import (\n")
b.WriteString("\t\"git.golem15.com/golem15/summercms/backpack\"\n")
b.WriteString("\t\"git.golem15.com/golem15/summercms/party\"\n")
b.WriteString(")\n\n")
fmt.Fprintf(&b, "// Plugin is the %s plugin.\n", id)
b.WriteString("type Plugin struct{}\n\n")
fmt.Fprintf(&b, "func (p *Plugin) ID() string { return %s }\n", strconv.Quote(id))
b.WriteString("func (p *Plugin) Requires() []string { return nil }\n\n")
b.WriteString("func (p *Plugin) Register(*backpack.App) error { return nil }\n")
b.WriteString("func (p *Plugin) Boot(*backpack.App) error { return nil }\n\n")
b.WriteString("func init() {\n")
b.WriteString("\tparty.Register(&Plugin{})\n")
b.WriteString("}\n")
formatted, err := format.Source([]byte(b.String()))
if err != nil {
return nil, fmt.Errorf("build: format plugin.go: %w", err)
}
return formatted, nil
}
func pluginGoMod(modPath, replace string) []byte {
var b strings.Builder
fmt.Fprintf(&b, "module %s\n\n", modPath)
b.WriteString(goVersionLine + "\n\n")
b.WriteString(toolchainLine + "\n\n")
fmt.Fprintf(&b, "require %s v0.0.0\n", frameworkModule)
if replace != "" {
fmt.Fprintf(&b, "\nreplace %s => %s\n", frameworkModule, replace)
}
return []byte(b.String())
}
func frameworkReplaceFor(pluginDir, appDir string) (string, error) {
_, replaces, err := parseGoMod(filepath.Join(appDir, "go.mod"))
if err != nil {
return "", err
}
appReplace, ok := replaces[frameworkModule]
if !ok || appReplace == "" {
return "", nil
}
target := appReplace
if filepath.IsAbs(appReplace) {
return filepath.ToSlash(appReplace), nil
}
target = filepath.Join(appDir, target)
target, err = filepath.Abs(target)
if err != nil {
return "", fmt.Errorf("build: plugin replace: %w", err)
}
rel, err := filepath.Rel(pluginDir, target)
if err != nil {
return filepath.ToSlash(target), nil
}
return filepath.ToSlash(rel), nil
}
func pluginIDFromGo(path string) (string, error) {
fset := token.NewFileSet()
file, err := parser.ParseFile(fset, path, nil, 0)
if err != nil {
return "", fmt.Errorf("build: parse %s: %w", path, err)
}
var id string
ast.Inspect(file, func(n ast.Node) bool {
fn, ok := n.(*ast.FuncDecl)
if !ok || fn.Name == nil || fn.Name.Name != "ID" || fn.Recv == nil || fn.Body == nil {
return true
}
for _, stmt := range fn.Body.List {
ret, ok := stmt.(*ast.ReturnStmt)
if !ok || len(ret.Results) != 1 {
continue
}
lit, ok := ret.Results[0].(*ast.BasicLit)
if !ok || lit.Kind != token.STRING {
continue
}
s, err := strconv.Unquote(lit.Value)
if err == nil {
id = s
}
}
return true
})
if id == "" {
return "", fmt.Errorf("build: %s does not return a plugin id", path)
}
return id, nil
}
func editAppModule(ctx context.Context, appDir, pluginDir, modPath string) error {
if err := runGo(ctx, appDir, "mod", "edit", "-require="+modPath+"@v0.0.0"); err != nil {
return err
}
rel, err := filepath.Rel(appDir, pluginDir)
if err != nil {
return fmt.Errorf("build: plugin replace: %w", err)
}
rel = filepath.ToSlash(rel)
if rel != ".." && !strings.HasPrefix(rel, "../") && !filepath.IsAbs(rel) && !strings.HasPrefix(rel, "./") {
rel = "./" + rel
}
if err := runGo(ctx, appDir, "mod", "edit", "-replace="+modPath+"="+rel); err != nil {
return err
}
return ensureToolchain(filepath.Join(appDir, "go.mod"))
}
func ensureGoWork(ctx context.Context, appDir string, m Manifest, pluginDir string) error {
gw := findGoWork(appDir)
if gw == "" {
if err := writeGoWork(filepath.Join(appDir, "go.work")); err != nil {
return err
}
gw = filepath.Join(appDir, "go.work")
for _, p := range m.Plugins {
if dir := localModuleDir(appDir, p.Module); dir != "" && dir != pluginDir {
if err := runGoWorkUse(ctx, appDir, gw, dir); err != nil {
return err
}
}
}
}
return runGoWorkUse(ctx, appDir, gw, pluginDir)
}
func writeGoWork(path string) error {
body := goVersionLine + "\n\n" + toolchainLine + "\n\nuse (\n\t.\n)\n"
if err := os.WriteFile(path, []byte(body), 0o644); err != nil {
return fmt.Errorf("build: write go.work: %w", err)
}
return nil
}
func runGoWorkUse(ctx context.Context, appDir, goWork, dir string) error {
cmd := exec.CommandContext(ctx, "go", "work", "use", dir)
cmd.Dir = appDir
applyGoWorkFile(cmd, goWork)
out, err := cmd.CombinedOutput()
if err != nil {
return fmt.Errorf("build: go work use: %w\n%s", err, out)
}
return ensureToolchain(goWork)
}
func localModuleDir(appDir, module string) string {
_, replaces, err := parseGoMod(filepath.Join(appDir, "go.mod"))
if err != nil {
return ""
}
r, ok := replaces[module]
if !ok || r == "" {
return ""
}
if filepath.IsAbs(r) {
return r
}
return filepath.Clean(filepath.Join(appDir, r))
}
func readModulePath(path string) (string, error) {
module, _, err := parseGoMod(path)
if err != nil {
return "", err
}
if module == "" {
return "", fmt.Errorf("build: %s has no module path", path)
}
return module, nil
}
func parseGoMod(path string) (string, map[string]string, error) {
data, err := os.ReadFile(path)
if err != nil {
return "", nil, fmt.Errorf("build: read %s: %w", path, err)
}
module := ""
replaces := make(map[string]string)
inReplace := false
for _, raw := range strings.Split(string(data), "\n") {
line := strings.TrimSpace(raw)
if i := strings.Index(line, "//"); i >= 0 {
line = strings.TrimSpace(line[:i])
}
if line == "" {
continue
}
if module == "" && strings.HasPrefix(line, "module ") {
module = strings.TrimSpace(strings.TrimPrefix(line, "module "))
continue
}
if line == "replace (" {
inReplace = true
continue
}
if inReplace {
if line == ")" {
inReplace = false
continue
}
if old, new, ok := splitReplace(line); ok {
replaces[old] = new
}
continue
}
if strings.HasPrefix(line, "replace ") {
if old, new, ok := splitReplace(strings.TrimPrefix(line, "replace ")); ok {
replaces[old] = new
}
}
}
return module, replaces, nil
}
func splitReplace(s string) (old, new string, ok bool) {
parts := strings.SplitN(s, "=>", 2)
if len(parts) != 2 {
return "", "", false
}
oldFields := strings.Fields(strings.TrimSpace(parts[0]))
newFields := strings.Fields(strings.TrimSpace(parts[1]))
if len(oldFields) == 0 || len(newFields) == 0 {
return "", "", false
}
return oldFields[0], newFields[0], true
}
func ensureToolchain(path string) error {
data, err := os.ReadFile(path)
if err != nil {
return fmt.Errorf("build: read %s: %w", path, err)
}
if bytes.Contains(data, []byte(toolchainLine)) {
return nil
}
lines := strings.Split(string(data), "\n")
out := make([]string, 0, len(lines)+2)
inserted := false
for _, line := range lines {
out = append(out, line)
if !inserted && strings.HasPrefix(strings.TrimSpace(line), "go ") {
out = append(out, "", toolchainLine)
inserted = true
}
}
if !inserted {
out = append([]string{toolchainLine, ""}, out...)
}
if err := os.WriteFile(path, []byte(strings.Join(out, "\n")), 0o644); err != nil {
return fmt.Errorf("build: write %s: %w", path, err)
}
return nil
}
func underRoot(root, path string) bool {
rel, err := filepath.Rel(root, path)
if err != nil {
return false
}
if rel == ".." || strings.HasPrefix(rel, ".."+string(os.PathSeparator)) {
return false
}
return true
}
func findGoWork(start string) string {
dir := start
for {
candidate := filepath.Join(dir, "go.work")
if st, err := os.Stat(candidate); err == nil && !st.IsDir() {
return candidate
}
parent := filepath.Dir(dir)
if parent == dir {
return ""
}
dir = parent
}
}
func runGo(ctx context.Context, dir string, args ...string) error {
cmd := exec.CommandContext(ctx, "go", args...)
cmd.Dir = dir
applyGoWork(cmd, dir)
out, err := cmd.CombinedOutput()
if err != nil {
return fmt.Errorf("build: go %s: %w\n%s", strings.Join(args, " "), err, out)
}
return nil
}
func applyGoWork(cmd *exec.Cmd, dir string) {
if gw := findGoWork(dir); gw != "" {
applyGoWorkFile(cmd, gw)
return
}
applyGoWorkFile(cmd, "off")
}
func applyGoWorkFile(cmd *exec.Cmd, goWork string) {
env := os.Environ()
filtered := make([]string, 0, len(env)+2)
for _, e := range env {
if strings.HasPrefix(e, "GOWORK=") || strings.HasPrefix(e, "GOFLAGS=") {
continue
}
filtered = append(filtered, e)
}
filtered = append(filtered, "GOWORK="+goWork)
if goWork == "off" {
filtered = append(filtered, "GOFLAGS=-mod=mod")
}
cmd.Env = filtered
}

263
internal/dev/watch.go Normal file
View File

@@ -0,0 +1,263 @@
package dev
import (
"context"
"fmt"
"io"
"os"
"os/exec"
"path/filepath"
"strings"
"syscall"
"time"
"github.com/fsnotify/fsnotify"
"git.golem15.com/golem15/summercms/internal/build"
)
const defaultDebounce = 200 * time.Millisecond
// Options customizes the watch loop. Tests inject Build and Start.
type Options struct {
Build func(ctx context.Context, appDir string, out io.Writer) error
Start func(ctx context.Context, binPath string, stdout, stderr io.Writer) (*exec.Cmd, error)
Debounce time.Duration
}
// Watch rebuilds the app on source changes and restarts the child binary.
func Watch(ctx context.Context, appDir string, out io.Writer) error {
return watch(ctx, appDir, out, Options{})
}
func watch(ctx context.Context, appDir string, out io.Writer, opts Options) error {
if appDir == "" {
return fmt.Errorf("dev: app directory is empty")
}
abs, err := filepath.Abs(appDir)
if err != nil {
return fmt.Errorf("dev: resolve app directory: %w", err)
}
appDir = abs
if out == nil {
out = os.Stdout
}
if opts.Build == nil {
opts.Build = build.App
}
if opts.Start == nil {
opts.Start = startBin
}
if opts.Debounce <= 0 {
opts.Debounce = defaultDebounce
}
m, err := build.LoadManifest(filepath.Join(appDir, "summer.yaml"))
if err != nil {
return err
}
binPath := filepath.Join(appDir, "bin", m.Binary)
watcher, err := fsnotify.NewWatcher()
if err != nil {
return fmt.Errorf("dev: watcher: %w", err)
}
defer watcher.Close()
if err := addWatches(watcher, appDir); err != nil {
return err
}
var current *child
defer func() { current.stop() }()
rebuild := func() {
if ctx.Err() != nil {
return
}
start := time.Now()
if err := opts.Build(ctx, appDir, out); err != nil {
fmt.Fprintf(out, "build error: %v\n", err)
return
}
fmt.Fprintf(out, "rebuild: %s\n", time.Since(start).Round(time.Millisecond))
if ctx.Err() != nil {
return
}
next, err := startChild(ctx, opts, binPath, out)
if err != nil {
fmt.Fprintf(out, "start error: %v\n", err)
return
}
current.stop()
current = next
}
rebuild()
events := make(chan struct{}, 1)
watchErrs := make(chan error, 1)
go func() {
for {
select {
case <-ctx.Done():
return
case ev, ok := <-watcher.Events:
if !ok {
return
}
if ev.Has(fsnotify.Create) {
if info, err := os.Stat(ev.Name); err == nil && info.IsDir() && !ignoredPath(appDir, ev.Name) {
_ = watcher.Add(ev.Name)
}
}
if !relevant(appDir, ev) {
continue
}
select {
case events <- struct{}{}:
default:
}
case err, ok := <-watcher.Errors:
if !ok {
return
}
if err == nil {
continue
}
select {
case watchErrs <- err:
default:
}
}
}
}()
var timer *time.Timer
timerC := func() <-chan time.Time {
if timer == nil {
return nil
}
return timer.C
}
for {
select {
case <-ctx.Done():
current.stop()
current = nil
return ctx.Err()
case err := <-watchErrs:
fmt.Fprintf(out, "watch error: %v\n", err)
case <-events:
if timer == nil {
timer = time.NewTimer(opts.Debounce)
} else {
if !timer.Stop() {
select {
case <-timer.C:
default:
}
}
timer.Reset(opts.Debounce)
}
case <-timerC():
timer = nil
rebuild()
}
}
}
type child struct {
cmd *exec.Cmd
wait <-chan error
}
func startChild(ctx context.Context, opts Options, binPath string, out io.Writer) (*child, error) {
cmd, err := opts.Start(ctx, binPath, out, out)
if err != nil {
return nil, err
}
if cmd == nil || cmd.Process == nil {
return nil, fmt.Errorf("dev: start returned no process")
}
ch := make(chan error, 1)
go func() { ch <- cmd.Wait() }()
return &child{cmd: cmd, wait: ch}, nil
}
func (c *child) stop() {
if c == nil || c.cmd == nil || c.cmd.Process == nil {
return
}
_ = c.cmd.Process.Signal(syscall.SIGTERM)
select {
case <-c.wait:
case <-time.After(2 * time.Second):
_ = c.cmd.Process.Kill()
<-c.wait
}
}
func startBin(_ context.Context, binPath string, stdout, stderr io.Writer) (*exec.Cmd, error) {
cmd := exec.Command(binPath)
cmd.Stdout = stdout
cmd.Stderr = stderr
cmd.Stdin = os.Stdin
if err := cmd.Start(); err != nil {
return nil, err
}
return cmd, nil
}
func addWatches(watcher *fsnotify.Watcher, root string) error {
return filepath.WalkDir(root, func(path string, d os.DirEntry, err error) error {
if err != nil {
return err
}
if !d.IsDir() {
return nil
}
if ignoredPath(root, path) && path != root {
return filepath.SkipDir
}
return watcher.Add(path)
})
}
func relevant(appDir string, ev fsnotify.Event) bool {
if ev.Op == fsnotify.Chmod {
return false
}
if ignoredPath(appDir, ev.Name) {
return false
}
return watchedName(filepath.Base(ev.Name))
}
func watchedName(base string) bool {
switch base {
case "go.mod", "go.work", ".env", "summer.yaml":
return true
}
switch filepath.Ext(base) {
case ".go", ".yaml", ".yml":
return true
}
return false
}
func ignoredPath(root, path string) bool {
rel, err := filepath.Rel(root, path)
if err != nil {
return true
}
if rel == "main.go" || rel == "plugins.gen.go" {
return true
}
for _, part := range strings.Split(rel, string(os.PathSeparator)) {
if part == ".git" || part == "bin" || part == "tmp" {
return true
}
}
return false
}

185
internal/dev/watch_test.go Normal file
View File

@@ -0,0 +1,185 @@
package dev
import (
"bytes"
"context"
"fmt"
"io"
"os"
"os/exec"
"path/filepath"
"strings"
"sync/atomic"
"syscall"
"testing"
"time"
)
func TestWatchRebuildsOnSourceEdit(t *testing.T) {
dir := t.TempDir()
writeWatchApp(t, dir)
var out bytes.Buffer
var builds atomic.Int32
var starts atomic.Int32
ctx, cancel := context.WithCancel(t.Context())
defer cancel()
opts := Options{
Debounce: 40 * time.Millisecond,
Build: func(ctx context.Context, appDir string, w io.Writer) error {
builds.Add(1)
fmt.Fprintln(w, "built hello in 1ms")
return nil
},
Start: func(ctx context.Context, binPath string, stdout, stderr io.Writer) (*exec.Cmd, error) {
starts.Add(1)
cmd := exec.Command("sleep", "30")
if err := cmd.Start(); err != nil {
return nil, err
}
return cmd, nil
},
}
done := make(chan error, 1)
go func() { done <- watch(ctx, dir, &out, opts) }()
waitUntil(t, "initial start", func() bool { return starts.Load() == 1 })
if builds.Load() != 1 {
t.Fatalf("initial builds = %d", builds.Load())
}
if err := os.WriteFile(filepath.Join(dir, "plugin.go"), []byte("package p\n// edited\n"), 0o644); err != nil {
t.Fatal(err)
}
waitUntil(t, "restart after edit", func() bool { return starts.Load() == 2 })
if builds.Load() != 2 {
t.Fatalf("builds after edit = %d", builds.Load())
}
if err := os.WriteFile(filepath.Join(dir, "plugins.gen.go"), []byte("package main\n// generated\n"), 0o644); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(dir, "main.go"), []byte("package main\n// generated\n"), 0o644); err != nil {
t.Fatal(err)
}
time.Sleep(200 * time.Millisecond)
if starts.Load() != 2 || builds.Load() != 2 {
t.Fatalf("generated files triggered rebuild starts=%d builds=%d", starts.Load(), builds.Load())
}
got := out.String()
if strings.Count(got, "rebuild:") != 2 {
t.Fatalf("want 2 rebuild latency lines, got %q", got)
}
cancel()
select {
case err := <-done:
if err != nil && err != context.Canceled {
t.Fatalf("watch exit: %v", err)
}
case <-time.After(3 * time.Second):
t.Fatal("watch did not exit on cancel")
}
}
func TestWatchKeepsChildOnBuildFailure(t *testing.T) {
dir := t.TempDir()
writeWatchApp(t, dir)
var out bytes.Buffer
var builds atomic.Int32
var starts atomic.Int32
var pid atomic.Int32
ctx, cancel := context.WithCancel(t.Context())
defer cancel()
opts := Options{
Debounce: 40 * time.Millisecond,
Build: func(ctx context.Context, appDir string, w io.Writer) error {
n := builds.Add(1)
if n >= 2 {
return fmt.Errorf("compile failed")
}
return nil
},
Start: func(ctx context.Context, binPath string, stdout, stderr io.Writer) (*exec.Cmd, error) {
starts.Add(1)
cmd := exec.Command("sleep", "30")
if err := cmd.Start(); err != nil {
return nil, err
}
pid.Store(int32(cmd.Process.Pid))
return cmd, nil
},
}
done := make(chan error, 1)
go func() { done <- watch(ctx, dir, &out, opts) }()
waitUntil(t, "initial start", func() bool { return starts.Load() == 1 })
if err := os.WriteFile(filepath.Join(dir, "plugin.go"), []byte("package p\n// bad\n"), 0o644); err != nil {
t.Fatal(err)
}
waitUntil(t, "failed rebuild", func() bool { return builds.Load() >= 2 })
time.Sleep(80 * time.Millisecond)
if starts.Load() != 1 {
t.Fatalf("build failure restarted child: starts=%d", starts.Load())
}
if !strings.Contains(out.String(), "compile failed") {
t.Fatalf("missing build error: %s", out.String())
}
proc, err := os.FindProcess(int(pid.Load()))
if err != nil {
t.Fatal(err)
}
if err := proc.Signal(syscall.Signal(0)); err != nil {
t.Fatalf("child was reaped after failed build: %v", err)
}
cancel()
select {
case <-done:
case <-time.After(3 * time.Second):
t.Fatal("watch did not exit")
}
deadline := time.Now().Add(2 * time.Second)
for time.Now().Before(deadline) {
if err := proc.Signal(syscall.Signal(0)); err != nil {
return
}
time.Sleep(20 * time.Millisecond)
}
t.Fatal("child still running after cancel")
}
func writeWatchApp(t *testing.T, dir string) {
t.Helper()
write(t, filepath.Join(dir, "summer.yaml"), "module: example.com/app\nbinary: hello\nplugins:\n - id: a.b\n module: example.com/app/plugins/demo\n")
write(t, filepath.Join(dir, "plugin.go"), "package p\n")
write(t, filepath.Join(dir, "main.go"), "package main\n")
write(t, filepath.Join(dir, "plugins.gen.go"), "package main\n")
}
func write(t *testing.T, path, body string) {
t.Helper()
if err := os.WriteFile(path, []byte(body), 0o644); err != nil {
t.Fatal(err)
}
}
func waitUntil(t *testing.T, name string, ok func() bool) {
t.Helper()
deadline := time.Now().Add(3 * time.Second)
for time.Now().Before(deadline) {
if ok() {
return
}
time.Sleep(15 * time.Millisecond)
}
t.Fatalf("timed out waiting for %s", name)
}