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"` HealthCheck *HealthCheckConfig `yaml:"healthcheck,omitempty" json:"healthcheck,omitempty"` Integrations []IntegrationDef `yaml:"integrations,omitempty" json:"integrations,omitempty"` } // 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"` HungarianUI bool `yaml:"hungarian_ui" json:"hungarian_ui"` } // 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"` } // IntegrationDef defines a single integration this app can provide to a target app. type IntegrationDef struct { Target string `yaml:"target" json:"target"` // target app slug: "filebrowser", "nextcloud" Label string `yaml:"label" json:"label"` // UI label (Hungarian) Description string `yaml:"description" json:"description"` // UI description } // HealthCheckConfig defines controller-side health probe configuration. // When configured, the controller periodically probes the app's container // and overrides the stack state to "unhealthy" if the service is not responding. type HealthCheckConfig struct { Interval string `yaml:"interval" json:"interval"` // e.g. "5m", "30s"; default "5m" Checks []HealthCheckItem `yaml:"checks" json:"checks"` } // HealthCheckItem defines a single health check probe. type HealthCheckItem struct { Type string `yaml:"type" json:"type"` // "http", "api", "tcp" Port int `yaml:"port" json:"port"` Path string `yaml:"path" json:"path"` // for http/api; default "/" Method string `yaml:"method" json:"method"` // for api; default "GET" Expect *HealthCheckExpect `yaml:"expect,omitempty" json:"expect,omitempty"` // for api } // HealthCheckExpect defines expected response content for "api" type checks. type HealthCheckExpect struct { Status int `yaml:"status" json:"status"` // expected HTTP status code BodyContains string `yaml:"body_contains" json:"body_contains"` // string that must appear in response body } // 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" } // Default healthcheck fields if meta.HealthCheck != nil { if meta.HealthCheck.Interval == "" { meta.HealthCheck.Interval = "5m" } for i := range meta.HealthCheck.Checks { if meta.HealthCheck.Checks[i].Path == "" && (meta.HealthCheck.Checks[i].Type == "http" || meta.HealthCheck.Checks[i].Type == "api") { meta.HealthCheck.Checks[i].Path = "/" } if meta.HealthCheck.Checks[i].Method == "" && meta.HealthCheck.Checks[i].Type == "api" { meta.HealthCheck.Checks[i].Method = "GET" } } } // 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 } // HasIntegrations returns true if the metadata defines any integrations. func (m *Metadata) HasIntegrations() bool { return len(m.Integrations) > 0 }