Quellcode durchsuchen

added more updates

xcad vor 9 Monaten
Ursprung
Commit
b4bc24a17f
12 geänderte Dateien mit 982 neuen und 575 gelöschten Zeilen
  1. 2 2
      WARP.md
  2. 9 9
      cli/__main__.py
  3. 0 3
      cli/core/args.py
  4. 12 87
      cli/core/library.py
  5. 182 76
      cli/core/module.py
  6. 106 151
      cli/core/prompt.py
  7. 4 23
      cli/core/registry.py
  8. 125 0
      cli/core/renderers.py
  9. 196 90
      cli/core/template.py
  10. 124 44
      cli/core/variables.py
  11. 193 60
      cli/modules/compose.py
  12. 29 30
      library/compose/n8n/compose.yaml

+ 2 - 2
WARP.md

@@ -79,7 +79,7 @@ boilerplate --log-level DEBUG [command]
 - `BaseModule`: Abstract base class providing shared commands (config management)
 - `BaseModule`: Abstract base class providing shared commands (config management)
 - Module Commands: Each module implements technology-specific operations
 - Module Commands: Each module implements technology-specific operations
 - Template Library: Structured collection of boilerplates with metadata
 - Template Library: Structured collection of boilerplates with metadata
-- `Template.vars_map`: Unified variables metadata and defaults (merged from module variables_spec and frontmatter)
+- `Template.variable_sections`: Ordered sections with merged metadata and defaults (combined from module variable sections and template frontmatter)
 - `PromptHandler`: Interactive prompting based on vars_map and template usage
 - `PromptHandler`: Interactive prompting based on vars_map and template usage
 
 
 ### Template Format
 ### Template Format
@@ -113,7 +113,7 @@ The codebase has been optimized following the ARCHITECTURE_OPTIMIZATION.md plan:
 
 
 ### Simplified Variable System (2025-09)
 ### Simplified Variable System (2025-09)
 - Replaced custom registry with a unified variables map (vars_map) on Template
 - Replaced custom registry with a unified variables map (vars_map) on Template
-- Module variables are defined as a simple dict (variables_spec) and merged with template frontmatter
+- Module variables are defined via nested `variable_sections` blocks and merged with template frontmatter sections
 - Dotted names (e.g., traefik.tls.certresolver) imply hierarchy for prompting/sections
 - Dotted names (e.g., traefik.tls.certresolver) imply hierarchy for prompting/sections
 - No separate Variable/Registry classes needed
 - No separate Variable/Registry classes needed
 
 

+ 9 - 9
cli/__main__.py

@@ -94,27 +94,27 @@ def init_app():
           failed_imports.append(error_info)
           failed_imports.append(error_info)
           logger.error(error_info)
           logger.error(error_info)
     
     
-    # Register modules with app
-    modules = registry.create_instances()
-    logger.debug(f"Registering {len(modules)} discovered modules")
+    # Register modules with app lazily
+    module_classes = list(registry.iter_module_classes())
+    logger.debug(f"Registering {len(module_classes)} discovered modules")
     
     
-    for module in modules:
+    for name, module_cls in module_classes:
       try:
       try:
-        logger.debug(f"Registering module: {module.__class__.__name__}")
-        module.register_cli(app)
+        logger.debug(f"Registering module class: {module_cls.__name__}")
+        module_cls.register_cli(app)
       except Exception as e:
       except Exception as e:
-        error_info = f"Registration failed for '{module.__class__.__name__}': {str(e)}"
+        error_info = f"Registration failed for '{module_cls.__name__}': {str(e)}"
         failed_registrations.append(error_info)
         failed_registrations.append(error_info)
         # Log warning but don't raise exception for individual module failures
         # Log warning but don't raise exception for individual module failures
         logger.warning(error_info)
         logger.warning(error_info)
         console.print(f"[yellow]Warning:[/yellow] {error_info}")
         console.print(f"[yellow]Warning:[/yellow] {error_info}")
     
     
     # If we have no modules registered at all, that's a critical error
     # If we have no modules registered at all, that's a critical error
-    if not modules and not failed_imports:
+    if not module_classes and not failed_imports:
       raise RuntimeError("No modules found to register")
       raise RuntimeError("No modules found to register")
     
     
     # Log summary
     # Log summary
-    successful_modules = len(modules) - len(failed_registrations)
+    successful_modules = len(module_classes) - len(failed_registrations)
     logger.info(f"Application initialized: {successful_modules} modules registered successfully")
     logger.info(f"Application initialized: {successful_modules} modules registered successfully")
     
     
     if failed_imports:
     if failed_imports:

+ 0 - 3
cli/core/args.py

@@ -1,7 +1,4 @@
 from typing import Dict, List
 from typing import Dict, List
-import logging
-
-logger = logging.getLogger(__name__)
 
 
 # NOTE: This helper supports both syntaxes:
 # NOTE: This helper supports both syntaxes:
 #   --var KEY=VALUE
 #   --var KEY=VALUE

+ 12 - 87
cli/core/library.py

@@ -1,7 +1,5 @@
 from pathlib import Path
 from pathlib import Path
 import logging
 import logging
-from .template import Template
-
 logger = logging.getLogger(__name__)
 logger = logging.getLogger(__name__)
 
 
 
 
@@ -52,7 +50,7 @@ class Library:
         raise FileNotFoundError(f"Template '{template_id}' found but missing any of the required files: {files}")
         raise FileNotFoundError(f"Template '{template_id}' found but missing any of the required files: {files}")
     
     
     logger.debug(f"Found template '{template_id}' at: {template_path}")
     logger.debug(f"Found template '{template_id}' at: {template_path}")
-    return template_path
+    return template_path, self.name
 
 
 
 
   def find(self, module_name, files, sort_results=False):
   def find(self, module_name, files, sort_results=False):
@@ -94,18 +92,18 @@ class Library:
               if file_path.exists():
               if file_path.exists():
                 has_any_file = True
                 has_any_file = True
                 break
                 break
-            
+
             if has_any_file:
             if has_any_file:
-              template_dirs.append(item)
+              template_dirs.append((item, self.name))
           else:
           else:
             # No file requirements, include all directories
             # No file requirements, include all directories
-            template_dirs.append(item)
+            template_dirs.append((item, self.name))
     except PermissionError as e:
     except PermissionError as e:
       raise FileNotFoundError(f"Permission denied accessing module '{module_name}' in library '{self.name}': {e}")
       raise FileNotFoundError(f"Permission denied accessing module '{module_name}' in library '{self.name}': {e}")
     
     
     # Sort if requested
     # Sort if requested
     if sort_results:
     if sort_results:
-      template_dirs.sort(key=lambda x: x.name.lower())
+      template_dirs.sort(key=lambda x: x[0].name.lower())
     
     
     logger.debug(f"Found {len(template_dirs)} templates in module '{module_name}'")
     logger.debug(f"Found {len(template_dirs)} templates in module '{module_name}'")
     return template_dirs
     return template_dirs
@@ -139,9 +137,9 @@ class LibraryManager:
     
     
     for library in sorted(self.libraries, key=lambda x: x.priority, reverse=True):
     for library in sorted(self.libraries, key=lambda x: x.priority, reverse=True):
       try:
       try:
-        template_path = library.find_by_id(module_name, files, template_id)
+        template_path, lib_name = library.find_by_id(module_name, files, template_id)
         logger.debug(f"Found template '{template_id}' in library '{library.name}'")
         logger.debug(f"Found template '{template_id}' in library '{library.name}'")
-        return template_path
+        return template_path, lib_name
       except FileNotFoundError:
       except FileNotFoundError:
         # Continue searching in next library
         # Continue searching in next library
         continue
         continue
@@ -178,88 +176,15 @@ class LibraryManager:
     seen_names = set()
     seen_names = set()
     unique_templates = []
     unique_templates = []
     for template in all_templates:
     for template in all_templates:
-      if template.name not in seen_names:
-        unique_templates.append(template)
-        seen_names.add(template.name)
+      name, library_name = template
+      if name.name not in seen_names:
+        unique_templates.append((name, library_name))
+        seen_names.add(name.name)
     
     
     # Sort if requested
     # Sort if requested
     if sort_results:
     if sort_results:
-      unique_templates.sort(key=lambda x: x.name.lower())
+      unique_templates.sort(key=lambda x: x[0].name.lower())
     
     
     logger.debug(f"Found {len(unique_templates)} unique templates total")
     logger.debug(f"Found {len(unique_templates)} unique templates total")
     return unique_templates
     return unique_templates
   
   
-  def load_template_by_id(self, module_name, files, template_id, module_variables=None):
-    """Load a template by its ID as a Template object.
-    
-    Args:
-        module_name: The module name (e.g., 'compose', 'terraform')
-        files: List of files to look for in the template directory
-        template_id: The template ID to find
-        module_variables: Optional dict of module-specific variables to merge
-    
-    Returns:
-        Template object if found, None otherwise
-    """
-    template_path = self.find_by_id(module_name, files, template_id)
-    if not template_path:
-      return None
-    
-    # Find the actual template file in the directory
-    template_file = None
-    for file_name in files:
-      candidate_file = template_path / file_name
-      if candidate_file.exists():
-        template_file = candidate_file
-        break
-    
-    if not template_file:
-      logger.error(f"Template '{template_id}' directory found but no template file exists")
-      return None
-    
-    # Load the template from file
-    try:
-      if module_variables:
-        logger.debug(f"Passing {len(module_variables)} module variables to template: {list(module_variables.keys())}")
-      else:
-        logger.debug("No module variables provided")
-      template = Template.from_file(template_file, module_variables=module_variables)
-      return template
-    except Exception as e:
-      logger.error(f"Error loading template '{template_id}': {e}")
-      return None
-  
-  def load_templates(self, module_name, files, sort_results=False, module_variables=None):
-    """Load all templates for a module as Template objects.
-    
-    Args:
-        module_name: The module name (e.g., 'compose', 'terraform')
-        files: List of files to look for in template directories
-        sort_results: Whether to return results sorted alphabetically
-        module_variables: Optional dict of module-specific variables to merge
-    
-    Returns:
-        List of Template objects
-    """
-    template_paths = self.find(module_name, files, sort_results)
-    
-    templates = []
-    for template_path in template_paths:
-      try:
-        # Find the actual template file in the directory
-        template_file = None
-        for file_name in files:
-          candidate_file = template_path / file_name
-          if candidate_file.exists():
-            template_file = candidate_file
-            break
-        
-        if template_file:
-          template = Template.from_file(template_file, module_variables=module_variables)
-          templates.append(template)
-        else:
-          logger.warning(f"Template directory '{template_path.name}' found but no template file exists")
-      except Exception as e:
-        logger.warning(f"Error loading template from {template_path}: {e}")
-    
-    return templates

