From 9b1a25dc94f15ffeb195babcea29c364e93a1683 Mon Sep 17 00:00:00 2001 From: Jakub Zych Date: Wed, 16 Sep 2026 13:47:27 +0200 Subject: [PATCH] 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 +}