package stacks import ( "os" "path/filepath" "strings" "gopkg.in/yaml.v3" ) // Metadata holds app information parsed from .felhom.yml. type Metadata struct { DisplayName string `yaml:"display_name" json:"display_name"` Description string `yaml:"description" json:"description"` Category string `yaml:"category" json:"category"` Subdomain string `yaml:"subdomain" json:"subdomain"` Slug string `yaml:"slug" json:"slug"` Resources ResourceHints `yaml:"resources" json:"resources"` DeployFields []DeployField `yaml:"deploy_fields" json:"deploy_fields"` } // ResourceHints describe what the app needs. type ResourceHints struct { RAM string `yaml:"ram" json:"ram"` PiCompatible bool `yaml:"pi_compatible" json:"pi_compatible"` NeedsHDD bool `yaml:"needs_hdd" json:"needs_hdd"` } // DeployField defines one configuration field shown during first deployment. type DeployField struct { EnvVar string `yaml:"env_var" json:"env_var"` Label string `yaml:"label" json:"label"` Type string `yaml:"type" json:"type"` // domain, secret, password, path, text, select, boolean Generate string `yaml:"generate" json:"generate"` // e.g., "password:24", "hex:32", "static:admin" Default string `yaml:"default" json:"default"` Required bool `yaml:"required" json:"required"` Placeholder string `yaml:"placeholder" json:"placeholder"` Description string `yaml:"description" json:"description"` LockedAfterDeploy bool `yaml:"locked_after_deploy" json:"locked_after_deploy"` Options []SelectOption `yaml:"options" json:"options,omitempty"` } // SelectOption is a choice for "select" type fields. type SelectOption struct { Value string `yaml:"value" json:"value"` Label string `yaml:"label" json:"label"` } // LoadMetadata reads .felhom.yml from a stack directory. // Returns default metadata if the file doesn't exist. func LoadMetadata(stackDir string) Metadata { meta := Metadata{} path := filepath.Join(stackDir, ".felhom.yml") data, err := os.ReadFile(path) if err != nil { // No metadata file — build defaults from directory name dirName := filepath.Base(stackDir) meta.DisplayName = toTitleCase(strings.ReplaceAll(dirName, "-", " ")) meta.Slug = dirName meta.Category = "tools" return meta } if err := yaml.Unmarshal(data, &meta); err != nil { // Parse error — still return defaults dirName := filepath.Base(stackDir) meta.DisplayName = toTitleCase(strings.ReplaceAll(dirName, "-", " ")) meta.Slug = dirName return meta } // Fill in defaults for missing fields dirName := filepath.Base(stackDir) if meta.Slug == "" { meta.Slug = dirName } if meta.DisplayName == "" { meta.DisplayName = toTitleCase(strings.ReplaceAll(dirName, "-", " ")) } if meta.Category == "" { meta.Category = "tools" } // DOMAIN field is always auto-filled — mark it implicitly required for i := range meta.DeployFields { if meta.DeployFields[i].Type == "domain" { meta.DeployFields[i].Required = true meta.DeployFields[i].LockedAfterDeploy = true } // secret fields are always locked after deploy if meta.DeployFields[i].Type == "secret" { meta.DeployFields[i].LockedAfterDeploy = true } } return meta } // HasDeployFields returns true if the app has any user-facing deploy fields // (i.e., fields beyond auto-filled domain and auto-generated secrets). func (m *Metadata) HasDeployFields() bool { for _, f := range m.DeployFields { if f.Type != "domain" && f.Type != "secret" { return true } } return false } // UserFacingFields returns only fields the user needs to interact with. // Excludes auto-filled (domain) and fully hidden (secret) fields. func (m *Metadata) UserFacingFields() []DeployField { var fields []DeployField for _, f := range m.DeployFields { if f.Type != "domain" && f.Type != "secret" { fields = append(fields, f) } } return fields } // AutoGeneratedFields returns fields that are generated without user input. func (m *Metadata) AutoGeneratedFields() []DeployField { var fields []DeployField for _, f := range m.DeployFields { if f.Type == "secret" || f.Type == "domain" { fields = append(fields, f) } } return fields }