+ 182 - 76
cli/core/module.py

@@ -4,11 +4,15 @@ from typing import Optional, Dict, Any, List
 import logging
 import logging
 from typer import Typer, Option, Argument, Context
 from typer import Typer, Option, Argument, Context
 from rich.console import Console
 from rich.console import Console
+from rich.table import Table
+from rich.panel import Panel
+from rich.rule import Rule
 
 
 from .library import LibraryManager
 from .library import LibraryManager
 from .template import Template
 from .template import Template
 from .prompt import PromptHandler
 from .prompt import PromptHandler
 from .args import parse_var_inputs
 from .args import parse_var_inputs
+from .renderers import render_variable_table, render_template_list_table
 
 
 logger = logging.getLogger(__name__)
 logger = logging.getLogger(__name__)
 console = Console()
 console = Console()
@@ -36,80 +40,64 @@ class Module(ABC):
     if hasattr(self, '_init_variables'):
     if hasattr(self, '_init_variables'):
       logger.debug(f"Module '{self.name}' has variable initialization method")
       logger.debug(f"Module '{self.name}' has variable initialization method")
       self._init_variables()
       self._init_variables()
-    
-    self.metadata = self._build_metadata()
     logger.info(f"Module '{self.name}' initialization completed successfully")
     logger.info(f"Module '{self.name}' initialization completed successfully")
-  
-  def _build_metadata(self) -> Dict[str, Any]:
-    """Build metadata from class attributes."""
-    metadata = {}
-    
-    # Add categories if defined
-    if hasattr(self, 'categories'):
-      metadata['categories'] = self.categories
-    
-    # Add variable metadata if defined
-    if hasattr(self, 'variable_metadata'):
-      metadata['variables'] = self.variable_metadata
-    
-    return metadata
 
 
   def list(self):
   def list(self):
     """List all templates."""
     """List all templates."""
     logger.debug(f"Listing templates for module '{self.name}'")
     logger.debug(f"Listing templates for module '{self.name}'")
-    templates = self.libraries.load_templates(
-      self.name, 
-      self.files, 
-      sort_results=True,
-      module_variables=getattr(self, 'variables_spec', {})
-    )
+    templates = []
+    module_sections = getattr(self, 'variable_sections', {})
+
+    entries = self.libraries.find(self.name, self.files, sort_results=True)
+    for template_dir, library_name in entries:
+      template = self._load_template_from_dir(template_dir, library_name, module_sections)
+      if template:
+        templates.append(template)
     
     
     if templates:
     if templates:
       logger.info(f"Listing {len(templates)} templates for module '{self.name}'")
       logger.info(f"Listing {len(templates)} templates for module '{self.name}'")
-      for template in templates:
-        console.print(f"[cyan]{template.id}[/cyan] - {template.name}")
+      table = render_template_list_table(templates, self.name, include_library=False)
+      console.print(table)
     else:
     else:
       logger.info(f"No templates found for module '{self.name}'")
       logger.info(f"No templates found for module '{self.name}'")
-    
+
     return templates
     return templates
 
 
-  def show(self, id: str = Argument(..., help="Template ID")):
+  def show(
+    self,
+    id: str,
+    show_content: bool = False,
+  ):
     """Show template details."""
     """Show template details."""
     logger.debug(f"Showing template '{id}' from module '{self.name}'")
     logger.debug(f"Showing template '{id}' from module '{self.name}'")
-    template = self.libraries.load_template_by_id(
-      self.name, 
-      self.files, 
-      id,
-      module_variables=getattr(self, 'variables_spec', {})
-    )
-    
-    if not template:
-      logger.debug(f"Template '{id}' not found in module '{self.name}'")
-      raise FileNotFoundError(f"Template '{id}' not found in module '{self.name}'")
+    template = self._load_template_by_id(id)
+
+    header_title = template.name or template.id
+    subtitle_parts = [template.id]
+    if template.version:
+      subtitle_parts.append(f"v{template.version}")
+    if template.library:
+      subtitle_parts.append(f"library: {template.library}")
+    subtitle = " • ".join(subtitle_parts)
+
+    description = template.description or "No description available"
+    console.print(Panel(description, title=header_title, subtitle=subtitle, border_style="magenta"))
+
+    metadata_table = Table.grid(padding=(0, 2))
+    metadata_table.add_column(style="dim", justify="right")
+    metadata_table.add_column(style="white")
+    metadata_table.add_row("Author", template.author or "-")
+    metadata_table.add_row("Date", template.date or "-")
+    metadata_table.add_row("Tags", ", ".join(template.tags) if template.tags else "-")
+    metadata_table.add_row("Files", ", ".join(template.files) if template.files else template.file_path.name)
+    console.print(Panel(metadata_table, title="Details", border_style="cyan", expand=False))
 
 
-    # Header
-    version = f" v{template.version}" if template.version else ""
-    console.print(f"[bold magenta]{template.name} ({template.id}{version})[/bold magenta]")
-    console.print(f"[dim white]{template.description}[/dim white]\n")
-    
-    # Metadata (only print if exists)
-    metadata = [
-      ("Author", template.author),
-      ("Date", template.date),
-      ("Tags", ', '.join(template.tags) if template.tags else None)
-    ]
-    
-    for label, value in metadata:
-      if value:
-        console.print(f"{label}: [cyan]{value}[/cyan]")
-    
-    # Variables (show template variables)
     if template.variables:
     if template.variables:
-      console.print(f"Variables: [cyan]{', '.join(template.variables.get_variable_names())}[/cyan]")
-    
-    # Content
-    if template.content:
-      print(f"\n{template.content}")
+      console.print(render_variable_table(template.variables, sections=template.variable_sections))
+
+    if show_content and template.content:
+      console.print(Rule("Template Content"))
+      console.print(template.content)
 
 
 
 
   def generate(
   def generate(
@@ -128,16 +116,7 @@ class Module(ABC):
     """
     """
 
 
     logger.info(f"Starting generation for template '{id}' from module '{self.name}'")
     logger.info(f"Starting generation for template '{id}' from module '{self.name}'")
-    template = self.libraries.load_template_by_id(
-      self.name, 
-      self.files, 
-      id,
-      module_variables=getattr(self, 'variables_spec', {})
-    )
-    
-    if not template:
-      logger.error(f"Template '{id}' not found for generation in module '{self.name}'")
-      raise FileNotFoundError(f"Template '{id}' not found in module '{self.name}'")
+    template = self._load_template_by_id(id)
 
 
     # Build variable overrides from Typer-collected options and any extra args
     # Build variable overrides from Typer-collected options and any extra args
     extra_args = []
     extra_args = []
@@ -163,6 +142,7 @@ class Module(ABC):
         module_name=self.name,
         module_name=self.name,
         template_var_order=template.template_var_names,
         template_var_order=template.template_var_names,
         module_var_order=template.module_var_names,
         module_var_order=template.module_var_names,
+        sections=template.variable_sections,
       )
       )
       
       
       if collected_values:
       if collected_values:
@@ -178,6 +158,7 @@ class Module(ABC):
 
 
     # Render template with collected values
     # Render template with collected values
     try:
     try:
+      variable_values = self._apply_common_defaults(template, variable_values)
       rendered_content = template.render(variable_values)
       rendered_content = template.render(variable_values)
       logger.info(f"Successfully rendered template '{id}'")
       logger.info(f"Successfully rendered template '{id}'")
       
       
@@ -201,13 +182,138 @@ class Module(ABC):
       console.print(f"[red]Error generating template: {str(e)}[/red]")
       console.print(f"[red]Error generating template: {str(e)}[/red]")
       raise
       raise
 
 
-  def register_cli(self, app: Typer):
-    """Register module commands with the main app."""
-    logger.debug(f"Registering CLI commands for module '{self.name}'")
+  @classmethod
+  def register_cli(cls, app: Typer):
+    """Register module commands with the main app using lazy instantiation."""
+    logger.debug(f"Registering CLI commands for module '{cls.name}'")
+
+    def _load_module() -> "Module":
+      logger.debug(f"Lazily instantiating module '{cls.name}'")
+      return cls()
+
+    def _invoke(method_name: str, *args, **kwargs):
+      module = _load_module()
+      method = getattr(module, method_name)
+      return method(*args, **kwargs)
+
     module_app = Typer()
     module_app = Typer()
-    module_app.command()(self.list)
-    module_app.command()(self.show)
+
+    @module_app.command()
+    def list():
+      return _invoke("list")
+
+    @module_app.command()
+    def show(
+      id: str = Argument(..., help="Template ID"),
+      show_content: bool = Option(
+        False,
+        "--show-content/--hide-content",
+        "-c/-C",
+        help="Display full template content",
+      ),
+    ):
+      return _invoke("show", id, show_content)
+
     # Allow extra args so we can parse --var overrides ourselves
     # Allow extra args so we can parse --var overrides ourselves
