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 } }