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 // /workflows// 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) } }