-    module_app.command(context_settings={"allow_extra_args": True, "ignore_unknown_options": True})(self.generate)
-    app.add_typer(module_app, name=self.name, help=self.description)
-    logger.info(f"Module '{self.name}' CLI commands registered")
+    @module_app.command(context_settings={"allow_extra_args": True, "ignore_unknown_options": True})
+    def generate(
+      id: str = Argument(..., help="Template ID"),
+      out: Optional[Path] = Option(None, "--out", "-o"),
+      interactive: bool = Option(
+        True,
+        "--interactive/--no-interactive",
+        "-i/-n",
+        help="Enable interactive prompting for variables",
+      ),
+      var: Optional[List[str]] = Option(
+        None,
+        "--var",
+        "-v",
+        help="Variable override (repeatable). Use KEY=VALUE or --var KEY VALUE",
+      ),
+      ctx: Context = None,
+    ):
+      return _invoke(
+        "generate",
+        id,
+        out,
+        interactive,
+        var,
+        ctx,
+      )
+
+    app.add_typer(module_app, name=cls.name, help=cls.description)
+    logger.info(f"Module '{cls.name}' CLI commands registered")
+
+  def _apply_common_defaults(self, template: Template, values: Dict[str, Any]) -> Dict[str, Any]:
+    """Ensure core variables have sensible defaults for non-interactive runs."""
+    defaults = {}
+
+    def needs_value(key: str) -> bool:
+      if key not in values:
+        return True
+      current = values[key]
+      return current is None or (isinstance(current, str) and current.strip() == "")
+
+    if template.variables.get_variable("service_name") and needs_value("service_name"):
+      defaults["service_name"] = template.id
+
+    if template.variables.get_variable("container_name") and needs_value("container_name"):
+      defaults["container_name"] = template.id
+
+    if template.variables.get_variable("container_timezone") and needs_value("container_timezone"):
+      defaults["container_timezone"] = "UTC"
+
+    if defaults:
+      logger.debug(f"Applying common defaults: {defaults}")
+      for key, value in defaults.items():
+        values[key] = value
+
+    return values
+
+  def _load_template_by_id(self, template_id: str) -> Template:
+    result = self.libraries.find_by_id(self.name, self.files, template_id)
+    if not result:
+      logger.debug(f"Template '{template_id}' not found in module '{self.name}'")
+      raise FileNotFoundError(f"Template '{template_id}' not found in module '{self.name}'")
+
+    template_dir, library_name = result
+    template = self._load_template_from_dir(
+      template_dir,
+      library_name,
+      getattr(self, 'variable_sections', {}),
+    )
+
+    if not template:
+      raise FileNotFoundError(f"Template file for '{template_id}' not found in module '{self.name}'")
+
+    return template
+
+  def _load_template_from_dir(
+    self,
+    template_dir: Path,
+    library_name: str,
+    module_sections: Dict[str, Any],
+  ) -> Optional[Template]:
+    template_file = self._resolve_template_file(template_dir)
+    if not template_file:
+      logger.warning(f"Template directory '{template_dir}' missing expected files {self.files}")
+      return None
+
+    try:
+      template = Template.from_file(
+        template_file,
+        module_sections=module_sections,
+        library_name=library_name,
+      )
+      return template
+    except Exception as exc:
+      logger.error(f"Failed to load template from {template_file}: {exc}")
+      return None
+
+  def _resolve_template_file(self, template_dir: Path) -> Optional[Path]:
+    for file_name in self.files:
+      candidate = template_dir / file_name
+      if candidate.exists():
+        return candidate
+    return None

+ 106 - 151
cli/core/prompt.py

@@ -1,15 +1,14 @@
-from typing import Dict, Any, List
+from typing import Dict, Any, List, Optional
+from collections import OrderedDict
 import logging
 import logging
 from rich.console import Console
 from rich.console import Console
 from rich.prompt import Prompt, Confirm, IntPrompt
 from rich.prompt import Prompt, Confirm, IntPrompt
 from rich.table import Table
 from rich.table import Table
-from rich.panel import Panel
-from rich.text import Text
 
 
 from .variables import Variable, VariableCollection
 from .variables import Variable, VariableCollection
+from .renderers import render_variable_table
 
 
 logger = logging.getLogger(__name__)
 logger = logging.getLogger(__name__)
-console = Console()
 
 
 
 
 class PromptHandler:
 class PromptHandler:
@@ -31,156 +30,99 @@ class PromptHandler:
     module_name: str = "",
     module_name: str = "",
     template_var_order: List[str] = None,
     template_var_order: List[str] = None,
     module_var_order: List[str] = None,
     module_var_order: List[str] = None,
+    sections: Optional[OrderedDict[str, Dict[str, Any]]] = None,
   ) -> Dict[str, Any]:
   ) -> Dict[str, Any]:
     """Collect values for variables that need input with an ordered, sectioned flow.
     """Collect values for variables that need input with an ordered, sectioned flow.
 
 
-    Sections (in order):
-    1) General (always)
-    2) <Template Name> Specific (always) - variables defined in frontmatter
-    3) Each *_enabled section (ask to enable -> then prompt variables)
+    When sections metadata is provided, it defines the order, prompt text, and
+    toggle behavior for each section. Otherwise all variables are shown in a
+    single "General" group.
     """
     """
     template_var_order = template_var_order or []
     template_var_order = template_var_order or []
     module_var_order = module_var_order or []
     module_var_order = module_var_order or []
 
 
-    # Build lookup maps for easy access and ordering
-    vars_map = variables.variables
+    section_meta_list: List[Dict[str, Any]] = []
+    if sections:
+      section_meta_list = list(sections.values())
+    else:
+      section_meta_list = [
+        {
+          "title": "General",
+          "variables": variables.get_variable_names(),
+          "toggle": None,
+          "prompt": None,
+          "description": None,
+        }
+      ]
+
+    self._display_current_values(variables, sections)
+
+    if not Confirm.ask("Customize any settings?", default=False):
+      logger.info("User opted to keep all default values")
+      return {}
 
 
-    # Partition variables
-    toggles: Dict[str, Variable] = {}
-    section_vars: Dict[str, List[Variable]] = {}
-    general_vars: List[Variable] = []
-    template_specific_vars: List[Variable] = []
+    collected: Dict[str, Any] = {}
 
 
-    # Determine which names are template-specific by provided order
-    template_specific_names = set(template_var_order)
+    for section_meta in section_meta_list:
+      title = section_meta.get("title") or "General"
+      prompt_text = section_meta.get("prompt")
+      toggle_name = section_meta.get("toggle")
+      description_text = section_meta.get("description")
+      var_names = section_meta.get("variables", [])
 
 
-    for name, var in vars_map.items():
-      # Classify template-specific first
-      if name in template_specific_names:
-        template_specific_vars.append(var)
-        continue
+      # Filter to existing variables
+      variable_objects = [variables.get_variable(name) for name in var_names]
+      variable_objects = [var for var in variable_objects if var is not None]
 
 
-      # Identify section toggles by *_enabled convention
-      if name.endswith("_enabled") and var.type == "bool":
-        section = name[: -len("_enabled")]
-        toggles[section] = var
-        section_vars.setdefault(section, [])
+      if not variable_objects:
         continue
         continue
 
 
-      # If it begins with a section prefix, associate with that section
-      prefix = name.split("_", 1)[0]
-      if prefix in toggles:
-        section_vars.setdefault(prefix, []).append(var)
-      else:
-        general_vars.append(var)
-
-    # Helper: compute which need values
-    def needs_value(v: Variable) -> bool:
-      return v.value is None or v.value == ""
-
-    # Order preservation based on source order lists
-    def order_by(names: List[str], items: List[Variable]) -> List[Variable]:
-      order_index = {n: i for i, n in enumerate(names)}
-      return sorted(items, key=lambda v: order_index.get(v.name, 1_000_000))
-
-    general_needing = [v for v in general_vars if needs_value(v)]
-    template_needing = [v for v in template_specific_vars if needs_value(v)]
-    sections_needing = {s: [v for v in section_vars.get(s, []) if needs_value(v)] for s in section_vars.keys()}
-
-    # Count for header
-    total_needed = len(general_needing) + len(template_needing) + sum(len(lst) for lst in sections_needing.values())
-
-    collected: Dict[str, Any] = {}
-
-    # General (always)
-    general_needing = order_by(module_var_order, general_needing)
-    if general_needing:
-      self.console.print("[bold magenta]General Configuration[/bold magenta]")
-      self.console.print("─" * 50, style="dim")
-      # Required first (no default)
-      for var in [v for v in general_needing if needs_value(v)]:
-        collected[var.name] = self._prompt_required(var)
-      # Show current values (non-empty), ask if user wants to change
-      current = [v for v in general_vars if not needs_value(v)]
-      self._maybe_reconfigure(current, collected)
-      self.console.print()
+      toggle_var = None
+      if toggle_name:
+        toggle_var = variables.get_variable(toggle_name)
+        if toggle_var is None:
+          toggle_var = next((var for var in variable_objects if var.name == toggle_name), None)
+
+      if toggle_var:
+        enabled = self._prompt_bool(
+          prompt_text or f"Enable {title}?",
+          toggle_var.get_typed_value(),
+        )
+        if enabled != bool(toggle_var.get_typed_value()):
+          collected[toggle_var.name] = enabled
+          toggle_var.value = enabled
+        if not enabled:
+          continue
+      elif prompt_text:
+        self.console.print(prompt_text, style="dim")
 
 
-    # Template-specific (always)
-    template_needing = order_by(template_var_order, template_needing)
-    if template_specific_vars:
-      self.console.print(f"[bold magenta]{template_name} Specific[/bold magenta]")
+      self.console.print(f"[bold magenta]{title}[/bold magenta]")
       self.console.print("─" * 50, style="dim")
       self.console.print("─" * 50, style="dim")
-      # Warning on overrides
-      for v in template_specific_vars:
-        if v.name in module_var_order:
-          self.console.print(f"[yellow]Warning:[/yellow] Template Specific variable '{v.name}' is also defined by {module_name}; template value takes precedence.")
-      # Required first
-      for var in [v for v in template_needing if needs_value(v)]:
-        collected[var.name] = self._prompt_required(var)
-      # Reconfigure current values
-      current = [v for v in template_specific_vars if not needs_value(v)]
-      self._maybe_reconfigure(current, collected)
-      self.console.print()
+      if description_text:
+        self.console.print(f"[dim]{description_text}[/dim]")
 
 
-    # Toggle sections in declaration order
-    for section in toggles.keys():
-      toggle_var = toggles[section]
-      # Ask to enable (general/template are always-on)
-      enabled = self._prompt_bool(f"Enable {section.replace('_', ' ').title()}?", toggle_var.get_typed_value())
-      collected[toggle_var.name] = enabled
-      if not enabled:
-        continue
+      for var in variable_objects:
+        if toggle_var and var.name == toggle_var.name:
+          continue
+        current = var.get_typed_value()
+        new_value = self._prompt_variable(var)
+        if new_value != current:
+          collected[var.name] = new_value
+          var.value = new_value
 
 
-      # Required first
-      needing = order_by(module_var_order, sections_needing.get(section, []))
-      if needing or section_vars.get(section):
-        self.console.print(f"[bold magenta]{section.replace('_', ' ').title()} Configuration[/bold magenta]")
-        self.console.print("─" * 50, style="dim")
-        for var in [v for v in needing if needs_value(v)]:
-          collected[var.name] = self._prompt_required(var)
-        # Reconfigure
-        current = [v for v in section_vars.get(section, []) if not needs_value(v)]
-        self._maybe_reconfigure(current, collected)
-        self.console.print()
+      self.console.print()
 
 
     logger.info(f"Variable collection completed. Collected {len(collected)} values")
     logger.info(f"Variable collection completed. Collected {len(collected)} values")
     return collected
     return collected
 
 
