Go 98 lines
package workflow
import (
"embed"
"fmt"
"io/fs"
"os"
"path"
"path/filepath"
"regexp"
"strings"
"github.com/ajhahnde/eeco/internal/config"
)
//go:embed template
var templateFS embed.FS
var workflowNameRE = regexp.MustCompile(`^[a-z0-9][a-z0-9-]*$`)
// namePlaceholder is replaced with the workflow name in every templated
// file as it is written.
const namePlaceholder = "__NAME__"
// Scaffold creates a new user workflow directory at
// <workspace>/workflows/<name>/ from the embedded template and returns
// its absolute path. It writes only inside the workspace (Constraint 1)
// and refuses to overwrite an existing workflow.
func Scaffold(cfg *config.Config, name string) (string, error) {
if cfg == nil {
return "", fmt.Errorf("scaffold: nil config")
}
if !workflowNameRE.MatchString(name) {
return "", fmt.Errorf("workflow name %q: must be lower-kebab-case (a-z, 0-9, '-')", name)
}
workflowsDir := filepath.Join(cfg.Workspace, "workflows")
dst := filepath.Join(workflowsDir, name)
// Defence in depth: the regex already forbids separators and dots,
// but verify the cleaned target stays inside the workspace before
// any write.
rel, err := filepath.Rel(cfg.Workspace, dst)
if err != nil || rel == ".." || strings.HasPrefix(rel, ".."+string(os.PathSeparator)) {
return "", fmt.Errorf("workflow path %q escapes the workspace", name)
}
if _, err := os.Stat(dst); err == nil {
return "", fmt.Errorf("workflow %q already exists at %s", name, dst)
} else if !os.IsNotExist(err) {
return "", err
}
if err := os.MkdirAll(dst, 0o755); err != nil {
return "", fmt.Errorf("scaffold: create dir: %w", err)
}
walkRoot := path.Join("template", profileSubdir(cfg.Profile))
err = fs.WalkDir(templateFS, walkRoot, func(p string, de fs.DirEntry, err error) error {
if err != nil {
return err
}
if de.IsDir() {
return nil
}
data, rerr := templateFS.ReadFile(p)
if rerr != nil {
return rerr
}
body := strings.ReplaceAll(string(data), namePlaceholder, name)
base := filepath.Base(p)
// The runnable entry must be executable; embed cannot carry the
// mode bit, so it is set explicitly here.
mode := fs.FileMode(0o644)
if base == EntryName {
mode = 0o755
}
return os.WriteFile(filepath.Join(dst, base), []byte(body), mode)
})
if err != nil {
return "", fmt.Errorf("scaffold: write template: %w", err)
}
return dst, nil
}
// profileSubdir maps a config.Profile to the template subdirectory its
// scaffold uses. Profiles without a dedicated template fall back to
// "generic" — identical to the pre-per-profile-templates behaviour,
// where every project got the same stub.
func profileSubdir(p config.Profile) string {
switch p {
case config.ProfileGo, config.ProfilePython, config.ProfileGeneric:
return string(p)
default:
return string(config.ProfileGeneric)
}
}