chore: merge executor worktree (worktree-agent-01-03)
This commit is contained in:
164
.planning/phases/01-framework-kernel-foundation/01-03-SUMMARY.md
Normal file
164
.planning/phases/01-framework-kernel-foundation/01-03-SUMMARY.md
Normal 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*
|
||||
@@ -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
170
bonfire/output.go
Normal 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
157
bonfire/output_test.go
Normal 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
133
bonfire/prompts.go
Normal 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
92
bonfire/prompts_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
@@ -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
210
bonfire/widgets.go
Normal 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()
|
||||
}
|
||||
@@ -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
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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
|
||||
},
|
||||
|
||||
@@ -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
5
go.mod
@@ -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
10
go.sum
@@ -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=
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
219
internal/build/manifest.go
Normal 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
476
internal/build/scaffold.go
Normal 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
263
internal/dev/watch.go
Normal 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
185
internal/dev/watch_test.go
Normal 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)
|
||||
}
|
||||
Reference in New Issue
Block a user