-  def _prompt_required(self, variable: Variable) -> Any:
-    """Prompt for a required variable; empty answers are not allowed."""
-    while True:
-      val = self._prompt_variable(variable)
-      if val is None or (isinstance(val, str) and val.strip() == ""):
-        self.console.print("[red]This field is required. Please enter a value.[/red]")
-        continue
-      return val
-
-  def _maybe_reconfigure(self, variables: List[Variable], collected: Dict[str, Any]):
-    """Show current values inline and ask if user wants to change them; if yes, prompt with defaults."""
-    vars_with_values = [(v.name, v.get_typed_value()) for v in variables]
-    if not vars_with_values:
-      return
-
-    # Build concise single-line presentation: Current Values: var=value, var2=value
-    line = Text()
-    line.append("Current Values: ", style="white")
-    for idx, (name, value) in enumerate(vars_with_values):
-      if idx > 0:
-        line.append(", ", style="white")
-      line.append(name, style="cyan")
-      line.append("=", style="white")
-      display = str(value) if value is not None else ""
-      line.append(display, style="green")
-    self.console.print(line)
-
-    if Confirm.ask("Change any of these values?", default=False):
-      for v in variables:
-        default_before = v.value
-        new_val = self._prompt_variable(v)
-        # If user pressed enter with empty string for str type, keep previous
-        if new_val == "" and isinstance(default_before, str):
-          continue
-        collected[v.name] = new_val
+  def _display_current_values(
+    self,
+    variables: VariableCollection,
+    sections: Optional[OrderedDict[str, Dict[str, Any]]] = None,
+  ) -> None:
+    self.console.print(
+      render_variable_table(variables, title="Current Defaults", sections=sections)
+    )
 
 
 
 
   def _prompt_variable(self, variable: Variable) -> Any:
   def _prompt_variable(self, variable: Variable) -> Any:
@@ -193,26 +135,42 @@ class PromptHandler:
     if variable.type in ["hostname", "email", "url"]:
     if variable.type in ["hostname", "email", "url"]:
       prompt_text += f" ({variable.type})"
       prompt_text += f" ({variable.type})"
 
 
-    # Show default value if available
-    default_value = variable.value
-
     try:
     try:
-      if variable.type == "bool":
-        return self._prompt_bool(prompt_text, default_value)
-      if variable.type == "int":
-        return self._prompt_int(prompt_text, default_value)
-      if variable.type == "enum":
-        return self._prompt_enum(prompt_text, variable.options or [], default_value)
-      return self._prompt_string(prompt_text, default_value)
-    except Exception as e:
-      logger.error(f"Error prompting for variable '{variable.name}': {str(e)}")
-      return self._prompt_string(prompt_text, default_value)
+      default_value = variable.get_typed_value()
+    except ValueError:
+      default_value = variable.value
+
+    handler = self._get_prompt_handler(variable)
+
+    while True:
+      try:
+        raw = handler(prompt_text, default_value)
+        return variable.convert(raw)
+      except ValueError as exc:
+        self._show_validation_error(str(exc))
+      except Exception as e:
+        logger.error(f"Error prompting for variable '{variable.name}': {str(e)}")
+        default_value = variable.value
+        handler = self._get_prompt_handler(variable)
+
+  def _get_prompt_handler(self, variable: Variable):
+    """Return the prompt function for a variable type."""
+    if variable.type == "enum":
+      return lambda text, default: self._prompt_enum(text, variable.options or [], default)
+    return {
+      "bool": self._prompt_bool,
+      "int": self._prompt_int,
+    }.get(variable.type, self._prompt_string)
+
+  def _show_validation_error(self, message: str) -> None:
+    """Display validation feedback consistently."""
+    self.console.print(f"[red]{message}[/red]")
 
 
   def _prompt_string(self, prompt_text: str, default: Any = None) -> str:
   def _prompt_string(self, prompt_text: str, default: Any = None) -> str:
     value = Prompt.ask(
     value = Prompt.ask(
       prompt_text,
       prompt_text,
       default=str(default) if default is not None else "",
       default=str(default) if default is not None else "",
-      show_default=False,
+      show_default=True,
     )
     )
     return value.strip() if value else ""
     return value.strip() if value else ""
 
 
@@ -246,7 +204,7 @@ class PromptHandler:
       value = Prompt.ask(
       value = Prompt.ask(
         prompt_text,
         prompt_text,
         default=str(default) if default else options[0],
         default=str(default) if default else options[0],
-        show_default=False,
+        show_default=True,
       )
       )
       if value in options:
       if value in options:
         return value
         return value
@@ -286,6 +244,3 @@ class PromptHandler:
     self.console.print()
     self.console.print()
     self.console.print(table)
     self.console.print(table)
     self.console.print()
     self.console.print()
-
-# TODO: Add validation hooks (URL, email, hostname) if needed
-# NOTE: Keep prompts single-line, clean, and with proper log levels per rules

+ 4 - 23
cli/core/registry.py

@@ -23,30 +23,11 @@ class ModuleRegistry:
     logger.info(f"Registered module '{module_class.name}' (total modules: {len(self._modules)})")
     logger.info(f"Registered module '{module_class.name}' (total modules: {len(self._modules)})")
     logger.debug(f"Module '{module_class.name}' details: description='{module_class.description}', files={module_class.files}")
     logger.debug(f"Module '{module_class.name}' details: description='{module_class.description}', files={module_class.files}")
   
   
-  def create_instances(self):
-    """Create instances of all registered modules."""
-    logger.info(f"Creating instances for {len(self._modules)} registered modules")
-    instances = []
-    failed_modules = []
-    
+  def iter_module_classes(self):
+    """Yield registered module classes without instantiating them."""
+    logger.debug(f"Iterating over {len(self._modules)} registered module classes")
     for name in sorted(self._modules.keys()):
     for name in sorted(self._modules.keys()):
-      try:
-        logger.debug(f"Attempting to create instance of module '{name}'")
-        instance = self._modules[name]()
-        instances.append(instance)
-        logger.debug(f"Successfully instantiated module '{name}'")
-      except Exception as e:
-        logger.error(f"Failed to instantiate module '{name}': {e}")
-        failed_modules.append(name)
-        print(f"Warning: Could not instantiate {name}: {e}")
-    
-    if failed_modules:
-      logger.warning(f"Failed to instantiate {len(failed_modules)} modules: {failed_modules}")
-    
-    logger.info(f"Successfully created {len(instances)} module instances out of {len(self._modules)} registered")
-    if instances:
-      logger.debug(f"Active modules: {[inst.name for inst in instances]}")
-    return instances
+      yield name, self._modules[name]
 
 
 # Global registry
 # Global registry
 registry = ModuleRegistry()
 registry = ModuleRegistry()

+ 125 - 0
cli/core/renderers.py

@@ -0,0 +1,125 @@
+from collections import OrderedDict
+from typing import Dict, Optional, Any, List
+
+from rich.table import Table
+
+from .variables import VariableCollection
+
+
+def render_variable_table(
+  variables: VariableCollection,
+  title: str = "Variables",
+  show_options: bool = False,
+  sections: Optional[OrderedDict[str, Dict[str, Any]]] = None,
+) -> Table:
+  """Build a Rich table representing variable metadata."""
+
+  table = Table(title=title, header_style="bold cyan")
+  table.add_column("Name", style="cyan", no_wrap=True)
+  table.add_column("Type", style="yellow", no_wrap=True)
+  if show_options:
+    table.add_column("Options", style="magenta")
+  table.add_column("Default", style="green", no_wrap=True)
+  table.add_column("Description", style="white")
+
+  rows_by_name: Dict[str, Dict[str, str]] = {
+    row["name"]: row for row in variables.as_rows()
+  }
+
+  def _style_value(value: str, enabled: bool) -> str:
+    if enabled or not value:
+      return value
+    return f"[grey50]{value}[/grey50]"
+
+  def _add_variable_row(row: Dict[str, str], *, enabled: bool = True) -> None:
+    cells = [
+      _style_value(row["name"], enabled),
+      _style_value(row["type"], enabled),
+    ]
+    if show_options:
+      options = ", ".join(row["options"]) if row["options"] else ""
+      cells.append(_style_value(options, enabled))
+    cells.extend(
+      [
+        _style_value(row["default"], enabled),
+        _style_value(row["description"], enabled),
+      ]
+    )
+    style = None if enabled else "grey50"
+    table.add_row(*cells, style=style)
+
+  if sections:
+    column_count = 4 + (1 if show_options else 0)
+    for idx, meta in enumerate(sections.values()):
+      title = meta.get("title") or "Section"
+      names = meta.get("variables", [])
+      toggle_var = None
+      toggle_name = meta.get("toggle")
+      if toggle_name:
+        toggle_var = variables.get_variable(toggle_name)
+      enabled = True
+      if toggle_var is not None:
+        try:
+          enabled = bool(toggle_var.get_typed_value())
+        except ValueError:
+          enabled = True
+
+      header_style = "bold magenta" if enabled else "bold grey50"
+      header_title = title if enabled else f"{title} (disabled)"
+      header_cells = [
+        _style_value(header_title, enabled)
+      ] + ["" for _ in range(column_count - 1)]
+      table.add_row(*header_cells, style=header_style, end_section=False)
+      for name in names:
+        row = rows_by_name.get(name)
+        if not row:
+          continue
+        _add_variable_row(row, enabled=enabled)
+      if idx != len(sections) - 1:
+        table.add_section()
+  else:
+    for row in rows_by_name.values():
+      _add_variable_row(row)
+
+  return table
+
+
+def render_template_list_table(
+  templates: List[Any],
+  module_name: str,
+  *,
+  include_library: bool = False,
+) -> Table:
+  """Build a Rich table for template listings without extra info lines.
+  
+  Columns and formatting:
+    - ID (with dimmed (version) suffix if available)
+    - Name
+    - Description (takes remaining width, truncates with ellipsis)
+    - Author (last column)
+  """
+  table = Table(title=f"{module_name.title()} Templates", header_style="bold cyan", expand=True)
+
+  # Constrain non-description columns to preserve space
+  table.add_column("ID", style="cyan", no_wrap=True, max_width=28, overflow="ellipsis")
+  table.add_column("Name", style="white", no_wrap=True, max_width=28, overflow="ellipsis")
+  if include_library:
+    table.add_column("Library", style="magenta", no_wrap=True, max_width=16, overflow="ellipsis")
+  # Description gets most space via ratio and truncates with ellipsis
+  table.add_column("Description", style="white", no_wrap=True, overflow="ellipsis", ratio=1)
+  table.add_column("Author", style="yellow", no_wrap=True, max_width=24, overflow="ellipsis")
+
+  for tpl in templates:
+    _id = tpl.id or "-"
+    _ver = tpl.version or ""
+    id_with_ver = f"{_id} [dim]({_ver})[/dim]" if _ver else _id
+    name = tpl.name or _id
+    author = tpl.author or "-"
+    desc = tpl.description or "-"
+    if include_library:
+      library = tpl.library or "-"
+      table.add_row(id_with_ver, name, library, desc, author)
+    else:
+      table.add_row(id_with_ver, name, desc, author)
+
+  return table

