package stacks import ( "fmt" "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"` AppInfo AppInfo `yaml:"app_info" json:"app_info"` OptionalConfig []OptionalConfigGroup `yaml:"optional_config" json:"optional_config"` } // AppInfo holds detailed app information for the info page. type AppInfo struct { Tagline string `yaml:"tagline" json:"tagline"` UseCases []string `yaml:"use_cases" json:"use_cases"` FirstSteps []string `yaml:"first_steps" json:"first_steps"` Prerequisites []string `yaml:"prerequisites" json:"prerequisites"` DefaultCreds string `yaml:"default_creds" json:"default_creds"` DocsURL string `yaml:"docs_url" json:"docs_url"` } // OptionalConfigGroup defines a group of optional config fields (e.g., "Metadata providers"). type OptionalConfigGroup struct { Group string `yaml:"group" json:"group"` Description string `yaml:"description" json:"description"` Fields []OptionalConfigField `yaml:"fields" json:"fields"` } // OptionalConfigField defines an individual optional config field. type OptionalConfigField struct { EnvVar string `yaml:"env_var" json:"env_var"` Label string `yaml:"label" json:"label"` Type string `yaml:"type" json:"type"` HelpURL string `yaml:"help_url" json:"help_url"` HelpText string `yaml:"help_text" json:"help_text"` } // ResourceHints describe what the app needs. type ResourceHints struct { MemRequest string `yaml:"mem_request" json:"mem_request"` MemLimit string `yaml:"mem_limit" json:"mem_limit"` 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, subdomain, 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 { fmt.Fprintf(os.Stderr, "[ERROR] Failed to parse .felhom.yml in %s: %v\n", stackDir, err) 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 and SUBDOMAIN fields are always auto-filled/required — mark implicitly for i := range meta.DeployFields { if meta.DeployFields[i].Type == "domain" || meta.DeployFields[i].Type == "subdomain" { 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 } // HasAppInfo returns true if the metadata has any app info content. func (m *Metadata) HasAppInfo() bool { return m.AppInfo.Tagline != "" || len(m.AppInfo.UseCases) > 0 || len(m.AppInfo.FirstSteps) > 0 } // HasOptionalConfig returns true if the metadata has any optional config groups. func (m *Metadata) HasOptionalConfig() bool { return len(m.OptionalConfig) > 0 }