Files
summercms/internal/build/build.go
Jakub Zych 2078f91a2b feat(01-01): generate and build hello app from manifest
- Add summer build with validated summer.yaml codegen
- Generate stable main.go and plugins.gen.go then go build
- Smoke-test the built hello binary greeter:hello command
2026-09-16 13:01:24 +02:00

309 lines
8.5 KiB
Go

package build
import (
"bytes"
"context"
"errors"
"fmt"
"go/format"
"io"
"os"
"os/exec"
"path/filepath"
"strconv"
"strings"
"time"
"unicode"
"github.com/knadh/koanf/parsers/yaml"
)
const manifestName = "summer.yaml"
// Manifest is the ordered app plugin list used to generate imports.
type Manifest struct {
Module string
Binary string
Plugins []Plugin
}
// Plugin is one compiled plugin module in manifest order.
type Plugin struct {
ID string
Module string
}
// LoadManifest reads and validates summer.yaml at path.
func LoadManifest(path string) (Manifest, error) {
raw, err := os.ReadFile(path)
if err != nil {
return Manifest{}, fmt.Errorf("build: read %s: %w", path, err)
}
tree, err := yaml.Parser().Unmarshal(raw)
if err != nil {
return Manifest{}, fmt.Errorf("build: parse %s: %w", path, err)
}
m := Manifest{
Module: strings.TrimSpace(asString(tree["module"])),
Binary: strings.TrimSpace(asString(tree["binary"])),
}
if err := validateModulePath(m.Module); err != nil {
return Manifest{}, fmt.Errorf("build: module: %w", err)
}
if err := validateBinary(m.Binary); err != nil {
return Manifest{}, fmt.Errorf("build: binary: %w", err)
}
seenID := make(map[string]struct{})
seenMod := make(map[string]struct{})
for i, item := range asSlice(tree["plugins"]) {
entry := asMap(item)
if entry == nil {
return Manifest{}, fmt.Errorf("build: plugins[%d] must be a mapping with id and module", i)
}
p := Plugin{
ID: strings.TrimSpace(asString(entry["id"])),
Module: strings.TrimSpace(asString(entry["module"])),
}
if p.ID == "" {
return Manifest{}, fmt.Errorf("build: plugins[%d] is missing id", i)
}
if strings.ContainsAny(p.ID, " \t\r\n\"'`") {
return Manifest{}, fmt.Errorf("build: plugins[%d] has invalid id %q", i, p.ID)
}
if _, dup := seenID[p.ID]; dup {
return Manifest{}, fmt.Errorf("build: duplicate plugin id %q", p.ID)
}
if err := validateModulePath(p.Module); err != nil {
return Manifest{}, fmt.Errorf("build: plugins[%d] module: %w", i, err)
}
if _, dup := seenMod[p.Module]; dup {
return Manifest{}, fmt.Errorf("build: duplicate plugin module %q", p.Module)
}
seenID[p.ID] = struct{}{}
seenMod[p.Module] = struct{}{}
m.Plugins = append(m.Plugins, p)
}
return m, nil
}
// App generates app sources from summer.yaml and runs go build in appDir.
func App(ctx context.Context, appDir string, out io.Writer) error {
if appDir == "" {
return fmt.Errorf("build: app directory is empty")
}
if !filepath.IsAbs(appDir) {
abs, err := filepath.Abs(appDir)
if err != nil {
return fmt.Errorf("build: resolve app directory: %w", err)
}
appDir = abs
}
if out == nil {
out = os.Stdout
}
m, err := LoadManifest(filepath.Join(appDir, manifestName))
if err != nil {
return err
}
if err := generate(appDir, m); err != nil {
return err
}
binRel := filepath.Join("bin", m.Binary)
if err := os.MkdirAll(filepath.Join(appDir, "bin"), 0o755); err != nil {
return fmt.Errorf("build: create bin dir: %w", err)
}
cmd := exec.CommandContext(ctx, "go", "build", "-o", binRel, ".")
cmd.Dir = appDir
cmd.Stdout = out
cmd.Stderr = out
start := time.Now()
if err := cmd.Run(); err != nil {
return fmt.Errorf("build: go build: %w", err)
}
fmt.Fprintf(out, "built %s in %s\n", m.Binary, time.Since(start).Round(time.Millisecond))
return nil
}
func generate(appDir string, m Manifest) error {
mainSrc, err := generateMain(m)
if err != nil {
return err
}
pluginsSrc, err := generatePluginsGen(m)
if err != nil {
return err
}
if err := writeIfChanged(filepath.Join(appDir, "main.go"), mainSrc); err != nil {
return err
}
if err := writeIfChanged(filepath.Join(appDir, "plugins.gen.go"), pluginsSrc); err != nil {
return err
}
return nil
}
func generateMain(m Manifest) ([]byte, error) {
var b strings.Builder
b.WriteString("// Code generated by summer build. DO NOT EDIT.\n\n")
b.WriteString("package main\n\n")
b.WriteString("import (\n")
b.WriteString("\t\"fmt\"\n")
b.WriteString("\t\"io\"\n")
b.WriteString("\t\"os\"\n\n")
b.WriteString("\t\"git.golem15.com/golem15/summercms/backpack\"\n")
b.WriteString("\t\"git.golem15.com/golem15/summercms/bonfire\"\n")
b.WriteString("\t\"git.golem15.com/golem15/summercms/compass\"\n")
b.WriteString("\t\"git.golem15.com/golem15/summercms/pact\"\n")
b.WriteString("\t\"git.golem15.com/golem15/summercms/party\"\n")
b.WriteString(")\n\n")
b.WriteString("func main() {\n")
b.WriteString("\tif err := run(os.Args[1:], os.Stdout); err != nil {\n")
b.WriteString("\t\tfmt.Fprintln(os.Stderr, err)\n")
b.WriteString("\t\tos.Exit(1)\n")
b.WriteString("\t}\n")
b.WriteString("}\n\n")
b.WriteString("func run(args []string, out io.Writer) error {\n")
b.WriteString("\tcfg, err := compass.Load(\"config\")\n")
b.WriteString("\tif err != nil {\n")
b.WriteString("\t\treturn err\n")
b.WriteString("\t}\n")
b.WriteString("\tapp := backpack.New(cfg)\n")
b.WriteString("\tplugins, err := party.Activate(app, PluginIDs)\n")
b.WriteString("\tif err != nil {\n")
b.WriteString("\t\treturn err\n")
b.WriteString("\t}\n")
b.WriteString("\tvar commands []bonfire.Command\n")
b.WriteString("\tfor _, plugin := range plugins {\n")
b.WriteString("\t\tif hasCommands, ok := plugin.(pact.HasCommands); ok {\n")
b.WriteString("\t\t\tcommands = append(commands, hasCommands.Commands()...)\n")
b.WriteString("\t\t}\n")
b.WriteString("\t}\n")
fmt.Fprintf(&b, "\troot := bonfire.NewRoot(%s, commands, out)\n", strconv.Quote(m.Binary))
b.WriteString("\troot.SetArgs(args)\n")
b.WriteString("\treturn root.Execute()\n")
b.WriteString("}\n")
return formatSource([]byte(b.String()))
}
func generatePluginsGen(m Manifest) ([]byte, error) {
var b strings.Builder
b.WriteString("// Code generated by summer build. DO NOT EDIT.\n\n")
b.WriteString("package main\n\n")
if len(m.Plugins) > 0 {
b.WriteString("import (\n")
for _, p := range m.Plugins {
fmt.Fprintf(&b, "\t_ %s\n", strconv.Quote(p.Module))
}
b.WriteString(")\n\n")
}
b.WriteString("// PluginIDs is the ordered plugin list from summer.yaml.\n")
b.WriteString("var PluginIDs = []string{\n")
for _, p := range m.Plugins {
fmt.Fprintf(&b, "\t%s,\n", strconv.Quote(p.ID))
}
b.WriteString("}\n")
return formatSource([]byte(b.String()))
}
func formatSource(src []byte) ([]byte, error) {
formatted, err := format.Source(src)
if err != nil {
return nil, fmt.Errorf("build: format generated go: %w", err)
}
return formatted, nil
}
func writeIfChanged(path string, content []byte) error {
existing, err := os.ReadFile(path)
if err == nil && bytes.Equal(existing, content) {
return nil
}
if err != nil && !errors.Is(err, os.ErrNotExist) {
return fmt.Errorf("build: read %s: %w", path, err)
}
if err := os.WriteFile(path, content, 0o644); err != nil {
return fmt.Errorf("build: write %s: %w", path, err)
}
return nil
}
func validateBinary(name string) error {
if name == "" {
return fmt.Errorf("is empty")
}
if name != filepath.Base(name) || strings.ContainsRune(name, os.PathSeparator) {
return fmt.Errorf("%q must be a bare file name", name)
}
if name == "." || name == ".." {
return fmt.Errorf("%q is not a valid binary name", name)
}
for _, r := range name {
if r > unicode.MaxASCII || !(unicode.IsLetter(r) || unicode.IsDigit(r) || r == '.' || r == '_' || r == '-') {
return fmt.Errorf("%q contains invalid characters", name)
}
}
return nil
}
func validateModulePath(path string) error {
if path == "" {
return fmt.Errorf("is empty")
}
if strings.HasPrefix(path, "-") {
return fmt.Errorf("%q must not start with -", path)
}
if strings.ContainsAny(path, " \t\r\n\\\"'`$;&|*?<>()[]{}") {
return fmt.Errorf("%q contains invalid characters", path)
}
if filepath.IsAbs(path) || strings.Contains(path, `\`) {
return fmt.Errorf("%q is not a valid module path", path)
}
for _, seg := range strings.Split(path, "/") {
if seg == "" || seg == "." || seg == ".." {
return fmt.Errorf("%q has an invalid path segment", path)
}
if strings.Contains(seg, "@") {
return fmt.Errorf("%q must not contain a version suffix", path)
}
}
return nil
}
func asString(v any) string {
s, _ := v.(string)
return s
}
func asSlice(v any) []any {
switch s := v.(type) {
case []any:
return s
case nil:
return nil
default:
return nil
}
}
func asMap(v any) map[string]any {
switch m := v.(type) {
case map[string]any:
return m
case map[any]any:
out := make(map[string]any, len(m))
for k, val := range m {
ks, ok := k.(string)
if !ok {
continue
}
out[ks] = val
}
return out
default:
return nil
}
}