+ 196 - 90
cli/core/template.py

@@ -2,6 +2,7 @@ from .variables import Variable, VariableCollection
 from pathlib import Path
 from pathlib import Path
 from typing import Any, Dict, List, Set, Tuple, Optional
 from typing import Any, Dict, List, Set, Tuple, Optional
 from dataclasses import dataclass, field
 from dataclasses import dataclass, field
+from collections import OrderedDict
 import logging
 import logging
 import re
 import re
 from jinja2 import Environment, BaseLoader, meta, nodes, TemplateSyntaxError
 from jinja2 import Environment, BaseLoader, meta, nodes, TemplateSyntaxError
@@ -10,6 +11,15 @@ import frontmatter
 logger = logging.getLogger(__name__)
 logger = logging.getLogger(__name__)
 
 
 
 
+def _log_variable_stage(stage: str, names) -> None:
+  """Helper to emit consistent debug output for variable lists."""
+  if not names:
+    return
+  if isinstance(names, (set, tuple)):
+    names = list(names)
+  logger.debug(f"{stage}: {names}")
+
+
 @dataclass
 @dataclass
 class Template:
 class Template:
   """Represents a template file with frontmatter and content."""
   """Represents a template file with frontmatter and content."""
@@ -28,6 +38,8 @@ class Template:
   module: str = ""
   module: str = ""
   tags: List[str] = field(default_factory=list)
   tags: List[str] = field(default_factory=list)
   files: List[str] = field(default_factory=list)
   files: List[str] = field(default_factory=list)
+  library: str = ""
+  variable_sections: "OrderedDict[str, Dict[str, Any]]" = field(default_factory=OrderedDict, init=False)
 
 
   # Extracted/merged variables
   # Extracted/merged variables
   variables: VariableCollection = field(default_factory=VariableCollection, init=False)
   variables: VariableCollection = field(default_factory=VariableCollection, init=False)
@@ -41,7 +53,10 @@ class Template:
       for name, value in variable_values.items():
       for name, value in variable_values.items():
         var = self.variables.get_variable(name)
         var = self.variables.get_variable(name)
         if var:
         if var:
-          var.value = value
+          try:
+            var.value = var.convert(value)
+          except ValueError as exc:
+            raise ValueError(f"Invalid value for variable '{name}': {exc}")
 
 
     env = self._create_jinja_env()
     env = self._create_jinja_env()
     context = self.variables.to_jinja_context()
     context = self.variables.to_jinja_context()
@@ -53,7 +68,12 @@ class Template:
     return self.variables.get_variable_names()
     return self.variables.get_variable_names()
 
 
   @classmethod
   @classmethod
-  def from_file(cls, file_path: Path, module_variables: Dict[str, Any] = None) -> "Template":
+  def from_file(
+    cls,
+    file_path: Path,
+    module_sections: Dict[str, Any] = None,
+    library_name: str = ""
+  ) -> "Template":
     """Create a Template instance from a file path."""
     """Create a Template instance from a file path."""
     logger.debug(f"Loading template from file: {file_path}")
     logger.debug(f"Loading template from file: {file_path}")
 
 
@@ -73,15 +93,41 @@ class Template:
         module=frontmatter_data.get("module", ""),
         module=frontmatter_data.get("module", ""),
         tags=frontmatter_data.get("tags", []),
         tags=frontmatter_data.get("tags", []),
         files=frontmatter_data.get("files", []),
         files=frontmatter_data.get("files", []),
+        library=library_name,
       )
       )
 
 
       logger.info(f"Loaded template '{template.id}' (v{template.version or 'unversioned'})")
       logger.info(f"Loaded template '{template.id}' (v{template.version or 'unversioned'})")
 
 
+      module_section_defs = module_sections or {}
+      module_flat, module_section_meta = cls._flatten_sections(module_section_defs)
+
+      template_section_defs = frontmatter_data.get("variable_sections") or {}
+      legacy_frontmatter_vars = frontmatter_data.get("variables")
+      if legacy_frontmatter_vars:
+        template_section_defs = OrderedDict(template_section_defs)
+        template_section_defs["template_specific"] = {
+          "title": f"{template.name or template_id} Specific",
+          "prefix": "",
+          "vars": legacy_frontmatter_vars,
+        }
+
+      template_flat, template_section_meta = cls._flatten_sections(template_section_defs)
+
       # Extract and merge variables (only those actually used)
       # Extract and merge variables (only those actually used)
-      variables, tpl_names, mod_names = cls._merge_variables(content, frontmatter_data, module_variables or {})
+      variables, tpl_names, mod_names = cls._merge_variables(
+        content,
+        module_flat,
+        template_flat,
+        template_id,
+      )
       template.variables = variables
       template.variables = variables
       template.template_var_names = tpl_names
       template.template_var_names = tpl_names
       template.module_var_names = mod_names
       template.module_var_names = mod_names
