6080170367
- New route: GET /apps/{slug} renders info page with use cases, setup guide, prerequisites
- New API: POST /api/stacks/{name}/optional-config for updating optional env vars
- New structs: AppInfo, OptionalConfigGroup, OptionalConfigField in metadata.go
- UpdateOptionalConfig saves to app.yaml and restarts deployed stacks with new env vars
- Info page template with hero section, screenshots, info cards, optional config form
- Navigation: stack cards now link to /apps/{slug}, deploy page has "Részletek" link
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
172 lines
6.1 KiB
Go
172 lines
6.1 KiB
Go
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"`
|
|
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, 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
|
|
}
|
|
|
|
// 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
|
|
}
|