Browse Source

added more updates

xcad 9 months ago
parent
commit
b4bc24a17f
12 changed files with 982 additions and 575 deletions
  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)
 - Module Commands: Each module implements technology-specific operations
 - 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
 
 ### Template Format
@@ -113,7 +113,7 @@ The codebase has been optimized following the ARCHITECTURE_OPTIMIZATION.md plan:
 
 ### Simplified Variable System (2025-09)
 - 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
 - No separate Variable/Registry classes needed
 

+ 9 - 9
cli/__main__.py

@@ -94,27 +94,27 @@ def init_app():
           failed_imports.append(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:
-        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:
-        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)
         # Log warning but don't raise exception for individual module failures
         logger.warning(error_info)
         console.print(f"[yellow]Warning:[/yellow] {error_info}")
     
     # 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")
     
     # 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")
     
     if failed_imports:

+ 0 - 3
cli/core/args.py

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

+ 12 - 87
cli/core/library.py

@@ -1,7 +1,5 @@
 from pathlib import Path
 import logging
-from .template import Template
-
 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}")
     
     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):
@@ -94,18 +92,18 @@ class Library:
               if file_path.exists():
                 has_any_file = True
                 break
-            
+
             if has_any_file:
-              template_dirs.append(item)
+              template_dirs.append((item, self.name))
           else:
             # No file requirements, include all directories
-            template_dirs.append(item)
+            template_dirs.append((item, self.name))
     except PermissionError as e:
       raise FileNotFoundError(f"Permission denied accessing module '{module_name}' in library '{self.name}': {e}")
     
     # Sort if requested
     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}'")
     return template_dirs
@@ -139,9 +137,9 @@ class LibraryManager:
     
     for library in sorted(self.libraries, key=lambda x: x.priority, reverse=True):
       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}'")
-        return template_path
+        return template_path, lib_name
       except FileNotFoundError:
         # Continue searching in next library
         continue
@@ -178,88 +176,15 @@ class LibraryManager:
     seen_names = set()
     unique_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
     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")
     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
 from typer import Typer, Option, Argument, Context
 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 .template import Template
 from .prompt import PromptHandler
 from .args import parse_var_inputs
+from .renderers import render_variable_table, render_template_list_table
 
 logger = logging.getLogger(__name__)
 console = Console()
@@ -36,80 +40,64 @@ class Module(ABC):
     if hasattr(self, '_init_variables'):
       logger.debug(f"Module '{self.name}' has variable initialization method")
       self._init_variables()
-    
-    self.metadata = self._build_metadata()
     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):
     """List all templates."""
     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:
       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:
       logger.info(f"No templates found for module '{self.name}'")
-    
+
     return templates
 
-  def show(self, id: str = Argument(..., help="Template ID")):
+  def show(
+    self,
+    id: str,
+    show_content: bool = False,
+  ):
     """Show template details."""
     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:
-      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(
@@ -128,16 +116,7 @@ class Module(ABC):
     """
 
     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
     extra_args = []
@@ -163,6 +142,7 @@ class Module(ABC):
         module_name=self.name,
         template_var_order=template.template_var_names,
         module_var_order=template.module_var_names,
+        sections=template.variable_sections,
       )
       
       if collected_values:
@@ -178,6 +158,7 @@ class Module(ABC):
 
     # Render template with collected values
     try:
+      variable_values = self._apply_common_defaults(template, variable_values)
       rendered_content = template.render(variable_values)
       logger.info(f"Successfully rendered template '{id}'")
       
@@ -201,13 +182,138 @@ class Module(ABC):
       console.print(f"[red]Error generating template: {str(e)}[/red]")
       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.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
-    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
 from rich.console import Console
 from rich.prompt import Prompt, Confirm, IntPrompt
 from rich.table import Table
-from rich.panel import Panel
-from rich.text import Text
 
 from .variables import Variable, VariableCollection
+from .renderers import render_variable_table
 
 logger = logging.getLogger(__name__)
-console = Console()
 
 
 class PromptHandler:
@@ -31,156 +30,99 @@ class PromptHandler:
     module_name: str = "",
     template_var_order: List[str] = None,
     module_var_order: List[str] = None,
+    sections: Optional[OrderedDict[str, Dict[str, Any]]] = None,
   ) -> Dict[str, Any]:
     """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 []
     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
 
-      # 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")
-      # 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")
     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:
@@ -193,26 +135,42 @@ class PromptHandler:
     if variable.type in ["hostname", "email", "url"]:
       prompt_text += f" ({variable.type})"
 
-    # Show default value if available
-    default_value = variable.value
-
     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:
     value = Prompt.ask(
       prompt_text,
       default=str(default) if default is not None else "",
-      show_default=False,
+      show_default=True,
     )
     return value.strip() if value else ""
 
@@ -246,7 +204,7 @@ class PromptHandler:
       value = Prompt.ask(
         prompt_text,
         default=str(default) if default else options[0],
-        show_default=False,
+        show_default=True,
       )
       if value in options:
         return value
@@ -286,6 +244,3 @@ class PromptHandler:
     self.console.print()
     self.console.print(table)
     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.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()):
-      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
 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 typing import Any, Dict, List, Set, Tuple, Optional
 from dataclasses import dataclass, field
+from collections import OrderedDict
 import logging
 import re
 from jinja2 import Environment, BaseLoader, meta, nodes, TemplateSyntaxError
@@ -10,6 +11,15 @@ import frontmatter
 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
 class Template:
   """Represents a template file with frontmatter and content."""
@@ -28,6 +38,8 @@ class Template:
   module: str = ""
   tags: 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
   variables: VariableCollection = field(default_factory=VariableCollection, init=False)