+      template.variable_sections = cls._combine_sections_meta(
+        module_section_meta,
+        template_section_meta,
+        template.variables,
+      )
 
 
       logger.debug(
       logger.debug(
         f"Final variables for template '{template.id}': {template.variables.get_variable_names()}"
         f"Final variables for template '{template.id}': {template.variables.get_variable_names()}"
@@ -113,45 +159,6 @@ class Template:
       post = frontmatter.load(f)
       post = frontmatter.load(f)
     return post.metadata, post.content
     return post.metadata, post.content
 
 
-  @staticmethod
-  def _extract_variables_from_frontmatter(frontmatter_data: Dict[str, Any]) -> Dict[str, Variable]:
-    """Extract variables from the 'variables:' section in frontmatter as Variable objects.
-
-    Example:
-      variables:
-        var_name:
-          description: "..."
-          type: "str"
-    """
-    variables_data = frontmatter_data.get("variables")
-    result: Dict[str, Variable] = {}
-
-    if not variables_data:
-      return result
-
-    try:
-      if isinstance(variables_data, dict):
-        for name, var_config in variables_data.items():
-          if isinstance(var_config, dict):
-            variable = Variable.from_dict(name, var_config)
-            result[name] = variable
-          else:
-            logger.warning(
-              f"Invalid variable configuration for '{name}': expected dict, got {type(var_config).__name__}"
-            )
-      else:
-        raise ValueError(
-          "Variables must be a dictionary. Use format: variables: { var_name: { type: 'str' } }"
-        )
-    except Exception as e:
-      logger.error(f"Error parsing variables from frontmatter: {str(e)}")
-      return {}
-
-    logger.debug(
-      f"Extracted {len(result)} variables (insertion order preserved): {list(result.keys())}"
-    )
-    return result
-
   @staticmethod
   @staticmethod
   def _extract_template_variables(content: str) -> Set[str]:
   def _extract_template_variables(content: str) -> Set[str]:
     """Extract variable names used in Jinja2 template content (flat names only).
     """Extract variable names used in Jinja2 template content (flat names only).
@@ -192,8 +199,9 @@ class Template:
   @staticmethod
   @staticmethod
   def _merge_variables(
   def _merge_variables(
     content: str,
     content: str,
-    frontmatter_data: Dict[str, Any],
     module_variables: Dict[str, Any],
     module_variables: Dict[str, Any],
+    template_variables: Dict[str, Any],
+    template_id: str,
   ) -> Tuple[VariableCollection, List[str], List[str]]:
   ) -> Tuple[VariableCollection, List[str], List[str]]:
     """Merge module + frontmatter vars, auto-create missing, and apply Jinja defaults.
     """Merge module + frontmatter vars, auto-create missing, and apply Jinja defaults.
 
 
@@ -206,60 +214,158 @@ class Template:
     used_variables = Template._extract_template_variables(content)
     used_variables = Template._extract_template_variables(content)
     jinja_defaults = Template._extract_jinja_defaults(content)
     jinja_defaults = Template._extract_jinja_defaults(content)
 
 
-    if not used_variables:
-      logger.debug("No variables found in template content")
-      return VariableCollection()
+    declared_variables = set(module_variables.keys()) | set(template_variables.keys())
+    missing_declared = used_variables - declared_variables
+    if missing_declared:
+      raise ValueError(
+        "Unknown variables referenced in template: "
+        + ", ".join(sorted(missing_declared))
+      )
 
 
     variables = VariableCollection()
     variables = VariableCollection()
 
 
-    logger.debug(
-      f"Processing module variables: {list(module_variables.keys()) if module_variables else []}"
+    # Keep only variables that are actually referenced in the template content,
+    # plus any explicitly defined in template frontmatter.
+    relevant_names = used_variables | set(template_variables.keys())
+
+    _log_variable_stage(
+      "Processing module variables",
+      list(module_variables.keys()) if module_variables else [],
     )
     )
 
 
-    # Compatibility bridge: if module defines *_enabled toggles and legacy roots are used
-    # (e.g., 'traefik' in template), ensure '<root>_enabled' is also included and map defaults.
-    toggle_roots = {k[:-len('_enabled')] for k in module_variables.keys() if k.endswith('_enabled')}
-
-    # Add missing toggles for used legacy roots
-    bridged_used = set(used_variables)
-    for root in toggle_roots:
-      if root in used_variables:
-        bridged_used.add(f"{root}_enabled")
-
-    # Map Jinja defaults from legacy roots to *_enabled toggles
-    bridged_defaults = dict(jinja_defaults)
-    for root in toggle_roots:
-      if root in jinja_defaults and f"{root}_enabled" not in bridged_defaults:
-        bridged_defaults[f"{root}_enabled"] = jinja_defaults[root]
-
-    # 1) Module variables (lowest precedence)
-    variables.add_from_dict(module_variables, bridged_used, label="module")
-
-    # 2) Frontmatter variables (override module specs)
-    template_vars = Template._extract_variables_from_frontmatter(frontmatter_data)
-    variables.add_from_dict(template_vars, bridged_used, label="template")
-
-    # Track source ordering lists
-    template_var_names_ordered: List[str] = [n for n in template_vars.keys() if n in bridged_used]
-    module_var_names_ordered: List[str] = [n for n in module_variables.keys() if n in bridged_used]
-
-    # 3) Auto-create missing variables for anything used in the template
-    defined_names = set(variables.variables.keys())
-    missing = bridged_used - defined_names
-
-    # Auto-create missing variables (flat names only). Skip legacy roots if their *_enabled exists.
-    for name in sorted(missing):
-      if name in toggle_roots:
-        # Will be provided via alias from '<root>_enabled'
-        logger.debug(f"Skipping auto-create for legacy root '{name}' (alias provided by *_enabled)")
-        continue
-      variables.variables[name] = Variable(name=name, type="str")
-      logger.debug(f"Auto-created variable '{name}' (flat)")
+    variables.add_from_dict(module_variables, relevant_names, label="module")
+    variables.add_from_dict(template_variables, relevant_names, label="template")
+
+    template_var_names_ordered: List[str] = [n for n in template_variables.keys() if n in relevant_names]
+    module_var_names_ordered: List[str] = [n for n in module_variables.keys() if n in relevant_names]
+
+    variables.apply_jinja_defaults(jinja_defaults)
 
 
-    # Apply Jinja defaults last (only fill if still empty)
-    variables.apply_jinja_defaults(bridged_defaults)
+    Template._ensure_defaults(variables, template_id)
 
 
     logger.debug(
     logger.debug(
-      f"Smart merge: {len(bridged_used)} used, {len(variables)} defined = {len(variables)} final variables"
+      f"Smart merge: {len(relevant_names)} used, {len(variables)} defined = {len(variables)} final variables"
     )
     )
     return variables, template_var_names_ordered, module_var_names_ordered
     return variables, template_var_names_ordered, module_var_names_ordered
+
+  @staticmethod
+  def _ensure_defaults(variables: VariableCollection, template_id: str) -> None:
+    """Ensure every variable has a default value; raise if any are missing."""
+    missing: List[str] = []
+
+    for var_name in variables.get_variable_names():
+      variable = variables.get_variable(var_name)
+      if not variable:
+        continue
+      if variable.value not in (None, ""):
+        continue
+
+      missing.append(var_name)
+
+    if missing:
+      raise ValueError(
+        f"Missing default value(s) for variables {', '.join(missing)} in template '{template_id}'"
+      )
+
+  @staticmethod
+  def _flatten_sections(
+    section_defs: Dict[str, Any],
+  ) -> Tuple[Dict[str, Dict[str, Any]], "OrderedDict[str, Dict[str, Any]]"]:
+    flat: Dict[str, Dict[str, Any]] = {}
+    meta: "OrderedDict[str, Dict[str, Any]]" = OrderedDict()
+
+    if not section_defs:
+      return flat, meta
+
+    for key, data in section_defs.items():
+      if not isinstance(data, dict):
+        continue
+
+      title = data.get("title") or key.replace('_', ' ').title()
+      toggle_name = data.get("toggle")
+      vars_spec = data.get("vars") or {}
+
+      variables_list: List[str] = []
+      for var_name, spec in vars_spec.items():
+        spec = dict(spec)
+        spec.setdefault("section", title)
+        flat[var_name] = spec
+        variables_list.append(var_name)
+
+      if toggle_name:
+        if toggle_name not in flat:
+          flat[toggle_name] = {
+            "type": "bool",
+            "default": False,
+            "section": title,
+            "description": data.get("toggle_description", ""),
+          }
+        if toggle_name not in variables_list:
+          variables_list.insert(0, toggle_name)
+
+      meta[key] = {
+        "title": title,
+        "prompt": data.get("prompt"),
+        "description": data.get("description"),
+        "toggle": toggle_name,
+        "variables": variables_list,
+      }
+
+    return flat, meta
+
+  @staticmethod
+  def _combine_sections_meta(
+    module_meta: "OrderedDict[str, Dict[str, Any]]",
+    template_meta: "OrderedDict[str, Dict[str, Any]]",
+    variables: VariableCollection,
+  ) -> "OrderedDict[str, Dict[str, Any]]":
+    combined: "OrderedDict[str, Dict[str, Any]]" = OrderedDict()
+
+    def _add_meta(source: "OrderedDict[str, Dict[str, Any]]") -> None:
+      for key, meta in source.items():
+        existing = combined.get(key)
+        if existing:
+          existing["variables"].extend(v for v in meta["variables"] if v not in existing["variables"])
+          if meta.get("prompt"):
+            existing["prompt"] = meta["prompt"]
+          if meta.get("description"):
+            existing["description"] = meta["description"]
+          if meta.get("toggle"):
+            existing["toggle"] = meta["toggle"]
+          if meta.get("title"):
+            existing["title"] = meta["title"]
+        else:
+          combined[key] = {
+            "title": meta.get("title") or key.replace('_', ' ').title(),
+            "prompt": meta.get("prompt"),
+            "description": meta.get("description"),
+            "toggle": meta.get("toggle"),
+            "variables": list(meta.get("variables", [])),
+          }
+
+    _add_meta(module_meta)
+    _add_meta(template_meta)
+
+    # Filter out variables that are not present in the final collection
+    existing_names = set(variables.get_variable_names())
+    seen: Set[str] = set()
+    for key, meta in list(combined.items()):
+      filtered = [name for name in meta["variables"] if name in existing_names]
+      if not filtered:
+        del combined[key]
+        continue
+      meta["variables"] = filtered
+      seen.update(filtered)
+
+    # Add remaining variables that were not covered by sections
+    remaining = [name for name in existing_names if name not in seen]
+    if remaining:
+      combined["other"] = {
+        "title": "Other",
+        "prompt": None,
+        "description": None,
+        "toggle": None,
+        "variables": remaining,
+      }
+
+    return combined

+ 124 - 44
cli/core/variables.py

@@ -1,69 +1,136 @@
 from dataclasses import dataclass, field
 from dataclasses import dataclass, field
 from typing import Any, Dict, List, Optional, Set
 from typing import Any, Dict, List, Optional, Set
+from urllib.parse import urlparse
 import logging
 import logging
+import re
 
 
 logger = logging.getLogger(__name__)
 logger = logging.getLogger(__name__)
 
 
+TRUE_VALUES = {"true", "1", "yes", "on"}
+FALSE_VALUES = {"false", "0", "no", "off"}
+HOSTNAME_REGEX = re.compile(r"^(?=.{1,253}$)(?!-)[A-Za-z0-9_-]{1,63}(?<!-)(\.(?!-)[A-Za-z0-9_-]{1,63}(?<!-))*$")
+EMAIL_REGEX = re.compile(r"^[^@\s]+@[^@\s]+\.[^@\s]+$")
+
 
 
 @dataclass
 @dataclass
 class Variable:
 class Variable:
-  """Represents a single templating variable.
-  
-  Supported types: str, int, float, bool, enum
-  """
+  """Represents a single templating variable with lightweight validation."""
+
   name: str
   name: str
   description: Optional[str] = None
   description: Optional[str] = None
-  type: str = "str"  # str, int, float, bool, enum
+  type: str = "str"
   options: Optional[List[Any]] = field(default_factory=list)
   options: Optional[List[Any]] = field(default_factory=list)
   prompt: Optional[str] = None
   prompt: Optional[str] = None
   value: Any = None
   value: Any = None
+  section: Optional[str] = None
 
 
   @classmethod
   @classmethod
   def from_dict(cls, name: str, data: dict) -> "Variable":
   def from_dict(cls, name: str, data: dict) -> "Variable":
-    """Unified constructor for dict-based specs (module or frontmatter).
-    Accepts keys: description/display, type, options, prompt, value/default.
-    """
-    return cls(
+    """Unified constructor for dict-based specs (module or frontmatter)."""
+    variable = cls(
       name=name,
       name=name,
       description=data.get("description") or data.get("display", ""),
       description=data.get("description") or data.get("display", ""),
       type=data.get("type", "str"),
       type=data.get("type", "str"),
       options=data.get("options", []),
       options=data.get("options", []),
       prompt=data.get("prompt"),
       prompt=data.get("prompt"),
-      value=data.get("value") if data.get("value") is not None else data.get("default")
+      value=data.get("value") if data.get("value") is not None else data.get("default"),
+      section=data.get("section"),
     )
     )
 
 
-  def get_typed_value(self) -> Any:
-    """Return the value converted to the appropriate Python type."""
-    if self.value is None:
+    if variable.value is not None:
+      try:
+        variable.value = variable.convert(variable.value)
+      except ValueError as exc:
+        raise ValueError(f"Invalid default for variable '{name}': {exc}")
+
+    return variable
+
+  def convert(self, value: Any) -> Any:
+    """Validate and convert a raw value based on the variable type."""
+    if value is None:
       return None
       return None
 
 
     if self.type == "bool":
     if self.type == "bool":
-      if isinstance(self.value, bool):
-        return self.value
-      return str(self.value).lower() in ("true", "1", "yes", "on")
+      if isinstance(value, bool):
+        return value
+      if isinstance(value, str):
+        lowered = value.strip().lower()
+        if lowered in TRUE_VALUES:
+          return True
+        if lowered in FALSE_VALUES:
+          return False
+      raise ValueError("value must be a boolean (true/false)")
+
     if self.type == "int":
     if self.type == "int":
-      return int(self.value)
+      if isinstance(value, int):
+        return value
+      if isinstance(value, str) and value.strip() == "":
+        return None
+      try:
+        return int(value)
+      except (TypeError, ValueError) as exc:
+        raise ValueError("value must be an integer") from exc
+
     if self.type == "float":
     if self.type == "float":
-      return float(self.value)
-    return str(self.value)
+      if isinstance(value, float):
+        return value
+      if isinstance(value, str) and value.strip() == "":
+        return None
+      try:
+        return float(value)
+      except (TypeError, ValueError) as exc:
+        raise ValueError("value must be a float") from exc
+
+    if self.type == "enum":
+      if value == "":
+        return None
+      val = str(value)
+      if self.options and val not in self.options:
+        raise ValueError(f"value must be one of: {', '.join(self.options)}")
+      return val
+
+    if self.type == "hostname":
+      val = str(value).strip()
+      if not val:
+        return ""
+      if val.lower() == "localhost":
+        return val
+      if not HOSTNAME_REGEX.fullmatch(val):
+        raise ValueError("value must be a valid hostname")
+      return val
+
+    if self.type == "url":
+      val = str(value).strip()
+      if not val:
+        return ""
+      parsed = urlparse(val)
+      if not (parsed.scheme and parsed.netloc):
+        raise ValueError("value must be a valid URL (include scheme and host)")
+      return val
+
+    if self.type == "email":
+      val = str(value).strip()
+      if not val:
+        return ""
+      if not EMAIL_REGEX.fullmatch(val):
+        raise ValueError("value must be a valid email address")
+      return val
+
+    # Default to string conversion, trimming trailing newline characters only
+    return str(value)
+
+  def get_typed_value(self) -> Any:
+    """Return the stored value converted to the appropriate Python type."""
+    return self.convert(self.value)
 
 
 
 
 @dataclass
 @dataclass
 class VariableCollection:
 class VariableCollection:
-  """Manages variables with merge precedence and builds Jinja context.
-
-  Flat model: context is a simple name -> typed value mapping.
-  """
+  """Manages variables with merge precedence and builds Jinja context."""
 
 
   variables: Dict[str, Variable] = field(default_factory=dict)
   variables: Dict[str, Variable] = field(default_factory=dict)
 
 
   def add_from_dict(self, specs: Dict[str, Any], used_vars: Set[str], label: str = "spec") -> None:
   def add_from_dict(self, specs: Dict[str, Any], used_vars: Set[str], label: str = "spec") -> None:
-    """Generic adder that accepts a mapping of name -> (Variable | dict spec).
-
-    - Preserves declaration order
-    - Filters by used_vars
-    - Uses Variable.from_dict for dict specs
-    """
     used = set(used_vars)
     used = set(used_vars)
     for name in specs.keys():
     for name in specs.keys():
       if name not in used:
       if name not in used:
@@ -82,29 +149,27 @@ class VariableCollection:
         )
         )
 
 
   def apply_jinja_defaults(self, jinja_defaults: Dict[str, str]) -> None:
   def apply_jinja_defaults(self, jinja_defaults: Dict[str, str]) -> None:
