- 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
220 lines
5.7 KiB
Go
220 lines
5.7 KiB
Go
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
|
|
}
|
|
}
|