@@ -41,7 +53,10 @@ class Template:
       for name, value in variable_values.items():
         var = self.variables.get_variable(name)
         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()
     context = self.variables.to_jinja_context()
@@ -53,7 +68,12 @@ class Template:
     return self.variables.get_variable_names()
 
   @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."""
     logger.debug(f"Loading template from file: {file_path}")
 
@@ -73,15 +93,41 @@ class Template:
         module=frontmatter_data.get("module", ""),
         tags=frontmatter_data.get("tags", []),
         files=frontmatter_data.get("files", []),
+        library=library_name,
       )
 
       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)
-      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.template_var_names = tpl_names
       template.module_var_names = mod_names
+      template.variable_sections = cls._combine_sections_meta(
+        module_section_meta,
+        template_section_meta,
+        template.variables,
+      )
 
       logger.debug(
         f"Final variables for template '{template.id}': {template.variables.get_variable_names()}"
@@ -113,45 +159,6 @@ class Template:
       post = frontmatter.load(f)
     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
   def _extract_template_variables(content: str) -> Set[str]:
     """Extract variable names used in Jinja2 template content (flat names only).
@@ -192,8 +199,9 @@ class Template:
   @staticmethod
   def _merge_variables(
     content: str,
-    frontmatter_data: Dict[str, Any],
     module_variables: Dict[str, Any],
+    template_variables: Dict[str, Any],
+    template_id: str,
   ) -> Tuple[VariableCollection, List[str], List[str]]:
     """Merge module + frontmatter vars, auto-create missing, and apply Jinja defaults.
 
@@ -206,60 +214,158 @@ class Template:
     used_variables = Template._extract_template_variables(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()
 
-    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(
-      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
+
+  @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 typing import Any, Dict, List, Optional, Set
+from urllib.parse import urlparse
 import logging
+import re
 
 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
 class Variable:
-  """Represents a single templating variable.
-  
-  Supported types: str, int, float, bool, enum
-  """
+  """Represents a single templating variable with lightweight validation."""
+
   name: str
   description: Optional[str] = None
-  type: str = "str"  # str, int, float, bool, enum
+  type: str = "str"
   options: Optional[List[Any]] = field(default_factory=list)
   prompt: Optional[str] = None
   value: Any = None
+  section: Optional[str] = None
 
   @classmethod
   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,
       description=data.get("description") or data.get("display", ""),
       type=data.get("type", "str"),
       options=data.get("options", []),
       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
 
     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":
-      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":
-      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
 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)
 
   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)
     for name in specs.keys():
       if name not in used:
@@ -82,29 +149,27 @@ class VariableCollection:
         )
 
   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():
       if var_name in self.variables:
         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]:
-    """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] = {}
 
-    # First pass: direct mapping
     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:
-        value = ""  # Avoid None in Jinja output
+        value = ""
       context[var_name] = value
 
-    # Second pass: alias *_enabled -> root
     for var_name, variable in self.variables.items():
       if var_name.endswith("_enabled"):
         root = var_name[: -len("_enabled")]
@@ -113,13 +178,28 @@ class VariableCollection:
     return context
 
   def get_variable_names(self) -> List[str]:
-    """Get variable names in insertion order."""
     return list(self.variables.keys())
 
   def get_variable(self, name: str) -> Optional[Variable]:
-    """Get a specific variable by 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:
-    """Number of variables in the collection."""
     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.registry import registry
 
 
 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"
   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"]
 
-  # 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)

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

@@ -9,69 +9,68 @@ tags:
   - automation
   - workflows
   - compose
-variables:
-  ports.http:
-    description: "Port for HTTP access to n8n"
-    default: "5678"
 ---
 services:
-  {{ service_name }}:
+  {{ service_name | default('n8n') }}:
     image: n8nio/n8n:1.110.1
+    container_name: {{ container_name | default('n8n') }}
     environment:
       - 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_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 %}
-      - N8N_EDITOR_BASE_URL=http://{{ traefik.host }}
+      - N8N_EDITOR_BASE_URL=http://{{ traefik_host | default('n8n.home.arpa') }}
       {% endif %}
       {% endif %}
-      {% if postgres %}
+      {% if postgres_enabled %}
       - 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 %}
     volumes:
       - /etc/localtime:/etc/localtime:ro
       - data:/home/node/.n8n
-    {% if network %}
+    {% if network_enabled %}
     networks:
       - {{ network_name | default('bridge') }}
     {% endif %}
-    {% if traefik %}
+    {% if traefik_enabled %}
     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 %}
-      - traefik.http.routers.{{ service_name }}.entrypoints={{ traefik.entrypoint | default('web') }}
+      - traefik.http.routers.{{ service_name | default('n8n') }}.entrypoints={{ traefik_entrypoint | default('web') }}
       {% endif %}
-      - traefik.http.services.{{ service_name }}.loadbalancer.server.port=5678
+      - traefik.http.services.{{ service_name | default('n8n') }}.loadbalancer.server.port=5678
     {% endif %}
     restart: {{ restart_policy | default('unless-stopped') }}
-    {% if ports %}
+    {% if ports_enabled %}
     ports:
-      - "{{ ports.http | default('5678') }}:5678"
+      - "{{ ports_http | default(5678) }}:5678"
     {% endif %}
 
 volumes:
   data:
     driver: local
 
-{% if network %}
+{% if network_enabled %}
 networks:
   {{ network_name | default('bridge') }}:
   {% if network_external %}
     external: true
+  {% else %}
+    driver: bridge
   {% endif %}
 {% endif %}