-    """Apply Jinja2 defaults to variables that do not have a value yet."""
     for var_name, default_value in jinja_defaults.items():
     for var_name, default_value in jinja_defaults.items():
       if var_name in self.variables:
       if var_name in self.variables:
         if self.variables[var_name].value is None or self.variables[var_name].value == "":
         if self.variables[var_name].value is None or self.variables[var_name].value == "":
-          self.variables[var_name].value = default_value
-          logger.debug(f"Applied Jinja2 default to '{var_name}': {default_value}")
+          try:
+            self.variables[var_name].value = self.variables[var_name].convert(default_value)
+            logger.debug(f"Applied Jinja2 default to '{var_name}': {default_value}")
+          except ValueError as exc:
+            logger.warning(f"Ignoring invalid Jinja default for '{var_name}': {exc}")
 
 
   def to_jinja_context(self) -> Dict[str, Any]:
   def to_jinja_context(self) -> Dict[str, Any]:
-    """Convert the collection to a flat dict suitable for Jinja rendering.
-
-    Compatibility: for any '<section>_enabled' boolean, also expose '<section>' for
-    legacy templates that expect a truthy/falsey root variable.
-    """
     context: Dict[str, Any] = {}
     context: Dict[str, Any] = {}
 
 
-    # First pass: direct mapping
     for var_name, variable in self.variables.items():
     for var_name, variable in self.variables.items():
-      value = variable.get_typed_value()
+      try:
+        value = variable.get_typed_value()
+      except ValueError as exc:
+        raise ValueError(f"Invalid value for variable '{var_name}': {exc}") from exc
       if value is None:
       if value is None:
-        value = ""  # Avoid None in Jinja output
+        value = ""
       context[var_name] = value
       context[var_name] = value
 
 
-    # Second pass: alias *_enabled -> root
     for var_name, variable in self.variables.items():
     for var_name, variable in self.variables.items():
       if var_name.endswith("_enabled"):
       if var_name.endswith("_enabled"):
         root = var_name[: -len("_enabled")]
         root = var_name[: -len("_enabled")]
@@ -113,13 +178,28 @@ class VariableCollection:
     return context
     return context
 
 
   def get_variable_names(self) -> List[str]:
   def get_variable_names(self) -> List[str]:
-    """Get variable names in insertion order."""
     return list(self.variables.keys())
     return list(self.variables.keys())
 
 
   def get_variable(self, name: str) -> Optional[Variable]:
   def get_variable(self, name: str) -> Optional[Variable]:
-    """Get a specific variable by name."""
     return self.variables.get(name)
     return self.variables.get(name)
 
 
+  def as_rows(self) -> List[Dict[str, Any]]:
+    """Return variable metadata for presentation or export."""
+    rows: List[Dict[str, Any]] = []
+    for name in self.get_variable_names():
+      variable = self.variables[name]
+      default = variable.get_typed_value()
+      rows.append(
+        {
+          "name": name,
+          "type": variable.type,
+          "description": variable.description or "",
+          "default": "" if default in (None, "") else str(default),
+          "options": list(variable.options or []),
+          "section": variable.section,
+        }
+      )
+    return rows
+
   def __len__(self) -> int:
   def __len__(self) -> int:
-    """Number of variables in the collection."""
     return len(self.variables)
     return len(self.variables)

+ 193 - 60
cli/modules/compose.py

@@ -1,73 +1,206 @@
+from collections import OrderedDict
+
 from ..core.module import Module
 from ..core.module import Module
 from ..core.registry import registry
 from ..core.registry import registry
 
 
 
 
 class ComposeModule(Module):
 class ComposeModule(Module):
-  """Docker Compose module.
-
-  Flat variable names only. Simple, explicit toggles (e.g., traefik_enabled) instead of dotted sections.
-  """
+  """Docker Compose module."""
 
 
   name = "compose"
   name = "compose"
   description = "Manage Docker Compose configurations"
   description = "Manage Docker Compose configurations"
-  # Per rule: prefer compose.yaml first, legacy names kept as fallback
   files = ["compose.yaml", "compose.yml", "docker-compose.yaml", "docker-compose.yml"]
   files = ["compose.yaml", "compose.yml", "docker-compose.yaml", "docker-compose.yml"]
 
 
