From 9b1a25dc94f15ffeb195babcea29c364e93a1683 Mon Sep 17 00:00:00 2001 From: Jakub Zych Date: Wed, 16 Sep 2026 13:47:27 +0200 Subject: [PATCH 1/4] feat(01-03): add make:plugin and plugin:add scaffolding - Scaffold a compiling vendor.plugin module with go.mod, plugin.go and config/ - Register the module once in summer.yaml, go.work and the app go.mod - Keep summer build on the same generate-and-compile path and print elapsed time --- cmd/summer/main.go | 65 ++++- cmd/summer/main_test.go | 26 ++ internal/build/build.go | 150 +---------- internal/build/build_test.go | 291 +++++++++++++++++++++ internal/build/manifest.go | 219 ++++++++++++++++ internal/build/scaffold.go | 476 +++++++++++++++++++++++++++++++++++ 6 files changed, 1077 insertions(+), 150 deletions(-) create mode 100644 internal/build/manifest.go create mode 100644 internal/build/scaffold.go diff --git a/cmd/summer/main.go b/cmd/summer/main.go index 620be23..57af315 100644 --- a/cmd/summer/main.go +++ b/cmd/summer/main.go @@ -10,7 +10,7 @@ import ( ) func main() { - root := bonfire.NewRoot("summer", []bonfire.Command{buildCommand()}, os.Stdout) + root := bonfire.NewRoot("summer", toolCommands(), os.Stdout) root.SetArgs(os.Args[1:]) if err := root.Execute(); err != nil { fmt.Fprintln(os.Stderr, err) @@ -18,6 +18,14 @@ func main() { } } +func toolCommands() []bonfire.Command { + return []bonfire.Command{ + buildCommand(), + makePluginCommand(), + addPluginCommand(), + } +} + func buildCommand() bonfire.Command { return bonfire.Command{ Name: "build", @@ -31,3 +39,58 @@ 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 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 + }, + } +} diff --git a/cmd/summer/main_test.go b/cmd/summer/main_test.go index 24f9372..4c04e66 100644 --- a/cmd/summer/main_test.go +++ b/cmd/summer/main_test.go @@ -1,15 +1,41 @@ 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"} { + if !slices.Contains(names, want) { + t.Fatalf("missing %s in %v", want, names) + } + } + + var buf bytes.Buffer + root := bonfire.NewRoot("summer", toolCommands(), &buf) + 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() diff --git a/internal/build/build.go b/internal/build/build.go index 2409912..9294c4e 100644 --- a/internal/build/build.go +++ b/internal/build/build.go @@ -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) @@ -229,80 +158,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 - } -} diff --git a/internal/build/build_test.go b/internal/build/build_test.go index db07913..261de99 100644 --- a/internal/build/build_test.go +++ b/internal/build/build_test.go @@ -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 { diff --git a/internal/build/manifest.go b/internal/build/manifest.go new file mode 100644 index 0000000..32ac0e8 --- /dev/null +++ b/internal/build/manifest.go @@ -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 + } +} diff --git a/internal/build/scaffold.go b/internal/build/scaffold.go new file mode 100644 index 0000000..5b779b5 --- /dev/null +++ b/internal/build/scaffold.go @@ -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/. +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 +} From a6004e490fde4ec3a93cb8b413f227b31819e913 Mon Sep 17 00:00:00 2001 From: Jakub Zych Date: Wed, 16 Sep 2026 13:54:44 +0200 Subject: [PATCH 2/4] feat(01-03): add deterministic rich CLI output - Inject bonfire.Output from stdin/stdout/stderr with stdlib widgets and x/term - Degrade spinner, progress, table and prompts without a TTY or color - Reject plugin command names that are not namespace:verb --- bonfire/command.go | 58 ++--- bonfire/output.go | 170 ++++++++++++++ bonfire/output_test.go | 157 +++++++++++++ bonfire/prompts.go | 133 +++++++++++ bonfire/prompts_test.go | 92 ++++++++ bonfire/root.go | 45 ++-- bonfire/widgets.go | 210 ++++++++++++++++++ cmd/summer/main.go | 6 +- cmd/summer/main_test.go | 5 +- examples/hello/hello_test.go | 43 ++-- examples/hello/main.go | 5 +- examples/hello/plugins/greeter/plugin.go | 4 + examples/hello/plugins/greeter/plugin_test.go | 20 +- go.mod | 3 +- go.sum | 6 +- internal/build/build.go | 5 +- 16 files changed, 884 insertions(+), 78 deletions(-) create mode 100644 bonfire/output.go create mode 100644 bonfire/output_test.go create mode 100644 bonfire/prompts.go create mode 100644 bonfire/prompts_test.go create mode 100644 bonfire/widgets.go diff --git a/bonfire/command.go b/bonfire/command.go index 94c2df8..737d267 100644 --- a/bonfire/command.go +++ b/bonfire/command.go @@ -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) } diff --git a/bonfire/output.go b/bonfire/output.go new file mode 100644 index 0000000..afac4dc --- /dev/null +++ b/bonfire/output.go @@ -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 } diff --git a/bonfire/output_test.go b/bonfire/output_test.go new file mode 100644 index 0000000..18f964d --- /dev/null +++ b/bonfire/output_test.go @@ -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) + } +} diff --git a/bonfire/prompts.go b/bonfire/prompts.go new file mode 100644 index 0000000..f5cef95 --- /dev/null +++ b/bonfire/prompts.go @@ -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 +} diff --git a/bonfire/prompts_test.go b/bonfire/prompts_test.go new file mode 100644 index 0000000..733a247 --- /dev/null +++ b/bonfire/prompts_test.go @@ -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) + } +} diff --git a/bonfire/root.go b/bonfire/root.go index 05c7326..54d40c1 100644 --- a/bonfire/root.go +++ b/bonfire/root.go @@ -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 } diff --git a/bonfire/widgets.go b/bonfire/widgets.go new file mode 100644 index 0000000..2509cff --- /dev/null +++ b/bonfire/widgets.go @@ -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() +} diff --git a/cmd/summer/main.go b/cmd/summer/main.go index 57af315..3d386e4 100644 --- a/cmd/summer/main.go +++ b/cmd/summer/main.go @@ -10,7 +10,11 @@ import ( ) func main() { - root := bonfire.NewRoot("summer", toolCommands(), 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) diff --git a/cmd/summer/main_test.go b/cmd/summer/main_test.go index 4c04e66..fdc0d89 100644 --- a/cmd/summer/main_test.go +++ b/cmd/summer/main_test.go @@ -26,7 +26,10 @@ func TestToolCommandNames(t *testing.T) { } var buf bytes.Buffer - root := bonfire.NewRoot("summer", toolCommands(), &buf) + 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) diff --git a/examples/hello/hello_test.go b/examples/hello/hello_test.go index 9a877e3..ae4f9da 100644 --- a/examples/hello/hello_test.go +++ b/examples/hello/hello_test.go @@ -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 { diff --git a/examples/hello/main.go b/examples/hello/main.go index 7bfb69b..e4200cf 100644 --- a/examples/hello/main.go +++ b/examples/hello/main.go @@ -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() } diff --git a/examples/hello/plugins/greeter/plugin.go b/examples/hello/plugins/greeter/plugin.go index 3a7f16a..a5a94e9 100644 --- a/examples/hello/plugins/greeter/plugin.go +++ b/examples/hello/plugins/greeter/plugin.go @@ -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 }, diff --git a/examples/hello/plugins/greeter/plugin_test.go b/examples/hello/plugins/greeter/plugin_test.go index 7bc5e59..bb87b09 100644 --- a/examples/hello/plugins/greeter/plugin_test.go +++ b/examples/hello/plugins/greeter/plugin_test.go @@ -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()) } diff --git a/go.mod b/go.mod index 8824788..7d2d10f 100644 --- a/go.mod +++ b/go.mod @@ -11,6 +11,7 @@ require ( 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 ( @@ -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 ) diff --git a/go.sum b/go.sum index ffb529b..b822701 100644 --- a/go.sum +++ b/go.sum @@ -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= diff --git a/internal/build/build.go b/internal/build/build.go index 9294c4e..4c6d134 100644 --- a/internal/build/build.go +++ b/internal/build/build.go @@ -110,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") From 270b7f1e8735d319ed6a89ae8d7db30f257ebb7a Mon Sep 17 00:00:00 2001 From: Jakub Zych Date: Wed, 16 Sep 2026 13:59:43 +0200 Subject: [PATCH 3/4] feat(01-03): add summer dev watch rebuild loop - Watch app sources with fsnotify, debounce, and one serialized build.App - Restart the child only after a successful build and print rebuild latency - Ignore generated app files and reap the child on cancellation --- cmd/summer/main.go | 16 +++ cmd/summer/main_test.go | 2 +- go.mod | 2 +- go.sum | 4 +- internal/dev/watch.go | 263 +++++++++++++++++++++++++++++++++++++ internal/dev/watch_test.go | 185 ++++++++++++++++++++++++++ 6 files changed, 468 insertions(+), 4 deletions(-) create mode 100644 internal/dev/watch.go create mode 100644 internal/dev/watch_test.go diff --git a/cmd/summer/main.go b/cmd/summer/main.go index 3d386e4..0a43fab 100644 --- a/cmd/summer/main.go +++ b/cmd/summer/main.go @@ -7,6 +7,7 @@ import ( "git.golem15.com/golem15/summercms/bonfire" "git.golem15.com/golem15/summercms/internal/build" + "git.golem15.com/golem15/summercms/internal/dev" ) func main() { @@ -27,6 +28,7 @@ func toolCommands() []bonfire.Command { buildCommand(), makePluginCommand(), addPluginCommand(), + devCommand(), } } @@ -72,6 +74,20 @@ func makePluginCommand() bonfire.Command { } } +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", diff --git a/cmd/summer/main_test.go b/cmd/summer/main_test.go index fdc0d89..fedefd8 100644 --- a/cmd/summer/main_test.go +++ b/cmd/summer/main_test.go @@ -19,7 +19,7 @@ func TestToolCommandNames(t *testing.T) { for _, c := range toolCommands() { names = append(names, c.Name) } - for _, want := range []string{"build", "make:plugin", "plugin:add"} { + for _, want := range []string{"build", "make:plugin", "plugin:add", "dev"} { if !slices.Contains(names, want) { t.Fatalf("missing %s in %v", want, names) } diff --git a/go.mod b/go.mod index 7d2d10f..14f5927 100644 --- a/go.mod +++ b/go.mod @@ -5,6 +5,7 @@ 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 @@ -15,7 +16,6 @@ require ( ) 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 diff --git a/go.sum b/go.sum index b822701..5df5ed1 100644 --- a/go.sum +++ b/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= diff --git a/internal/dev/watch.go b/internal/dev/watch.go new file mode 100644 index 0000000..253b726 --- /dev/null +++ b/internal/dev/watch.go @@ -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 +} diff --git a/internal/dev/watch_test.go b/internal/dev/watch_test.go new file mode 100644 index 0000000..44ab3d7 --- /dev/null +++ b/internal/dev/watch_test.go @@ -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) +} From 9b2b035b68ec71012801369fc4a79d1ec8331a10 Mon Sep 17 00:00:00 2001 From: Jakub Zych Date: Wed, 16 Sep 2026 14:01:34 +0200 Subject: [PATCH 4/4] docs(01-03): complete plugin scaffold CLI and watch-loop plan - Record make:plugin, bonfire output and summer dev results --- .../01-03-SUMMARY.md | 164 ++++++++++++++++++ 1 file changed, 164 insertions(+) create mode 100644 .planning/phases/01-framework-kernel-foundation/01-03-SUMMARY.md diff --git a/.planning/phases/01-framework-kernel-foundation/01-03-SUMMARY.md b/.planning/phases/01-framework-kernel-foundation/01-03-SUMMARY.md new file mode 100644 index 0000000..98658b8 --- /dev/null +++ b/.planning/phases/01-framework-kernel-foundation/01-03-SUMMARY.md @@ -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//{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: 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/` 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: `, 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/` 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*