- TestPluginDirs and TestEffectiveFramework over go list JSON fixtures and git checkouts; TestResolveInstall against an in-memory registry; TestAppendManifestPlugins for the in-place summer.yaml edit - fix: a package of the application's own module was reported as a submodule with the application's git describe; it is in-app (local) - fix: summer.yaml items written as '- id:' keep their spacing when plugin:add or plugin:install appends to them
469 lines
13 KiB
Go
469 lines
13 KiB
Go
package build
|
|
|
|
import (
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
"regexp"
|
|
"strings"
|
|
"unicode"
|
|
|
|
"git.golem15.com/golem15/summercms/modules/bonfire"
|
|
"github.com/knadh/koanf/parsers/yaml"
|
|
)
|
|
|
|
const manifestName = "summer.yaml"
|
|
|
|
var (
|
|
pluginIDRe = regexp.MustCompile(`^[a-z][a-z0-9]*\.[a-z][a-z0-9]*$`)
|
|
buildTagRe = regexp.MustCompile(`^[a-z0-9_]+$`)
|
|
)
|
|
|
|
// Manifest is the ordered app plugin list used to generate imports, plus
|
|
// the Go build tags summer build passes to go build (summer.yaml build.tags).
|
|
type Manifest struct {
|
|
Module string
|
|
Binary string
|
|
Plugins []Plugin
|
|
Tags []string
|
|
}
|
|
|
|
// 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)
|
|
}
|
|
|
|
tags, err := loadBuildTags(tree["build"])
|
|
if err != nil {
|
|
return Manifest{}, err
|
|
}
|
|
m.Tags = tags
|
|
return m, nil
|
|
}
|
|
|
|
// loadBuildTags reads the optional build mapping and its tags list. Entries
|
|
// are trimmed and validated; duplicates are dropped keeping the first.
|
|
func loadBuildTags(raw any) ([]string, error) {
|
|
if raw == nil {
|
|
return nil, nil
|
|
}
|
|
section := asMap(raw)
|
|
if section == nil {
|
|
return nil, fmt.Errorf("build: build must be a mapping (build.tags)")
|
|
}
|
|
rawTags, ok := section["tags"]
|
|
if !ok || rawTags == nil {
|
|
return nil, nil
|
|
}
|
|
list, ok := rawTags.([]any)
|
|
if !ok {
|
|
return nil, fmt.Errorf("build: build.tags must be a list of tags")
|
|
}
|
|
var tags []string
|
|
seen := make(map[string]struct{}, len(list))
|
|
for i, item := range list {
|
|
s, ok := item.(string)
|
|
if !ok {
|
|
return nil, fmt.Errorf("build: build.tags[%d] must be a string", i)
|
|
}
|
|
tag := strings.TrimSpace(s)
|
|
if err := ValidateBuildTag(tag); err != nil {
|
|
return nil, fmt.Errorf("build: build.tags[%d]: %w", i, err)
|
|
}
|
|
if _, dup := seen[tag]; dup {
|
|
continue
|
|
}
|
|
seen[tag] = struct{}{}
|
|
tags = append(tags, tag)
|
|
}
|
|
return tags, nil
|
|
}
|
|
|
|
// ValidateBuildTag requires a Go build tag of lowercase letters, digits and
|
|
// underscores, so a summer.yaml value can never smuggle a flag into go build.
|
|
func ValidateBuildTag(tag string) error {
|
|
if tag == "" {
|
|
return fmt.Errorf("build tag is empty")
|
|
}
|
|
if !buildTagRe.MatchString(tag) {
|
|
return fmt.Errorf("build tag %q must match ^[a-z0-9_]+$", tag)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// SaveManifest writes summer.yaml with plugins in slice order and, when
|
|
// present, the build.tags list.
|
|
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 len(m.Tags) > 0 {
|
|
b.WriteString("build:\n")
|
|
b.WriteString(" tags:\n")
|
|
for i, tag := range m.Tags {
|
|
if err := ValidateBuildTag(tag); err != nil {
|
|
return fmt.Errorf("build: build.tags[%d]: %w", i, err)
|
|
}
|
|
fmt.Fprintf(&b, " - %s\n", tag)
|
|
}
|
|
}
|
|
if err := os.WriteFile(path, []byte(b.String()), 0o644); err != nil {
|
|
return fmt.Errorf("build: write %s: %w", path, err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// appendManifestPlugins adds plugins to the end of the plugins list in the
|
|
// summer.yaml at path by inserting text, so comments, blank lines and the
|
|
// order of the other keys stay as they are. The entries take the
|
|
// indentation of the existing items; a file without a plugins key gets a
|
|
// new block at its end. The result is parsed again with LoadManifest, and
|
|
// any error restores the original bytes.
|
|
func appendManifestPlugins(path string, plugins []Plugin) error {
|
|
if len(plugins) == 0 {
|
|
return nil
|
|
}
|
|
for i, p := range 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)
|
|
}
|
|
}
|
|
orig, err := os.ReadFile(path)
|
|
if err != nil {
|
|
return fmt.Errorf("build: read %s: %w", path, err)
|
|
}
|
|
before, err := LoadManifest(path)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
updated, err := insertManifestPlugins(string(orig), plugins)
|
|
if err != nil {
|
|
return fmt.Errorf("build: edit %s: %w", path, err)
|
|
}
|
|
if err := os.WriteFile(path, []byte(updated), 0o644); err != nil {
|
|
return fmt.Errorf("build: write %s: %w", path, err)
|
|
}
|
|
restore := func(cause error) error {
|
|
if werr := os.WriteFile(path, orig, 0o644); werr != nil {
|
|
return fmt.Errorf("%w (restoring %s also failed: %v)", cause, path, werr)
|
|
}
|
|
return cause
|
|
}
|
|
after, err := LoadManifest(path)
|
|
if err != nil {
|
|
return restore(fmt.Errorf("build: summer.yaml edit did not parse, left unchanged: %w", err))
|
|
}
|
|
want := append(append([]Plugin(nil), before.Plugins...), plugins...)
|
|
if len(after.Plugins) != len(want) {
|
|
return restore(fmt.Errorf("build: summer.yaml edit gave %d plugins instead of %d, left unchanged", len(after.Plugins), len(want)))
|
|
}
|
|
for i := range want {
|
|
if after.Plugins[i] != want[i] {
|
|
return restore(fmt.Errorf("build: summer.yaml edit changed plugins[%d], left unchanged", i))
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// insertManifestPlugins returns src with plugins appended to its
|
|
// top-level plugins block.
|
|
func insertManifestPlugins(src string, plugins []Plugin) (string, error) {
|
|
lines := strings.SplitAfter(src, "\n")
|
|
if n := len(lines); n > 0 && lines[n-1] == "" {
|
|
lines = lines[:n-1]
|
|
}
|
|
key := -1
|
|
for i, l := range lines {
|
|
if strings.HasPrefix(l, "plugins:") {
|
|
key = i
|
|
break
|
|
}
|
|
}
|
|
// dash is the indentation before "-", gap the spacing after it.
|
|
render := func(dash, gap, keyIndent string) string {
|
|
var b strings.Builder
|
|
for _, p := range plugins {
|
|
fmt.Fprintf(&b, "%s-%sid: %s\n%smodule: %s\n", dash, gap, p.ID, keyIndent, p.Module)
|
|
}
|
|
return b.String()
|
|
}
|
|
if key < 0 {
|
|
out := strings.Join(lines, "")
|
|
if out != "" && !strings.HasSuffix(out, "\n") {
|
|
out += "\n"
|
|
}
|
|
return out + "plugins:\n" + render(" ", " ", " "), nil
|
|
}
|
|
rest := strings.TrimSpace(stripYAMLComment(strings.TrimPrefix(lines[key], "plugins:")))
|
|
switch rest {
|
|
case "", "[]", "~", "null":
|
|
default:
|
|
return "", fmt.Errorf("the plugins list is written inline; write it as a block list (one \"- id:\" item per plugin) and run the command again")
|
|
}
|
|
// The block runs until the next line that starts a top-level key.
|
|
end := key + 1
|
|
last := key
|
|
dash, gap, keyIndent := "", " ", ""
|
|
for ; end < len(lines); end++ {
|
|
l := strings.TrimRight(lines[end], "\r\n")
|
|
trimmed := strings.TrimSpace(l)
|
|
if trimmed == "" || strings.HasPrefix(trimmed, "#") {
|
|
continue
|
|
}
|
|
indented := l[0] == ' ' || l[0] == '\t'
|
|
if !indented && !strings.HasPrefix(l, "-") {
|
|
break
|
|
}
|
|
last = end
|
|
if dash == "" && strings.HasPrefix(trimmed, "-") {
|
|
dash = l[:len(l)-len(strings.TrimLeft(l, " \t"))]
|
|
afterDash := strings.TrimLeft(trimmed[1:], " \t")
|
|
if g := trimmed[1 : len(trimmed)-len(afterDash)]; g != "" && !strings.Contains(g, "\t") {
|
|
gap = g
|
|
}
|
|
keyIndent = strings.Repeat(" ", len(dash)+1+len(gap))
|
|
}
|
|
}
|
|
if dash == "" && keyIndent == "" {
|
|
dash, keyIndent = " ", " "
|
|
}
|
|
head := lines[:last+1]
|
|
if rest != "" {
|
|
head = append(append([]string(nil), lines[:key]...), "plugins:\n")
|
|
head = append(head, lines[key+1:last+1]...)
|
|
}
|
|
tail := lines[last+1:]
|
|
out := strings.Join(head, "")
|
|
if !strings.HasSuffix(out, "\n") {
|
|
out += "\n"
|
|
}
|
|
return out + render(dash, gap, keyIndent) + strings.Join(tail, ""), nil
|
|
}
|
|
|
|
// stripYAMLComment drops a trailing " # comment" from a YAML value.
|
|
func stripYAMLComment(s string) string {
|
|
if i := strings.Index(s, " #"); i >= 0 {
|
|
return s[:i]
|
|
}
|
|
if strings.HasPrefix(strings.TrimSpace(s), "#") {
|
|
return ""
|
|
}
|
|
return s
|
|
}
|
|
|
|
// 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
|
|
}
|
|
|
|
// cliInvocation is the summer command line being run (os.Args without the
|
|
// program), shell-quoted, for the Try line of a hint.
|
|
func cliInvocation() string {
|
|
parts := make([]string, 0, len(os.Args))
|
|
for _, a := range os.Args[1:] {
|
|
if a == "" || strings.ContainsAny(a, " \t\n'\"\\$`*?[]{}()<>|&;#~!") {
|
|
a = "'" + strings.ReplaceAll(a, "'", `'\''`) + "'"
|
|
}
|
|
parts = append(parts, a)
|
|
}
|
|
return strings.Join(parts, " ")
|
|
}
|
|
|
|
// cliCommand is the summer command being run (the first argument that is
|
|
// not an option), or def when there is none, for hint texts.
|
|
func cliCommand(def string) string {
|
|
args := os.Args[1:]
|
|
for i := 0; i < len(args); i++ {
|
|
switch a := args[i]; {
|
|
case a == "--env":
|
|
i++
|
|
case a != "" && !strings.HasPrefix(a, "-"):
|
|
return a
|
|
}
|
|
}
|
|
return def
|
|
}
|
|
|
|
// FindAppDir walks up from start looking for summer.yaml.
|
|
func FindAppDir(start string) (string, error) {
|
|
return findAppDir(start)
|
|
}
|
|
|
|
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 "", bonfire.WithHint(fmt.Errorf("build: no %s found from %s", manifestName, start),
|
|
"summer "+cliCommand("build")+" runs inside an application: a directory with summer.yaml, or one below it.",
|
|
"cd <app>; {bin} "+cliInvocation())
|
|
}
|
|
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
|
|
}
|
|
}
|