-  # Common Compose variables used across templates. Only variables actually used
-  # in a given template are kept during merge.
-  variables_spec = {
-    # General
-    "service_name": {"description": "Service name", "type": "str"},
-    "container_name": {"description": "Container name", "type": "str"},
-    "container_timezone": {"description": "Container timezone (e.g., Europe/Berlin)", "type": "str"},
-    "container_loglevel": {
-      "description": "Container log level",
-      "type": "enum",
-      "options": ["debug", "info", "warn", "error"],
-      "default": "info",
-    },
-    "restart_policy": {
-      "description": "Container restart policy",
-      "type": "enum",
-      "options": ["unless-stopped", "always", "on-failure", "no"],
-      "default": "unless-stopped",
-    },
-
-    # Networking
-    "network_enabled": {"description": "Enable custom network block", "type": "bool", "default": False},
-    "network_name": {"description": "Docker network name", "type": "str", "default": "bridge"},
-    "network_external": {"description": "Use existing Docker network", "type": "bool", "default": True},
-
-    # Ports
-    "ports_enabled": {"description": "Expose ports via 'ports' mapping", "type": "bool", "default": False},
-    "service_port_http": {"description": "HTTP service port (host)", "type": "int", "default": 8080},
-    "service_port_https": {"description": "HTTPS service port (host)", "type": "int", "default": 8443},
-
-    # Traefik
-    "traefik_enabled": {"description": "Enable Traefik reverse proxy integration", "type": "bool", "default": False},
-    "traefik_host": {"description": "Domain name for your service", "type": "hostname"},
-    "traefik_entrypoint": {"description": "HTTP entrypoint (non-TLS)", "type": "str", "default": "web"},
-    "traefik_tls_enabled": {"description": "Enable HTTPS/TLS", "type": "bool", "default": True},
-    "traefik_tls_entrypoint": {"description": "TLS entrypoint", "type": "str", "default": "websecure"},
-    "traefik_tls_certresolver": {"description": "Traefik certificate resolver name", "type": "str"},
-
-    # Docker Swarm
-    "swarm_enabled": {"description": "Enable Docker Swarm mode", "type": "bool", "default": False},
-    "swarm_replicas": {"description": "Number of replicas in Swarm", "type": "int", "default": 1},
-
-    # Nginx example
-    "nginx_dashboard_enabled": {"description": "Enable Nginx dashboard", "type": "bool", "default": False},
-    "nginx_dashboard_port": {"description": "Nginx dashboard port (host)", "type": "int", "default": 8081},
-
-    # PostgreSQL integration
-    "postgres_enabled": {"description": "Enable PostgreSQL integration", "type": "bool", "default": False},
-    "postgres_host": {"description": "PostgreSQL host", "type": "str", "default": "postgres"},
-    "postgres_port": {"description": "PostgreSQL port", "type": "int", "default": 5432},
-    "postgres_database": {"description": "PostgreSQL database name", "type": "str"},
-    "postgres_user": {"description": "PostgreSQL user", "type": "str"},
-    "postgres_password": {"description": "PostgreSQL password", "type": "str"},
-  }
+  variable_sections = OrderedDict(
+    {
+      "general": {
+        "title": "General",
+        "vars": {
+          "service_name": {
+            "description": "Service name",
+            "type": "str",
+            "default": "",
+          },
+          "container_name": {
+            "description": "Container name",
+            "type": "str",
+            "default": "",
+          },
+          "container_timezone": {
+            "description": "Container timezone (e.g., Europe/Berlin)",
+            "type": "str",
+            "default": "UTC",
+          },
+          "container_loglevel": {
+            "description": "Container log level",
+            "type": "enum",
+            "options": ["debug", "info", "warn", "error"],
+            "default": "info",
+          },
+          "restart_policy": {
+            "description": "Container restart policy",
+            "type": "enum",
+            "options": ["unless-stopped", "always", "on-failure", "no"],
+            "default": "unless-stopped",
+          },
+        },
+      },
+      "network": {
+        "title": "Network",
+        "prompt": "Enable custom network block?",
+        "toggle": "network_enabled",
+        "vars": {
+          "network_enabled": {
+            "description": "Enable custom network block",
+            "type": "bool",
+            "default": False,
+          },
+          "network_name": {
+            "description": "Docker network name",
+            "type": "str",
+            "default": "bridge",
+          },
+          "network_external": {
+            "description": "Use existing Docker network",
+            "type": "bool",
+            "default": True,
+          },
+        },
+      },
+      "ports": {
+        "title": "Ports",
+        "prompt": "Expose ports via 'ports' mapping?",
+        "toggle": "ports_enabled",
+        "vars": {
+          "ports_enabled": {
+            "description": "Expose ports via 'ports' mapping",
+            "type": "bool",
+            "default": False,
+          },
+          "service_port_http": {
+            "description": "HTTP service port (host)",
+            "type": "int",
+            "default": 8080,
+          },
+          "service_port_https": {
+            "description": "HTTPS service port (host)",
+            "type": "int",
+            "default": 8443,
+          },
+          "ports_http": {
+            "description": "Port for HTTP access to the service",
+            "type": "int",
+            "default": 5678,
+          },
+        },
+      },
+      "traefik": {
+        "title": "Traefik",
+        "prompt": "Enable Traefik reverse proxy integration?",
+        "toggle": "traefik_enabled",
+        "description": "Traefik routes external traffic to your service.",
+        "vars": {
+          "traefik_enabled": {
+            "description": "Enable Traefik reverse proxy integration",
+            "type": "bool",
+            "default": False,
+          },
+          "traefik_host": {
+            "description": "Domain name for your service",
+            "type": "hostname",
+            "default": "",
+          },
+          "traefik_entrypoint": {
+            "description": "HTTP entrypoint (non-TLS)",
+            "type": "str",
+            "default": "web",
+          },
+          "traefik_tls_enabled": {
+            "description": "Enable HTTPS/TLS",
+            "type": "bool",
+            "default": True,
+          },
+          "traefik_tls_entrypoint": {
+            "description": "TLS entrypoint",
+            "type": "str",
+            "default": "websecure",
+          },
+          "traefik_tls_certresolver": {
+            "description": "Traefik certificate resolver name",
+            "type": "str",
+            "default": "",
+          },
+        },
+      },
+      "swarm": {
+        "title": "Docker Swarm",
+        "vars": {
+          "swarm_enabled": {
+            "description": "Enable Docker Swarm mode",
+            "type": "bool",
+            "default": False,
+          },
+          "swarm_replicas": {
+            "description": "Number of replicas in Swarm",
+            "type": "int",
+            "default": 1,
+          },
+        },
+      },
+      "nginx": {
+        "title": "Nginx Dashboard",
+        "vars": {
+          "nginx_dashboard_enabled": {
+            "description": "Enable Nginx dashboard",
+            "type": "bool",
+            "default": False,
+          },
+          "nginx_dashboard_port": {
+            "description": "Nginx dashboard port (host)",
+            "type": "int",
+            "default": 8081,
+          },
+        },
+      },
+      "postgres": {
+        "title": "PostgreSQL",
+        "prompt": "Configure external PostgreSQL database?",
+        "toggle": "postgres_enabled",
+        "vars": {
+          "postgres_enabled": {
+            "description": "Enable PostgreSQL integration",
+            "type": "bool",
+            "default": False,
+          },
+          "postgres_host": {
+            "description": "PostgreSQL host",
+            "type": "str",
+            "default": "postgres",
+          },
+          "postgres_port": {
+            "description": "PostgreSQL port",
+            "type": "int",
+            "default": 5432,
+          },
+          "postgres_database": {
+            "description": "PostgreSQL database name",
+            "type": "str",
+            "default": "",
+          },
+          "postgres_user": {
+            "description": "PostgreSQL user",
+            "type": "str",
+            "default": "",
+          },
+          "postgres_password": {
+            "description": "PostgreSQL password",
+            "type": "str",
+            "default": "",
+          },
+        },
+      },
+    }
+  )
 
 
 
 
-# Register the module
 registry.register(ComposeModule)
 registry.register(ComposeModule)

+ 29 - 30
library/compose/n8n/compose.yaml

@@ -9,69 +9,68 @@ tags:
   - automation
   - automation
   - workflows
   - workflows
   - compose
   - compose
-variables:
-  ports.http:
-    description: "Port for HTTP access to n8n"
-    default: "5678"
 ---
 ---
 services:
 services:
-  {{ service_name }}:
+  {{ service_name | default('n8n') }}:
     image: n8nio/n8n:1.110.1
     image: n8nio/n8n:1.110.1
+    container_name: {{ container_name | default('n8n') }}
     environment:
     environment:
       - N8N_LOG_LEVEL={{ container_loglevel | default('info') }}
       - N8N_LOG_LEVEL={{ container_loglevel | default('info') }}
-      - GENERIC_TIMEZONE={{ container_timezone }}
-      - TZ={{ container_timezone }}
+      - GENERIC_TIMEZONE={{ container_timezone | default('UTC') }}
+      - TZ={{ container_timezone | default('UTC') }}
       - N8N_ENFORCE_SETTINGS_FILE_PERMISSIONS=true
       - N8N_ENFORCE_SETTINGS_FILE_PERMISSIONS=true
       - N8N_RUNNERS_ENABLED=true
       - N8N_RUNNERS_ENABLED=true
-      {% if traefik %}
-      {% if traefik.tls %}
-      - N8N_EDITOR_BASE_URL=https://{{ traefik.host }}
+      {% if traefik_enabled %}
+      {% if traefik_tls_enabled %}
+      - N8N_EDITOR_BASE_URL=https://{{ traefik_host | default('n8n.home.arpa') }}
       {% else %}
       {% else %}
-      - N8N_EDITOR_BASE_URL=http://{{ traefik.host }}
+      - N8N_EDITOR_BASE_URL=http://{{ traefik_host | default('n8n.home.arpa') }}
       {% endif %}
       {% endif %}
       {% endif %}
       {% endif %}
-      {% if postgres %}
+      {% if postgres_enabled %}
       - DB_TYPE=postgresdb
       - DB_TYPE=postgresdb
-      - DB_POSTGRESDB_HOST={{ postgres.host }}
-      - DB_POSTGRESDB_PORT=${DB_POSTGRESDB_PORT:-5432}
-      - DB_POSTGRESDB_DATABASE=${POSTGRES_DB}
-      - DB_POSTGRESDB_USER=${POSTGRES_USER}
-      - DB_POSTGRESDB_PASSWORD=${POSTGRES_PASSWORD}
+      - DB_POSTGRESDB_HOST={{ postgres_host | default('postgres') }}
+      - DB_POSTGRESDB_PORT={{ postgres_port | default(5432) }}
+      - DB_POSTGRESDB_DATABASE={{ postgres_database | default('n8n') }}
+      - DB_POSTGRESDB_USER={{ postgres_user | default('n8n') }}
+      - DB_POSTGRESDB_PASSWORD={{ postgres_password | default('n8n') }}
       {% endif %}
       {% endif %}
     volumes:
     volumes:
       - /etc/localtime:/etc/localtime:ro
       - /etc/localtime:/etc/localtime:ro
       - data:/home/node/.n8n
       - data:/home/node/.n8n
-    {% if network %}
+    {% if network_enabled %}
     networks:
     networks:
       - {{ network_name | default('bridge') }}
       - {{ network_name | default('bridge') }}
     {% endif %}
     {% endif %}
-    {% if traefik %}
+    {% if traefik_enabled %}
     labels:
     labels:
-      - traefik.enable={{ traefik | default('true') }}
-      - traefik.http.routers.{{ service_name }}.rule=Host(`{{ traefik.host }}`)
-      {% if traefik.tls %}
-      - traefik.http.routers.{{ service_name }}.entrypoints={{ traefik.tls.entrypoint | default('websecure') }}
-      - traefik.http.routers.{{ service_name }}.tls=true
-      - traefik.http.routers.{{ service_name }}.tls.certresolver={{ traefik.tls.certresolver }}
+      - traefik.enable=true
+      - traefik.http.routers.{{ service_name | default('n8n') }}.rule=Host(`{{ traefik_host | default('n8n.home.arpa') }}`)
+      {% if traefik_tls_enabled %}
+      - traefik.http.routers.{{ service_name | default('n8n') }}.entrypoints={{ traefik_tls_entrypoint | default('websecure') }}
+      - traefik.http.routers.{{ service_name | default('n8n') }}.tls=true
+      - traefik.http.routers.{{ service_name | default('n8n') }}.tls.certresolver={{ traefik_tls_certresolver | default('default') }}
       {% else %}
       {% else %}
-      - traefik.http.routers.{{ service_name }}.entrypoints={{ traefik.entrypoint | default('web') }}
+      - traefik.http.routers.{{ service_name | default('n8n') }}.entrypoints={{ traefik_entrypoint | default('web') }}
       {% endif %}
       {% endif %}
-      - traefik.http.services.{{ service_name }}.loadbalancer.server.port=5678
+      - traefik.http.services.{{ service_name | default('n8n') }}.loadbalancer.server.port=5678
     {% endif %}
     {% endif %}
     restart: {{ restart_policy | default('unless-stopped') }}
     restart: {{ restart_policy | default('unless-stopped') }}
-    {% if ports %}
+    {% if ports_enabled %}
     ports:
     ports:
-      - "{{ ports.http | default('5678') }}:5678"
+      - "{{ ports_http | default(5678) }}:5678"
     {% endif %}
     {% endif %}
 
 
 volumes:
 volumes:
   data:
   data:
     driver: local
     driver: local
 
 
-{% if network %}
+{% if network_enabled %}
 networks:
 networks:
   {{ network_name | default('bridge') }}:
   {{ network_name | default('bridge') }}:
   {% if network_external %}
   {% if network_external %}
     external: true
     external: true
+  {% else %}
+    driver: bridge
   {% endif %}
   {% endif %}
 {% endif %}
 {% endif %}