Parcourir la source

fix: Hide enabler variables from optional configuration display

- Filter out enabler variables (network, traefik, swarm) from the optional configuration overview
- Enablers are now only shown when prompting for enable/disable, not in the optional values list
- Prevents redundant display of boolean enabler variables
xcad il y a 9 mois
Parent
commit
c3ca59e687

+ 0 - 49
cli/core/config.py

@@ -3,7 +3,6 @@
 from dataclasses import dataclass, field
 from pathlib import Path
 from typing import Any, Dict, List, Optional
-import json
 import logging
 import yaml
 
@@ -215,51 +214,3 @@ def set_config(config):
   """
   global _config
   _config = config
-
-
-# Legacy ConfigManager for backwards compatibility
-class ConfigManager:
-  """Legacy configuration manager for module configs.
-  
-  This is kept for backwards compatibility but uses json files.
-  """
-  
-  def __init__(self, config_dir=None):
-    if config_dir is None:
-      config_dir = Path.home() / ".boilerplates"
-    self.config_dir = config_dir
-    self.config_dir.mkdir(parents=True, exist_ok=True)
-  
-  def get_variable_defaults(self, module_name):
-    """Get user-configured default values for variables in a module."""
-    config_file = self.config_dir / f"{module_name}_vars.json"
-    if config_file.exists():
-      try:
-        with open(config_file, 'r') as f:
-          return json.load(f)
-      except json.JSONDecodeError:
-        logger.warning(f"Invalid JSON in {config_file}")
-    return {}
-  
-  def save_variable_defaults(self, module_name, variable_defaults):
-    """Save user-configured default values for variables in a module."""
-    config_file = self.config_dir / f"{module_name}_vars.json"
-    with open(config_file, 'w') as f:
-      json.dump(variable_defaults, f, indent=2)
-  
-  def get_module_config(self, module_name):
-    """Get module-specific configuration."""
-    config_file = self.config_dir / f"{module_name}.json"
-    if config_file.exists():
-      try:
-        with open(config_file, 'r') as f:
-          return json.load(f)
-      except json.JSONDecodeError:
-        logger.warning(f"Invalid JSON in {config_file}")
-    return {}
-  
-  def save_module_config(self, module_name, config):
-    """Save module-specific configuration."""
-    config_file = self.config_dir / f"{module_name}.json"
-    with open(config_file, 'w') as f:
-      json.dump(config, f, indent=2)

+ 47 - 46
cli/core/module.py

@@ -7,7 +7,7 @@ from rich.console import Console
 from .config import get_config
 from .exceptions import TemplateNotFoundError, TemplateValidationError
 from .library import LibraryManager
-from .prompt import PromptHandler
+from .prompt import SimplifiedPromptHandler
 from .variables import VariableRegistry
 
 logger = logging.getLogger('boilerplates')
@@ -67,11 +67,9 @@ class Module(ABC):
       if value:
         console.print(f"{label}: [cyan]{value}[/cyan]")
     
-    # Variable groups
+    # Variables
     if template.vars:
-      groups = self.variables.get_variables_for_template(template.vars)
-      if groups:
-        console.print(f"Functions: [cyan]{', '.join(groups.keys())}[/cyan]")
+      console.print(f"Variables: [cyan]{', '.join(sorted(template.vars))}[/cyan]")
     
     # Content
     if template.content:
@@ -122,59 +120,62 @@ class Module(ABC):
   
   def _validate_template(self, template, template_id: str) -> None:
     """Validate template and raise error if validation fails."""
-    errors = template.validate(set(self.variables.variables.keys()))
-    
-    if errors:
-      logger.error(f"Template '{template_id}' validation failed")
-      raise TemplateValidationError(template_id, errors)
+    # Get registered variables for validation
+    registered_vars = set(self.variables.variables.keys())
+    
+    # Validate will raise TemplateValidationError for critical errors (syntax)
+    # and return a list of warnings for non-critical issues
+    warnings = template.validate(registered_vars)
+    
+    # If there are non-critical warnings, log them but don't fail
+    if warnings:
+      logger.warning(f"Template '{template_id}' has validation warnings: {warnings}")
+      # Optionally, you could still raise an error for strict validation:
+      # raise TemplateValidationError(template_id, warnings)
   
   def _process_variables(self, template) -> Dict[str, Any]:
     """Process template variables with prompting."""
-    grouped_vars = self.variables.get_variables_for_template(list(template.vars))
-    if not grouped_vars:
+    # Get variables used in template that are registered
+    template_vars = self.variables.get_variables_for_template(list(template.vars))
+    if not template_vars:
       return {}
     
-    # Collect all defaults
-    defaults = {
-      var.name: var.default 
-      for group_vars in grouped_vars.values() 
-      for var in group_vars 
-      if var.default is not None
-    }
-    defaults.update(template.var_defaults)  # Template defaults override
+    # Collect all defaults from variables and template
+    defaults = {}
+    for var_name, var in template_vars.items():
+      if var.default is not None:
+        defaults[var_name] = var.default
+    
+    # Handle dict variable defaults specially
+    # Auto-detect dict type from template usage
+    for var_name, var in template_vars.items():
+      # If template uses dict access, treat it as dict type regardless of registration
+      if var_name in template.var_dict_keys:
+        # This is a dict variable with dynamic keys
+        # Get defaults for each key from template
+        if var_name in template.var_defaults and isinstance(template.var_defaults[var_name], dict):
+          if var_name not in defaults:
+            defaults[var_name] = {}
+          defaults[var_name].update(template.var_defaults[var_name])
+    
+    # Also add template defaults for regular variables
+    for k, v in template.var_defaults.items():
+      if not isinstance(v, dict):  # Skip dict defaults, already handled
+        defaults[k] = v
     
     # Use rich output if enabled
     if not get_config().use_rich_output:
       # Simple fallback - just prompt for missing values
       values = defaults.copy()
-      for group_vars in grouped_vars.values():
-        for var in group_vars:
-          if var.name not in values:
-            desc = f" ({var.description})" if var.description else ""
-            values[var.name] = input(f"Enter {var.name}{desc}: ")
+      for var_name, var in template_vars.items():
+        if var_name not in values:
+          desc = f" ({var.description})" if var.description else ""
+          values[var_name] = input(f"Enter {var_name}{desc}: ")
       return values
     
-    # Format for PromptHandler
-    formatted_groups = {}
-    for group_name, variables in grouped_vars.items():
-      group_info = self.variables.groups.get(group_name, {})
-      formatted_groups[group_name] = {
-        'display_name': group_info.get('display_name', group_name.title()),
-        'description': group_info.get('description', ''),
-        'icon': group_info.get('icon', ''),
-        'vars': {},
-        'enabler': self.variables.group_enablers.get(group_name, '')
-      }
-      
-      # Add usage patterns to each variable config
-      for var in variables:
-        var_config = var.to_prompt_config()
-        # Add usage patterns if this variable is used in the template
-        if var.name in template.var_usage:
-          var_config['usage_patterns'] = template.var_usage[var.name]
-        formatted_groups[group_name]['vars'][var.name] = var_config
-    
-    return PromptHandler(formatted_groups, defaults)()
+    # Pass dict keys info to prompt handler
+    # Use the new simplified prompt handler with dict support
+    return SimplifiedPromptHandler(template_vars, defaults, template.var_dict_keys)()
   
   def register_cli(self, app: Typer):
     """Register module commands with the main app."""

+ 205 - 435
cli/core/prompt.py

@@ -1,480 +1,250 @@
-from typing import Dict, Any, List, Optional
-import logging
+"""Simplified prompt handler for dotted notation variables."""
+from typing import Dict, Any, List, Tuple, Optional
+from collections import OrderedDict
 from rich.console import Console
 from rich.prompt import Prompt, Confirm, IntPrompt, FloatPrompt
-from rich.table import Table
-from rich import box
+import logging
 
 logger = logging.getLogger('boilerplates')
+console = Console()
 
-class PromptHandler:
-  """Prompt handler with Rich UI for variable configuration."""
 
-  def __init__(self, variable_groups: Dict[str, Any], resolved_defaults: Dict[str, Any] = None):
-    """Initialize the prompt handler.
+class SimplifiedPromptHandler:
+  """Clean prompt handler for dotted notation variables."""
+  
+  def __init__(self, variables: Dict[str, Any], defaults: Dict[str, Any], dict_keys: Dict[str, List[str]] = None):
+    """Initialize with template variables and defaults.
     
     Args:
-        variable_groups: Dictionary of variable groups from VariableManager
-        resolved_defaults: Pre-resolved default values with priority handling
+      variables: Dict of variable name to Variable object
+      defaults: Dict of variable name to default value
+      dict_keys: Dict variables and their keys used in template
     """
-    self.variable_groups = variable_groups
-    self.resolved_defaults = resolved_defaults or {}
-    self.console = Console()
-    self.final_values = {}
-    
-    # Map prompt types to their handlers
-    self.prompt_handlers = {
-      'boolean': self._prompt_boolean,
-      'integer': self._prompt_integer,
-      'float': self._prompt_float,
-      'choice': self._prompt_choice,
-      'list': self._prompt_list,
-      'string': self._prompt_string
-    }
+    self.variables = variables
+    self.defaults = defaults
+    self.dict_keys = dict_keys or {}
+    self.values = {}
     
   def __call__(self) -> Dict[str, Any]:
-    """Execute the prompting logic and return final variable values."""
-    logger.debug(f"Starting prompt handler with {len(self.variable_groups)} variable groups")
-
-    # Process groups in order (general first if exists)
-    for group_name, group_data in self._get_ordered_groups():
-      self._process_variable_group(group_name, group_data)
+    """Execute the prompting flow."""
+    # Group variables by prefix (preserves registration order)
+    groups, standalone = self._group_variables()
     
-    self._show_summary()
-    return self.final_values
-  
-  def _get_ordered_groups(self) -> List[tuple]:
-    """Get groups in processing order (general first)."""
-    ordered = []
-    if 'general' in self.variable_groups:
-      ordered.append(('general', self.variable_groups['general']))
+    # Process standalone variables first as "General" group
+    if standalone:
+      self._process_variable_set("General", standalone, is_group=False)
     
-    for name, data in self.variable_groups.items():
-      if name != 'general':
-        ordered.append((name, data))
+    # Process each group in the order they were registered
+    for group_name, group_vars in groups.items():
+      self._process_variable_set(group_name.title(), group_vars, is_group=True)
     
-    return ordered
+    return self.values
   
-  def _process_variable_group(self, group_name: str, group_data: Dict[str, Any]):
-    """Process a single variable group."""
-    variables = group_data.get('vars', {})
-    if not variables:
-      return
-
-    # Flatten multivalue variables to check which ones are truly required
-    required_items, optional_items = self._categorize_variables(variables)
-    
-    if not (required_items or optional_items):
-      return
+  def _group_variables(self) -> Tuple[Dict[str, List[str]], List[str]]:
+    """Group variables by their prefix, preserving registration order.
     
-    # Apply defaults for all optional items
-    for var_name, key, default_value in optional_items:
-      if key is not None:
-        # Multivalue with key
-        if var_name not in self.final_values:
-          self.final_values[var_name] = {}
-        self.final_values[var_name][key] = default_value
+    Returns:
+      (groups, standalone) where groups is {prefix: [var_names]}
+    """
+    groups = OrderedDict()
+    standalone = []
+    
+    # Process variables in registration order
+    for var_name in self.variables.keys():
+      if '.' in var_name:
+        # This is a child variable (e.g., 'traefik.host')
+        prefix = var_name.split('.')[0]
+        
+        # Create group if needed (preserves first encounter order)
+        if prefix not in groups:
+          groups[prefix] = []
+        
+        # Add to group
+        groups[prefix].append(var_name)
       else:
-        # Simple variable
-        self.final_values[var_name] = default_value
+        # Standalone variable (no dots)
+        standalone.append(var_name)
     
-    # Check for enabler variable
-    enabler_var_name = group_data.get('enabler', '')
-    has_enabler = enabler_var_name and enabler_var_name in variables
+    return groups, standalone
+  
+  def _process_variable_set(self, display_name: str, var_names: List[str], 
+                            is_group: bool = False):
+    """Unified method to process any set of variables.
     
-    # Determine if group should be processed
-    if has_enabler:
-      # Handle enabler group
-      enabled = self._prompt_enabler(group_name, enabler_var_name)
-      self.final_values[enabler_var_name] = enabled
+    Args:
+        display_name: Name to show in the header
+        var_names: List of variable names to process
+        is_group: Whether this is a group (vs standalone)
+    """
+    # Deduplicate variables
+    var_names = list(dict.fromkeys(var_names))  # Preserves order while removing duplicates
+    
+    # Check if this group has an enabler (standalone variable with same name as group)
+    group_name = display_name.lower()
+    enabler = None
+    if is_group and group_name in self.variables:
+      enabler = group_name
+      # Ask about enabler first
+      console.print(f"\n[bold cyan]{display_name} Configuration[/bold cyan]")
+      var = self.variables[enabler]
+      enabled = Confirm.ask(
+        f"Enable {enabler}?", 
+        default=bool(self.defaults.get(enabler, False))
+      )
+      self.values[enabler] = enabled
+      
       if not enabled:
+        # Skip all group variables
         return
-    elif not required_items:
-      # No required items, ask if user wants to configure optional ones
-      if not self._should_process_optional_group(group_name):
-        return
-    
-    # Show group header
-    self._show_group_header(group_name, group_data)
-    
-    # Process required items first
-    if required_items:
-      for var_name, key, _ in required_items:
-        if var_name == enabler_var_name:
-          continue  # Already handled
-        
-        if key is not None:
-          # Multivalue required key
-          value = self._prompt_for_multivalue_key(
-            var_name, key, variables[var_name], required=True
-          )
-          if var_name not in self.final_values:
-            self.final_values[var_name] = {}
-          self.final_values[var_name][key] = value
-        else:
-          # Simple required variable
-          self.final_values[var_name] = self._prompt_for_variable(
-            var_name, variables[var_name], required=True
-          )
-    
-    # Process optional items if user wants to change them
-    # Filter out already-prompted items from required_items
-    already_prompted = set((var_name, key) for var_name, key, _ in required_items)
-    optional_to_prompt = [
-      (var_name, key, default) for var_name, key, default in optional_items 
-      if var_name != enabler_var_name and (var_name, key) not in already_prompted
-    ]
     
-    if optional_to_prompt:
-      self._handle_optional_items(group_name, optional_to_prompt, variables)
-  
-  def _handle_optional_items(self, group_name: str, optional_items: list, variables: Dict[str, Any]):
-    """Handle optional items (variables or multivalue keys with defaults)."""
-    # Group items by variable for preview
-    vars_to_show = {}
-    for var_name, key, default in optional_items:
-      if var_name not in vars_to_show:
-        vars_to_show[var_name] = []
-      vars_to_show[var_name].append((key, default))
-    
-    # Show preview
-    self._show_preview(list(vars_to_show.keys()))
+    # Split into required and optional
+    required = []
+    optional = []
+    for var_name in var_names:
+      if var_name in self.defaults:
+        optional.append(var_name)
+      else:
+        required.append(var_name)
     
-    # Ask if user wants to customize
-    try:
-      want_to_customize = Confirm.ask(f"Do you want to change {group_name} values?", default=False)
-    except (EOFError, KeyboardInterrupt):
-      logger.debug(f"User interrupted customization for {group_name}, using defaults")
-      return
+    # Apply defaults
+    for var_name in optional:
+      self.values[var_name] = self.defaults[var_name]
     
-    if want_to_customize:
-      for var_name, key, default in optional_items:
-        var_data = variables[var_name]
-        
-        if key is not None:
-          # Multivalue item
-          value = self._prompt_for_multivalue_key(
-            var_name, key, var_data, required=False
-          )
-          if isinstance(key, int):
-            # Handle list index
-            if var_name not in self.final_values:
-              self.final_values[var_name] = []
-            while len(self.final_values[var_name]) <= key:
-              self.final_values[var_name].append(None)
-            self.final_values[var_name][key] = value
-          else:
-            # Handle dict key
-            if var_name not in self.final_values:
-              self.final_values[var_name] = {}
-            self.final_values[var_name][key] = value
+    # Process required variables
+    if required:
+      if not enabler:  # Don't repeat header if we already showed it for enabler
+        console.print(f"\n[bold cyan]{display_name} - Required Configuration[/bold cyan]")
+      for var_name in required:
+        var = self.variables[var_name]
+        if var_name in self.dict_keys:
+          console.print(f"\n[cyan]{var.description or var_name}[/cyan]")
+          self.values[var_name] = self._prompt_dict_variable(var_name, var)
         else:
-          # Simple variable
-          current_value = self.final_values.get(var_name)
-          self.final_values[var_name] = self._prompt_for_variable(
-            var_name, var_data, required=False, current_value=current_value
-          )
-  
-  def _categorize_variables(self, variables: Dict[str, Any]) -> tuple:
-    """Categorize variables into required and optional items.
-    
-    Returns:
-      (required_items, optional_items) where each item is (var_name, key_or_index, default_value)
-      For simple variables, key_or_index is None.
-    """
-    required_items = []
-    optional_items = []
-    
-    for var_name, var_data in variables.items():
-      patterns = var_data.get('usage_patterns', {})
+          display = var_name.replace('.', ' ') if is_group else var_name
+          self.values[var_name] = self._prompt_variable(display, var, required=True)
+    
+    # Process optional variables
+    if optional:
+      console.print(f"\n[bold cyan]{display_name} - Optional Configuration[/bold cyan]")
+      # Filter out enabler variables from display
+      display_optional = [v for v in optional if v != enabler]
+      if display_optional:
+        self._show_variables(display_optional)
       
-      if patterns and patterns.get('keys'):
-        # Multivalue with specific keys
-        for key in patterns['keys']:
-          # Check if this specific key has a default
-          default = None
-          if var_name in self.resolved_defaults and isinstance(self.resolved_defaults[var_name], dict):
-            default = self.resolved_defaults[var_name].get(key)
-          
-          if default is None:
-            required_items.append((var_name, key, None))
+      if Confirm.ask("Do you want to change any values?", default=False):
+        for var_name in optional:
+          # Skip the enabler variable as it was already handled
+          if var_name == enabler:
+            continue
+          var = self.variables[var_name]
+          if var_name in self.dict_keys:
+            console.print(f"\n[cyan]{var.description or var_name}[/cyan]")
+            self.values[var_name] = self._prompt_dict_variable(
+              var_name, var, current_values=self.values.get(var_name, {})
+            )
           else:
-            optional_items.append((var_name, key, default))
+            display = var_name.replace('.', ' ') if is_group else var_name
+            self.values[var_name] = self._prompt_variable(
+              display, var, current_value=self.values[var_name]
+            )
+  
+  
+  def _prompt_dict_variable(self, var_name: str, var: Any, current_values: Dict[str, Any] = None) -> Dict[str, Any]:
+    """Prompt for a dict variable with dynamic keys."""
+    result = {}
+    keys = self.dict_keys.get(var_name, [])
+    current_values = current_values or {}
+    
+    for key in keys:
+      # Use current value if available, otherwise check for default
+      current_value = current_values.get(key)
+      if current_value is None:
+        if var_name in self.defaults and isinstance(self.defaults[var_name], dict):
+          current_value = self.defaults[var_name].get(key)
       
-      elif patterns and patterns.get('indices'):
-        # Multivalue with specific indices  
-        for idx in patterns['indices']:
-          # Check if this specific index has a default
-          default = None
-          if var_name in self.resolved_defaults and isinstance(self.resolved_defaults[var_name], list):
-            if idx < len(self.resolved_defaults[var_name]):
-              default = self.resolved_defaults[var_name][idx]
-          
-          if default is None:
-            required_items.append((var_name, idx, None))
-          else:
-            optional_items.append((var_name, idx, default))
+      prompt_msg = f"Enter {var_name}['{key}']"
+      if current_value is not None:
+        prompt_msg += f" [dim]({current_value})[/dim]"
+      else:
+        prompt_msg += " [red](Required)[/red]"
       
+      # Infer type from current value or default
+      if isinstance(current_value, int):
+        while True:
+          try:
+            result[key] = IntPrompt.ask(prompt_msg, default=current_value)
+            break
+          except ValueError:
+            console.print("[red]Please enter a valid integer[/red]")
+      elif isinstance(current_value, bool):
+        result[key] = Confirm.ask(prompt_msg, default=current_value)
       else:
-        # Simple variable
-        default = self.resolved_defaults.get(var_name)
-        if default is None:
-          required_items.append((var_name, None, None))
+        value = Prompt.ask(prompt_msg, default=str(current_value) if current_value else None)
+        result[key] = value if value else current_value
+    
+    return result
+  
+  def _show_variables(self, var_names: List[str]):
+    """Display current variable values."""
+    for var_name in var_names:
+      value = self.values.get(var_name, self.defaults.get(var_name))
+      if value is not None:
+        display_name = var_name.replace('.', ' ')
+        # Special formatting for dict values - show each key separately
+        if isinstance(value, dict):
+          for key, val in value.items():
+            console.print(f"  {display_name}['{key}']: [dim]{val}[/dim]")
         else:
-          optional_items.append((var_name, None, default))
-    
-    return required_items, optional_items
-  
-  def _prompt_for_multivalue_key(self, var_name: str, key, var_data: Dict[str, Any], required: bool = False) -> Any:
-    """Prompt for a specific key/index of a multivalue variable."""
-    var_type = var_data.get('type', 'string')
-    
-    # Build prompt message
-    if isinstance(key, int):
-      prompt_msg = f"{var_name}[{key}]"
-    else:
-      prompt_msg = f"{var_name}['{key}']"
-    
-    if var_data.get('description'):
-      prompt_msg = f"Enter {prompt_msg} ({var_data['description']})"
-    else:
-      prompt_msg = f"Enter {prompt_msg}"
-    
-    if required:
-      prompt_msg += " [red](Required)[/red]"
-    
-    # Get current value if exists
-    current_value = None
-    if var_name in self.final_values:
-      if isinstance(self.final_values[var_name], dict) and key in self.final_values[var_name]:
-        current_value = self.final_values[var_name][key]
-      elif isinstance(self.final_values[var_name], list) and isinstance(key, int) and key < len(self.final_values[var_name]):
-        current_value = self.final_values[var_name][key]
-    
-    # Prompt based on type
-    handler = self.prompt_handlers.get(var_type, self.prompt_handlers['string'])
-    if var_type == 'string':
-      return handler(prompt_msg, current_value, required)
-    else:
-      return handler(prompt_msg, current_value)
-  
-  def _should_process_optional_group(self, group_name: str) -> bool:
-    """Ask if user wants to configure optional settings for a group."""
-    try:
-      return Confirm.ask(f"Do you want to configure {group_name} settings?", default=False)
-    except (EOFError, KeyboardInterrupt):
-      return False
-  
-  
-  def _prompt_enabler(self, group_name: str, enabler_var_name: str) -> bool:
-    """Prompt for a group enabler variable."""
-    current_value = self.final_values.get(enabler_var_name, False)
-    try:
-      return Confirm.ask(
-        f"Do you want to enable [bold]{group_name}[/bold]?",
-        default=bool(current_value)
-      )
-    except (EOFError, KeyboardInterrupt):
-      logger.debug(f"User interrupted enabler prompt for {group_name}")
-      return False
-  
-  def _show_group_header(self, group_name: str, group_data: Dict[str, Any]):
-    """Display group header."""
-    icon = group_data.get('icon', '')
-    display_name = group_data.get('display_name', group_name.title())
-    icon_display = f"{icon} " if icon else ""
-    self.console.print(f"\n{icon_display}[bold magenta]{display_name} Variables[/bold magenta]")
-  
-  
-  def _show_preview(self, variables: List[str]):
-    """Show preview of configured values."""
-    if not variables:
-      return
-    
-    previews = []
-    for var_name in variables:
-      value = self.final_values.get(var_name)
-      if value is None:
-        display_value = "not set"
-      elif isinstance(value, dict):
-        # Show dict values compactly
-        items = [f"{k}={v}" for k, v in value.items()]
-        display_value = "{" + ", ".join(items[:2]) + ("..." if len(items) > 2 else "") + "}"
-      elif isinstance(value, list):
-        # Show list values compactly
-        display_value = "[" + ", ".join(str(v) for v in value[:2]) + (", ..." if len(value) > 2 else "") + "]"
-      else:
-        display_value = str(value)[:22] + "..." if len(str(value)) > 25 else str(value)
-      previews.append(f"{var_name}={display_value}")
-    
-    self.console.print(f"[dim white]({', '.join(previews)})[/dim white]")
-  
-  
-  def _prompt_for_variable(self, var_name: str, var_data: Dict[str, Any], required: bool = False, current_value: Any = None) -> Any:
-    """Prompt user for a single variable.
-    
-    Note: Multivalue variables with patterns are handled separately via _prompt_for_multivalue_key.
-    This method only handles simple variables or multivalue without specific patterns.
-    """
-    var_type = var_data.get('type', 'string')
+          console.print(f"  {display_name}: [dim]{value}[/dim]")
+  
+  def _prompt_variable(
+    self, 
+    name: str, 
+    var: Any, 
+    required: bool = False,
+    current_value: Any = None
+  ) -> Any:
+    """Prompt for a single variable value."""
+    var_type = var.type if hasattr(var, 'type') else 'string'
+    description = var.description if hasattr(var, 'description') else ''
     
     # Build prompt message
-    prompt_message = self._build_prompt_message(var_name, var_data, required, current_value)
-    
-    # Get handler and execute prompt
-    handler = self.prompt_handlers.get(var_type, self.prompt_handlers['string'])
-    
-    try:
-      # Special handling for choice type (needs options)
-      if var_type == 'choice':
-        return handler(prompt_message, current_value, var_data.get('options', []))
-      # Special handling for string type (needs required flag)
-      elif var_type == 'string':
-        return handler(prompt_message, current_value, required)
-      else:
-        return handler(prompt_message, current_value)
-    except KeyboardInterrupt:
-      raise
-    except Exception as e:
-      logger.error(f"Error prompting for {var_name}: {e}")
-      self.console.print(f"[red]Error getting input for {var_name}[/red]")
-      # Fallback to string prompt
-      return self._prompt_string(prompt_message, current_value, required)
-  
-  def _build_prompt_message(self, var_name: str, var_data: Dict[str, Any], required: bool, current_value: Any) -> str:
-    """Build the prompt message for a variable."""
-    parts = ["Enter", f"[bold]{var_name}[/bold]"]
-    
-    if description := var_data.get('description'):
+    parts = [f"Enter {name}"]
+    if description:
       parts.append(f"({description})")
-    
     if current_value is not None:
       parts.append(f"[dim]({current_value})[/dim]")
     elif required:
       parts.append("[red](Required)[/red]")
     
-    return " ".join(parts)
-  
-  def _prompt_string(self, prompt_message: str, current_value: Any = None, required: bool = False) -> str:
-    """Prompt for string input."""
-    default = str(current_value) if current_value is not None else None
-    
-    while True:
-      try:
-        value = Prompt.ask(prompt_message, default=default) or ""
-        
-        if required and not value.strip():
-          self.console.print("[red]This field is required[/red]")
-          continue
-          
-        return value.strip()
-      except (EOFError, KeyboardInterrupt):
-        raise KeyboardInterrupt("Operation cancelled by user")
-  
-  def _prompt_boolean(self, prompt_message: str, current_value: Any = None) -> bool:
-    """Prompt for boolean input."""
-    default = bool(current_value) if current_value is not None else None
-    try:
-      return Confirm.ask(prompt_message, default=default)
-    except (EOFError, KeyboardInterrupt):
-      raise KeyboardInterrupt("Operation cancelled by user")
-  
-  def _prompt_integer(self, prompt_message: str, current_value: Any = None) -> int:
-    """Prompt for integer input."""
-    default = int(current_value) if current_value is not None else None
-    
-    while True:
-      try:
-        return IntPrompt.ask(prompt_message, default=default)
-      except ValueError:
-        self.console.print("[red]Please enter a valid integer[/red]")
-      except (EOFError, KeyboardInterrupt):
-        raise KeyboardInterrupt("Operation cancelled by user")
-  
-  def _prompt_float(self, prompt_message: str, current_value: Any = None) -> float:
-    """Prompt for float input."""
-    default = float(current_value) if current_value is not None else None
-    
-    while True:
-      try:
-        return FloatPrompt.ask(prompt_message, default=default)
-      except ValueError:
-        self.console.print("[red]Please enter a valid number[/red]")
-      except (EOFError, KeyboardInterrupt):
-        raise KeyboardInterrupt("Operation cancelled by user")
-  
-  def _prompt_choice(self, prompt_message: str, current_value: Any = None, options: List[Any] = None) -> Any:
-    """Prompt for choice from options."""
-    if not options:
-      return self._prompt_string(prompt_message, current_value)
+    prompt_msg = " ".join(parts)
     
-    # Show options
-    self.console.print("\n[dim]Available options:[/dim]")
-    for i, option in enumerate(options, 1):
-      marker = "→" if option == current_value else " "
-      self.console.print(f"  {marker} {i}. {option}")
+    # Handle different types
+    if var_type == 'boolean':
+      default = bool(current_value) if current_value is not None else None
+      return Confirm.ask(prompt_msg, default=default)
     
-    while True:
-      try:
-        choice = Prompt.ask(f"{prompt_message} (1-{len(options)})")
-        
-        # Try numeric selection
+    elif var_type == 'integer':
+      default = int(current_value) if current_value is not None else None
+      while True:
         try:
-          idx = int(choice) - 1
-          if 0 <= idx < len(options):
-            return options[idx]
+          return IntPrompt.ask(prompt_msg, default=default)
         except ValueError:
-          # Try string match
-          matches = [opt for opt in options if str(opt).lower() == choice.lower()]
-          if matches:
-            return matches[0]
-        
-        self.console.print(f"[red]Invalid choice. Enter 1-{len(options)} or option name[/red]")
-      except (EOFError, KeyboardInterrupt):
-        raise KeyboardInterrupt("Operation cancelled by user")
-  
-  def _prompt_list(self, prompt_message: str, current_value: Any = None) -> List[str]:
-    """Prompt for list input (comma-separated)."""
-    default = ", ".join(str(item) for item in current_value) if isinstance(current_value, list) else str(current_value or "")
-    
-    self.console.print("[dim]Enter values separated by commas[/dim]")
+          console.print("[red]Please enter a valid integer[/red]")
     
-    try:
-      value = Prompt.ask(prompt_message, default=default)
-      return [item.strip() for item in value.split(',') if item.strip()] if value.strip() else []
-    except (EOFError, KeyboardInterrupt):
-      raise KeyboardInterrupt("Operation cancelled by user")
-  
-  def _show_summary(self):
-    """Display summary of configured variables."""
-    if not self.final_values:
-      return
-    
-    # Compact summary for few variables, table for many
-    if len(self.final_values) <= 5:
-      summaries = []
-      for name, value in self.final_values.items():
-        display_value = self._truncate_value(value, 20)
-        summaries.append(f"[cyan]{name}[/cyan]=[green]{display_value}[/green]")
-      self.console.print(f"\n[dim]Using:[/dim] {', '.join(summaries)}")
-    else:
-      table = Table(box=box.SIMPLE)
-      table.add_column("Variable", style="cyan")
-      table.add_column("Value", style="green")
-      
-      for name, value in self.final_values.items():
-        display_value = self._truncate_value(value, 50)
-        table.add_row(name, display_value)
-      
-      self.console.print(table)
-    
-    self.console.print()
+    elif var_type == 'float':
+      default = float(current_value) if current_value is not None else None
+      while True:
+        try:
+          return FloatPrompt.ask(prompt_msg, default=default)
+        except ValueError:
+          console.print("[red]Please enter a valid number[/red]")
     
-    # Confirm generation
-    if not Confirm.ask("Proceed with generation?", default=True):
-      raise KeyboardInterrupt("Generation cancelled by user")
-  
-  def _truncate_value(self, value: Any, max_length: int) -> str:
-    """Truncate value for display."""
-    display_value = ", ".join(str(item) for item in value) if isinstance(value, list) else str(value)
-    return display_value[:max_length-3] + "..." if len(display_value) > max_length else display_value
+    else:  # string
+      default = str(current_value) if current_value is not None else None
+      while True:
+        value = Prompt.ask(prompt_msg, default=default) or ""
+        if required and not value.strip():
+          console.print("[red]This field is required[/red]")
+          continue
+        return value.strip()

+ 130 - 137
cli/core/template.py

@@ -1,5 +1,6 @@
 from pathlib import Path
-from typing import Any, Dict, Set, Tuple, List
+from typing import Any, Dict, List, Set, Tuple
+from dataclasses import dataclass, field
 import logging
 import re
 from jinja2 import Environment, BaseLoader, meta, nodes, TemplateSyntaxError
@@ -7,9 +8,50 @@ import frontmatter
 from .exceptions import TemplateValidationError
 
 
+@dataclass
 class Template:
   """Data class for template information extracted from frontmatter."""
   
+  # Required fields
+  file_path: Path
+  content: str = ""
+  
+  # Frontmatter fields with defaults
+  name: str = ""
+  description: str = "No description available"
+  author: str = ""
+  date: str = ""
+  version: str = ""
+  module: str = ""
+  tags: List[str] = field(default_factory=list)
+  files: List[str] = field(default_factory=list)
+  
+  # Computed properties (will be set in __post_init__)
+  id: str = field(init=False)
+  directory: str = field(init=False)
+  relative_path: str = field(init=False)
+  size: int = field(init=False)
+  
+  # Template variable analysis results
+  vars: Set[str] = field(default_factory=set, init=False)
+  var_defaults: Dict[str, Any] = field(default_factory=dict, init=False)
+  var_dict_keys: Dict[str, List[str]] = field(default_factory=dict, init=False)  # Track dict access patterns
+  
+  def __post_init__(self):
+    """Initialize computed properties after dataclass initialization."""
+    # Set default name if not provided
+    if not self.name:
+      self.name = self.file_path.parent.name
+    
+    # Computed properties
+    self.id = self.file_path.parent.name
+    self.directory = self.file_path.parent.name
+    self.relative_path = self.file_path.name
+    self.size = self.file_path.stat().st_size if self.file_path.exists() else 0
+    
+    # Parse template variables
+    self.vars, self.var_defaults, self.var_dict_keys = self._parse_template_variables(self.content)
+  
   @staticmethod
   def _create_jinja_env() -> Environment:
     """Create standardized Jinja2 environment for consistent template processing."""
@@ -19,46 +61,27 @@ class Template:
       lstrip_blocks=True,         # Strip leading whitespace from block tags  
       keep_trailing_newline=False  # Remove trailing newlines
     )
-  
-  def __init__(self, file_path: Path, frontmatter_data: Dict[str, Any], content: str):
-    self.file_path = file_path
-    self.content = content
-    
-    # Extract frontmatter fields with defaults
-    self.name = frontmatter_data.get('name', file_path.parent.name)  # Use directory name as default
-    self.description = frontmatter_data.get('description', 'No description available')
-    self.author = frontmatter_data.get('author', '')
-    self.date = frontmatter_data.get('date', '')
-    self.version = frontmatter_data.get('version', '')
-    self.module = frontmatter_data.get('module', '')
-    self.tags = frontmatter_data.get('tags', [])
-    self.files = frontmatter_data.get('files', [])
-    
-    # Additional computed properties
-    self.id = file_path.parent.name  # Unique identifier (parent directory name)
-    self.directory = file_path.parent.name  # Directory name where the template is located
-    self.relative_path = file_path.name
-    self.size = file_path.stat().st_size if file_path.exists() else 0
-    
-    # Extract variables and defaults from the template content
-    # vars: Set[str] - All Jinja2 variable names found in template (e.g., {'app_name', 'port', 'debug'})
-    # var_defaults: Dict[str, Any] - Default values from | default() filters (e.g., {'app_name': 'my-app', 'port': 8080})
-    # var_usage: Dict[str, Dict] - How variables are used (simple, array indices, dict keys)
-    self.vars, self.var_defaults, self.var_usage = self._parse_template_variables(content)
 
   @classmethod
   def from_file(cls, file_path: Path) -> "Template":
     """Create a Template instance from a file path."""
     try:
       frontmatter_data, content = cls._parse_frontmatter(file_path)
-      return cls(file_path=file_path, frontmatter_data=frontmatter_data, content=content)
-    except Exception:
-      # If frontmatter parsing fails, create a basic Template object
       return cls(
         file_path=file_path,
-        frontmatter_data={'name': file_path.parent.name},
-        content=""
+        content=content,
+        name=frontmatter_data.get('name', ''),
+        description=frontmatter_data.get('description', 'No description available'),
+        author=frontmatter_data.get('author', ''),
+        date=frontmatter_data.get('date', ''),
+        version=frontmatter_data.get('version', ''),
+        module=frontmatter_data.get('module', ''),
+        tags=frontmatter_data.get('tags', []),
+        files=frontmatter_data.get('files', [])
       )
+    except Exception:
+      # If frontmatter parsing fails, create a basic Template object
+      return cls(file_path=file_path)
   
   @staticmethod
   def _parse_frontmatter(file_path: Path) -> Tuple[Dict[str, Any], str]:
@@ -67,18 +90,15 @@ class Template:
       post = frontmatter.load(f)
     return post.metadata, post.content
   
-  def _parse_template_variables(self, template_content: str) -> Tuple[Set[str], Dict[str, Any], Dict[str, Dict]]:
-    """Parse Jinja2 template to extract variables, defaults, and usage patterns.
+  def _parse_template_variables(self, template_content: str) -> Tuple[Set[str], Dict[str, Any], Dict[str, List[str]]]:
+    """Parse Jinja2 template to extract variables and their defaults.
     
-    Examples:
-        {{ app_name | default('my-app') }} → Simple variable
-        {{ service_port['http'] }} → Dict with key 'http'
-        {{ service_port.https }} → Dict with key 'https' (dot notation)
-        {{ docker_network[0] }} → Array with index 0
-        {{ ports[item.name] }} → Dynamic dict key
+    Two separate approaches:
+    - Dotted notation (traefik.host): Must be defined in module
+    - Dict notation (service_port['http']): Dynamic keys allowed
     
     Returns:
-        Tuple of (all_variable_names, variable_defaults, variable_usage_patterns)
+        Tuple of (all_variable_names, variable_defaults, dict_access_patterns)
     """
     try:
       env = self._create_jinja_env()
@@ -87,13 +107,32 @@ class Template:
       # Start with variables found by Jinja2's meta utility
       all_variables = meta.find_undeclared_variables(ast)
       
-      # Add variables used in Getattr and Getitem nodes
-      for node in ast.find_all((nodes.Getattr, nodes.Getitem)):
-          current_node = node.node
-          while isinstance(current_node, (nodes.Getattr, nodes.Getitem)):
-              current_node = current_node.node
-          if isinstance(current_node, nodes.Name):
-              all_variables.add(current_node.name)
+      # Track dict access patterns (e.g., service_port['http'])
+      dict_keys = {}  # var_name -> list of keys accessed
+      for node in ast.find_all(nodes.Getitem):
+        if isinstance(node.arg, nodes.Const) and isinstance(node.arg.value, str):
+          # This is dict access with a string key
+          current = node.node
+          if isinstance(current, nodes.Name):
+            var_name = current.name
+            key = node.arg.value
+            if var_name not in dict_keys:
+              dict_keys[var_name] = []
+            if key not in dict_keys[var_name]:
+              dict_keys[var_name].append(key)
+      
+      # Handle dotted notation variables (like traefik.host, swarm.replicas)
+      for node in ast.find_all(nodes.Getattr):
+        current = node.node
+        # Build the full dotted name
+        parts = [node.attr]
+        while isinstance(current, nodes.Getattr):
+          parts.insert(0, current.attr)
+          current = current.node
+        if isinstance(current, nodes.Name):
+          parts.insert(0, current.name)
+          # Add the full dotted variable name
+          all_variables.add('.'.join(parts))
       
       # Extract default values from | default() filters
       defaults = {}
@@ -104,7 +143,7 @@ class Template:
             defaults[node.node.name] = node.args[0].value
           # Handle dict access defaults: {{ var['key'] | default(value) }}
           elif isinstance(node.node, nodes.Getitem):
-            if isinstance(node.node.node, nodes.Name) and isinstance(node.node.arg, nodes.Const):
+            if isinstance(node.node.arg, nodes.Const) and isinstance(node.node.node, nodes.Name):
               var_name = node.node.node.name
               key = node.node.arg.value
               if var_name not in defaults:
@@ -112,81 +151,67 @@ class Template:
               if not isinstance(defaults[var_name], dict):
                 defaults[var_name] = {}
               defaults[var_name][key] = node.args[0].value
+          # Handle dotted variable defaults: {{ port.http | default(8080) }}
+          elif isinstance(node.node, nodes.Getattr):
+            # Build the full dotted name
+            current = node.node
+            parts = []
+            while isinstance(current, nodes.Getattr):
+              parts.insert(0, current.attr)
+              current = current.node
+            if isinstance(current, nodes.Name):
+              parts.insert(0, current.name)
+              var_name = '.'.join(parts)
+              defaults[var_name] = node.args[0].value
       
-      # Analyze variable usage patterns for multivalue support
-      usage_patterns = self._analyze_variable_patterns(template_content)
-      
-      return all_variables, defaults, usage_patterns
+      return all_variables, defaults, dict_keys
     except Exception as e:
       logging.getLogger('boilerplates').debug(f"Error parsing template variables: {e}")
       return set(), {}, {}
-  
-  def _analyze_variable_patterns(self, template_content: str) -> Dict[str, Dict]:
-    """Analyze how variables are used in the template to detect multivalue patterns.
-    
-    Returns a dict mapping variable names to their usage info:
-    {
-      'service_port': {
-        'keys': ['http', 'https'],  # Keys used with this variable
-        'indices': [],               # Numeric indices used
-      }
-    }
-    """
-    patterns = {}
-    
-    # Pattern for dict access: variable['key'] or variable["key"]
-    dict_pattern = r'{{\s*(\w+)\[[\'"]([\w-]+)[\'"]\]'
-    for match in re.finditer(dict_pattern, template_content):
-      var_name, key = match.groups()
-      if var_name not in patterns:
-        patterns[var_name] = {'keys': [], 'indices': []}
-      if key not in patterns[var_name]['keys']:
-        patterns[var_name]['keys'].append(key)
-    
-    # Pattern for numeric index: variable[0], variable[1], etc.
-    index_pattern = r'{{\s*(\w+)\[(\d+)\]'
-    for match in re.finditer(index_pattern, template_content):
-      var_name, index = match.groups()
-      if var_name not in patterns:
-        patterns[var_name] = {'keys': [], 'indices': []}
-      idx = int(index)
-      if idx not in patterns[var_name]['indices']:
-        patterns[var_name]['indices'].append(idx)
-    
-    # Sort indices if present
-    for var_name in patterns:
-      patterns[var_name]['indices'].sort()
-    
-    return patterns
 
-  def validate(self, registered_variables=None):
+  def validate(self, registered_variables: Set[str]) -> List[str]:
     """Validate template integrity.
     
     Args:
-        registered_variables: Optional set of variable names registered by the module.
-                             If provided, checks for undefined variables.
+        registered_variables: Set of variable names registered by the module.
+                             Required for proper variable validation.
     
     Returns:
         List of validation error messages. Empty list if valid.
+    
+    Raises:
+        TemplateValidationError: If validation fails (critical errors only).
     """
     errors = []
     
-    # Check for Jinja2 syntax errors
+    # Check for Jinja2 syntax errors (critical - should raise immediately)
     try:
       env = self._create_jinja_env()
       env.from_string(self.content)
     except TemplateSyntaxError as e:
-      errors.append(f"Invalid Jinja2 syntax at line {e.lineno}: {e.message}")
+      raise TemplateValidationError(self.id, [f"Invalid Jinja2 syntax at line {e.lineno}: {e.message}"])
     except Exception as e:
-      errors.append(f"Template parsing error: {str(e)}")
-    
-    # Check for undefined variables if registered variables are provided
-    if registered_variables is not None:
-      # Variables that are used in template but not defined anywhere
-      undefined = self.vars - set(self.var_defaults.keys()) - registered_variables
-      if undefined:
-        var_list = ", ".join(sorted(undefined))
-        errors.append(f"Undefined variables: {var_list}")
+      raise TemplateValidationError(self.id, [f"Template parsing error: {str(e)}"])
+    
+    # Check for undefined variables
+    # Dotted variables MUST be registered in the module (even if they have defaults)
+    # Dict variables only need the base name registered
+    undefined = []
+    for var in self.vars:
+      if '.' in var:
+        # Dotted variable MUST be registered (defaults don't excuse them)
+        if var not in registered_variables:
+          undefined.append(var)
+      else:
+        # Non-dotted variable
+        if var not in registered_variables and var not in self.var_defaults:
+          # Check if it's a dict variable with keys
+          if var not in self.var_dict_keys:
+            undefined.append(var)
+    
+    if undefined:
+      var_list = ", ".join(sorted(undefined))
+      errors.append(f"Undefined variables (dotted variables must be registered in module): {var_list}")
     
     # Check for missing required frontmatter fields
     if not self.name or self.name == self.file_path.parent.name:
@@ -200,38 +225,6 @@ class Template:
       errors.append("Template has no content")
     
     return errors
-  
-  def validate_strict(self, registered_variables=None):
-    """Validate template and raise exception if invalid.
-    
-    Args:
-        registered_variables: Optional set of variable names registered by the module.
-    
-    Raises:
-        TemplateValidationError: If validation fails
-    """
-    errors = self.validate(registered_variables)
-    if errors:
-      raise TemplateValidationError(self.id, errors)
-
-  def to_dict(self) -> Dict[str, Any]:
-    """Convert to dictionary for display."""
-    return {
-      'id': self.id,
-      'name': self.name,
-      'description': self.description,
-      'author': self.author,
-      'date': self.date,
-      'version': self.version,
-      'module': self.module,
-      'tags': self.tags,
-      'files': self.files,
-      'directory': self.directory,
-      'path': str(self.relative_path),
-      'size': f"{self.size:,} bytes",
-      'vars': list(self.vars),
-      'var_defaults': self.var_defaults
-    }
 
   def render(self, variable_values: Dict[str, Any]) -> str:
     """Render the template with the provided variable values."""

+ 57 - 57
cli/core/variables.py

@@ -1,19 +1,27 @@
-from typing import Any, Dict, List
+from typing import Any, Dict, List, Tuple
 from dataclasses import dataclass, field
 from collections import OrderedDict
 
 
 @dataclass
 class Variable:
-  """Variable with all necessary properties."""
+  """Variable with automatic grouping via dotted notation.
+  
+  Variables are automatically grouped by their prefix:
+  - 'traefik' is a standalone boolean (enabler)
+  - 'traefik.host' is part of the traefik group
+  - 'port.http' is part of the port group
+  
+  Type can be:
+  - 'string': String value (default)
+  - 'integer': Integer value  
+  - 'float': Float value
+  - 'boolean': Boolean value
+  """
   name: str
   description: str = ""
   default: Any = None
-  type: str = "string"
-  options: List[Any] = field(default_factory=list)  # FIXME: not needed
-  group: str = "general"
-  required: bool = False  # FIXME: not needed
-  multivalue: bool = False  # If True, variable can accept multiple values as dict/list
+  type: str = "string"  # string, integer, float, boolean
   
   def to_prompt_config(self) -> Dict[str, Any]:
     """Convert to prompt configuration."""
@@ -21,69 +29,61 @@ class Variable:
       'name': self.name,
       'description': self.description, 
       'type': self.type,
-      'options': self.options,
-      'required': self.required,
-      'default': self.default,
-      'multivalue': self.multivalue
+      'default': self.default
     }
 
 
 class VariableRegistry:
-  """Variable management for modules."""
+  """Simplified variable registry with automatic grouping via dotted notation."""
   
   def __init__(self):
     self.variables: Dict[str, Variable] = OrderedDict()
-    self.groups: Dict[str, Dict[str, Any]] = OrderedDict()
-    self.registration_order: Dict[str, List[str]] = {}  # group -> ordered list of variable names
-    self.group_enablers: Dict[str, str] = {}  # group -> enabler variable name
   
-  def register_variable(self, var: Variable) -> None:
+  def register(self, var: Variable) -> None:
     """Register a variable."""
     self.variables[var.name] = var
-    # Track registration order per group
-    if var.group not in self.registration_order:
-      self.registration_order[var.group] = []
-    self.registration_order[var.group].append(var.name)
-    
-  def register_group(self, name: str, display_name: str, 
-                    description: str = "", icon: str = "", enabler: str = "") -> None:
-    """Register a variable group.
+  
+  def get_variables_for_template(self, template_vars: List[str]) -> Dict[str, Variable]:
+    """Get variables that are used in the template.
     
-    Args:
-        name: Internal group name
-        display_name: Display name for the group
-        description: Group description
-        icon: Optional icon for the group
-        enabler: Optional variable name that controls if this group is enabled
+    Returns a dict of variable name to Variable object for all
+    variables used in the template, preserving registration order.
     """
-    self.groups[name] = {
-      'display_name': display_name,
-      'description': description,
-      'icon': icon
-    }
-    
-    if enabler:
-      self.group_enablers[name] = enabler
+    result = OrderedDict()
+    # Iterate through registered variables to preserve registration order
+    for var_name in self.variables.keys():
+      if var_name in template_vars:
+        result[var_name] = self.variables[var_name]
+    return result
   
-  def get_variables_for_template(self, template_vars: List[str]) -> Dict[str, List[Variable]]:
-    """Get variables grouped by their group name, preserving registration order."""
-    grouped = OrderedDict()
+  def group_variables(self, variables: Dict[str, Variable]) -> Tuple[Dict[str, List[str]], List[str]]:
+    """Automatically group variables by their dotted notation prefix.
     
-    # First, organize variables by group
-    temp_grouped = {}
-    for var_name in template_vars:
-      if var_name in self.variables:
-        var = self.variables[var_name]
-        if var.group not in temp_grouped:
-          temp_grouped[var.group] = []
-        temp_grouped[var.group].append(var)
+    Returns:
+      (groups, standalone) where:
+      - groups: Dict mapping group name to list of variable names in that group
+      - standalone: List of variable names that aren't in any group
+    """
+    groups = OrderedDict()
+    standalone = []
+    all_var_names = list(variables.keys())
     
-    # Then, sort variables within each group by registration order
-    for group_name, vars_list in temp_grouped.items():
-      grouped[group_name] = sorted(
-        vars_list,
-        key=lambda v: self.registration_order.get(group_name, []).index(v.name) 
-        if v.name in self.registration_order.get(group_name, []) else float('inf')
-      )
+    for var_name in all_var_names:
+      if '.' in var_name:
+        # This is a grouped variable like 'traefik.host'
+        prefix = var_name.split('.')[0]
+        if prefix not in groups:
+          groups[prefix] = []
+        groups[prefix].append(var_name)
+      else:
+        # Check if this is a group parent (has children)
+        is_group_parent = any(v.startswith(f"{var_name}.") for v in all_var_names)
+        if is_group_parent:
+          if var_name not in groups:
+            groups[var_name] = []
+          # The parent itself is not added to the group list, it's the enabler
+        else:
+          # Truly standalone variable
+          standalone.append(var_name)
     
-    return grouped
+    return groups, standalone

+ 8 - 12
cli/modules/ansible.py

@@ -1,17 +1,13 @@
 from ..core.module import Module
-from ..core.registry import register_module
+from ..core.registry import registry
 
-@register_module(
-  name="ansible",
-  description="Manage Ansible playbooks and configurations",
-  files=["playbook.yml", "playbook.yaml", "main.yml", "main.yaml", "site.yml", "site.yaml"],
-  priority=8
-)
 class AnsibleModule(Module):
   """Module for managing Ansible playbooks and configurations."""
+  
+  name = "ansible"
+  description = "Manage Ansible playbooks and configurations"
+  files = ["playbook.yml", "playbook.yaml", "main.yml", "main.yaml", 
+           "site.yml", "site.yaml"]
 
-  def __init__(self):
-    super().__init__(name=self.name, description=self.description, files=self.files)
-
-  def register(self, app):
-    return super().register(app)
+# Register the module
+registry.register(AnsibleModule)

+ 61 - 56
cli/modules/compose.py

@@ -10,79 +10,84 @@ class ComposeModule(Module):
   files = ["docker-compose.yml", "compose.yml", "compose.yaml"]
   
   def _init_variables(self):
-    """Initialize Compose-specific variables."""
-    # Register groups
-    self.variables.register_group(
-      "general", "General Settings",
-      "Basic configuration for Docker Compose services"
-    )
-
-    self.variables.register_group(
-      "swarm", "Docker Swarm Settings",
-      "Settings for deploying services in Docker Swarm mode", icon="󰒋 ", enabler="swarm"
-    )
-
-    self.variables.register_group(
-      "traefik", "Traefik Configuration", 
-      "Reverse proxy settings", icon="󰞉 ", enabler="traefik"
-    )
-    
-    # Register variables
-    self.variables.register_variable(Variable(
+    """Initialize Compose-specific variables with dotted notation."""
+    # General standalone variables - register first
+    self.variables.register(Variable(
       name="service_name",
-      description="Name of the service",
-      group="general"
+      description="Name of the service"
     ))
     
-    self.variables.register_variable(Variable(
+    self.variables.register(Variable(
       name="container_name",
-      description="Container name",
-      group="general"
+      description="Container name"
     ))
-
-    self.variables.register_variable(Variable(
+    
+    # Variable for dynamic port mappings (dict type auto-detected from template)
+    self.variables.register(Variable(
       name="service_port",
-      description="Port(s) the service listens on (can be single or multiple)",
-      type="integer",
-      group="general",
-      multivalue=True
+      description="Service port mappings"
     ))
-
-    self.variables.register_variable(Variable(
-      name="swarm",
-      description="Enable Docker Swarm mode",
+    
+    # Network group - enabler controls whether to use network
+    self.variables.register(Variable(
+      name="network",
+      description="Enable custom network",
       type="boolean",
-      default=False,
-      group="swarm"
+      default=False
     ))
-
-    self.variables.register_variable(Variable(
+    
+    self.variables.register(Variable(
+      name="network.name",
+      description="Docker network name",
+      default="bridge"
+    ))
+    
+    self.variables.register(Variable(
+      name="network.external",
+      description="Is network external",
+      type="boolean",
+      default=True
+    ))
+    
+    # Traefik group - enabler controls whether to use Traefik
+    self.variables.register(Variable(
       name="traefik",
-      description="Enable Traefik",
+      description="Enable Traefik reverse proxy",
       type="boolean",
-      default=False,
-      group="traefik"
+      default=False
     ))
     
-    self.variables.register_variable(Variable(
-      name="traefik_host",
-      description="Traefik hostname",
-      group="traefik"
+    self.variables.register(Variable(
+      name="traefik.host",
+      description="Hostname for Traefik routing"
+    ))
+    
+    self.variables.register(Variable(
+      name="traefik.tls",
+      description="Enable TLS",
+      type="boolean",
+      default=True
     ))
 
-    self.variables.register_variable(Variable(
-      name="traefik_certresolver",
-      description="Traefik certificate resolver",
-      group="traefik"
+    self.variables.register(Variable(
+      name="traefik.certresolver",
+      description="Certificate resolver name",
+      default="letsencrypt"
     ))
     
-    # Add docker_network as a multivalue example
-    self.variables.register_variable(Variable(
-      name="docker_network",
-      description="Docker network(s) to connect to",
-      type="string",
-      group="general",
-      multivalue=True
+    # Swarm group - enabler controls whether to use Swarm mode
+    self.variables.register(Variable(
+      name="swarm",
+      description="Enable Docker Swarm mode",
+      type="boolean",
+      default=False
+    ))
+    
+    self.variables.register(Variable(
+      name="swarm.replicas",
+      description="Number of replicas in swarm mode",
+      type="integer",
+      default=1
     ))
 
 # Register the module

+ 7 - 11
cli/modules/docker.py

@@ -1,16 +1,12 @@
 from ..core.module import Module
-from ..core.registry import register_module
+from ..core.registry import registry
 
-@register_module(
-  name="docker",
-  description="Manage Docker configurations and files",
-  files=["Dockerfile", "dockerfile", ".dockerignore"]
-)
 class DockerModule(Module):
   """Module for managing Docker configurations and files."""
+  
+  name = "docker"
+  description = "Manage Docker configurations and files"
+  files = ["Dockerfile", "dockerfile", ".dockerignore"]
 
-  def __init__(self):
-    super().__init__(name=self.name, description=self.description, files=self.files)
-
-  def register(self, app):
-    return super().register(app)
+# Register the module
+registry.register(DockerModule)

+ 7 - 11
cli/modules/github_actions.py

@@ -1,16 +1,12 @@
 from ..core.module import Module
-from ..core.registry import register_module
+from ..core.registry import registry
 
-@register_module(
-  name="github-actions",
-  description="Manage GitHub Actions workflows",
-  files=["action.yml", "action.yaml", "workflow.yml", "workflow.yaml"]
-)
 class GitHubActionsModule(Module):
   """Module for managing GitHub Actions workflows."""
+  
+  name = "github-actions"
+  description = "Manage GitHub Actions workflows"
+  files = ["action.yml", "action.yaml", "workflow.yml", "workflow.yaml"]
 
-  def __init__(self):
-    super().__init__(name=self.name, description=self.description, files=self.files)
-
-  def register(self, app):
-    return super().register(app)
+# Register the module
+registry.register(GitHubActionsModule)

+ 7 - 11
cli/modules/gitlab_ci.py

@@ -1,16 +1,12 @@
 from ..core.module import Module
-from ..core.registry import register_module
+from ..core.registry import registry
 
-@register_module(
-  name="gitlab-ci",
-  description="Manage GitLab CI/CD pipelines",
-  files=[".gitlab-ci.yml", ".gitlab-ci.yaml", "gitlab-ci.yml", "gitlab-ci.yaml"]
-)
 class GitLabCIModule(Module):
   """Module for managing GitLab CI/CD pipelines."""
+  
+  name = "gitlab-ci"
+  description = "Manage GitLab CI/CD pipelines"
+  files = [".gitlab-ci.yml", ".gitlab-ci.yaml", "gitlab-ci.yml", "gitlab-ci.yaml"]
 
-  def __init__(self):
-    super().__init__(name=self.name, description=self.description, files=self.files)
-
-  def register(self, app):
-    return super().register(app)
+# Register the module
+registry.register(GitLabCIModule)

+ 7 - 11
cli/modules/kestra.py

@@ -1,16 +1,12 @@
 from ..core.module import Module
-from ..core.registry import register_module
+from ..core.registry import registry
 
-@register_module(
-  name="kestra",
-  description="Manage Kestra workflows and configurations",
-  files=["inputs.yaml", "variables.yaml", "webhook.yaml", "flow.yml", "flow.yaml"]
-)
 class KestraModule(Module):
   """Module for managing Kestra workflows and configurations."""
+  
+  name = "kestra"
+  description = "Manage Kestra workflows and configurations"
+  files = ["inputs.yaml", "variables.yaml", "webhook.yaml", "flow.yml", "flow.yaml"]
 
-  def __init__(self):
-    super().__init__(name=self.name, description=self.description, files=self.files)
-
-  def register(self, app):
-    return super().register(app)
+# Register the module
+registry.register(KestraModule)

+ 8 - 13
cli/modules/kubernetes.py

@@ -1,18 +1,13 @@
 from ..core.module import Module
-from ..core.registry import register_module
+from ..core.registry import registry
 
-@register_module(
-  name="kubernetes",
-  description="Manage Kubernetes manifests and configurations",
-  files=["deployment.yml", "deployment.yaml", "service.yml", "service.yaml", "manifest.yml", "manifest.yaml", "values.yml", "values.yaml"],
-  priority=5,
-  dependencies=["docker"]
-)
 class KubernetesModule(Module):
   """Module for managing Kubernetes manifests and configurations."""
+  
+  name = "kubernetes"
+  description = "Manage Kubernetes manifests and configurations"
+  files = ["deployment.yml", "deployment.yaml", "service.yml", "service.yaml", 
+           "manifest.yml", "manifest.yaml", "values.yml", "values.yaml"]
 
-  def __init__(self):
-    super().__init__(name=self.name, description=self.description, files=self.files)
-
-  def register(self, app):
-    return super().register(app)
+# Register the module
+registry.register(KubernetesModule)

+ 7 - 11
cli/modules/packer.py

@@ -1,16 +1,12 @@
 from ..core.module import Module
-from ..core.registry import register_module
+from ..core.registry import registry
 
-@register_module(
-  name="packer",
-  description="Manage Packer templates and configurations",
-  files=["template.pkr.hcl", "build.pkr.hcl", "variables.pkr.hcl", "sources.pkr.hcl"]
-)
 class PackerModule(Module):
   """Module for managing Packer templates and configurations."""
+  
+  name = "packer"
+  description = "Manage Packer templates and configurations"
+  files = ["template.pkr.hcl", "build.pkr.hcl", "variables.pkr.hcl", "sources.pkr.hcl"]
 
-  def __init__(self):
-    super().__init__(name=self.name, description=self.description, files=self.files)
-
-  def register(self, app):
-    return super().register(app)
+# Register the module
+registry.register(PackerModule)

+ 7 - 11
cli/modules/vagrant.py

@@ -1,16 +1,12 @@
 from ..core.module import Module
-from ..core.registry import register_module
+from ..core.registry import registry
 
-@register_module(
-  name="vagrant",
-  description="Manage Vagrant configurations and files",
-  files=["Vagrantfile", "vagrantfile"]
-)
 class VagrantModule(Module):
   """Module for managing Vagrant configurations and files."""
+  
+  name = "vagrant"
+  description = "Manage Vagrant configurations and files"
+  files = ["Vagrantfile", "vagrantfile"]
 
-  def __init__(self):
-    super().__init__(name=self.name, description=self.description, files=self.files)
-
-  def register(self, app):
-    return super().register(app)
+# Register the module
+registry.register(VagrantModule)

+ 20 - 11
library/compose/nginx/compose.yaml

@@ -17,22 +17,25 @@ services:
     {% endif %}
     {% if swarm %}
     deploy:
-      replicas: {{ swarm_replicas | default(1) }}
+      replicas: {{ swarm.replicas | default(1) }}
       {% if traefik %}
       labels:
         - traefik.enable={{ traefik }}
         - traefik.http.services.{{ container_name }}.loadbalancer.server.port=80
         - traefik.http.routers.{{ container_name }}.entrypoints=websecure
-        - traefik.http.routers.{{ container_name }}.rule=Host(`{{ traefik_host }}`)
-        - traefik.http.routers.{{ container_name }}.tls={{ traefik_tls | default(true) }}
-        - traefik.http.routers.{{ container_name }}.tls.certresolver={{ traefik_certresolver }}
+        - traefik.http.routers.{{ container_name }}.rule=Host(`{{ traefik.host }}`)
+        - traefik.http.routers.{{ container_name }}.tls={{ traefik.tls | default(true) }}
+        - traefik.http.routers.{{ container_name }}.tls.certresolver={{ traefik.certresolver }}
         - traefik.http.routers.{{ container_name }}.service={{ container_name }}
       {% endif %}
     {% endif %}
-    {% if not traefik %}
+    {% if not traefik and service_port %}
     ports:
-      - "{{ service_port['http']  }}:80"
+      - "{{ service_port['http'] | default(8080) }}:80"
       - "{{ service_port['https'] | default(8443) }}:443"
+      {% if nginx_dashboard %}
+      - "{{ nginx_dashboard.port['dashboard'] | default(8081) }}:8080"
+      {% endif %}
     {% endif %}
     # volumes:
     #   - ./config/default.conf:/etc/nginx/conf.d/default.conf:ro
@@ -42,17 +45,23 @@ services:
       - traefik.enable={{ traefik  }}
       - traefik.http.services.{{ container_name }}.loadbalancer.server.port=80
       - traefik.http.routers.{{ container_name }}.entrypoints=websecure
-      - traefik.http.routers.{{ container_name }}.rule=Host(`{{ traefik_host }}`)
-      - traefik.http.routers.{{ container_name }}.tls={{ traefik_tls | default(true) }}
-      - traefik.http.routers.{{ container_name }}.tls.certresolver={{ traefik_certresolver }}
+      - traefik.http.routers.{{ container_name }}.rule=Host(`{{ traefik.host }}`)
+      - traefik.http.routers.{{ container_name }}.tls={{ traefik.tls | default(true) }}
+      - traefik.http.routers.{{ container_name }}.tls.certresolver={{ traefik.certresolver }}
       - traefik.http.routers.{{ container_name }}.service={{ container_name }}
     {% endif %}
+    {% if network %}
     networks:
-      - {{ docker_network | default('bridge') }}
+      - {{ network.name | default('bridge') }}
+    {% endif %}
     {% if not swarm %}
     restart: unless-stopped
     {% endif %}
 
+{% if network %}
 networks:
-  {{ docker_network | default('bridge') }}:
+  {{ network.name | default('bridge') }}:
+    {% if network.external | default(true) %}
     external: true
+    {% endif %}
+{% endif %}

+ 0 - 52
library/compose/test-complex/compose.yaml

@@ -1,52 +0,0 @@
----
-name: "Complex Test Template"
-description: "A comprehensive template for testing complex variable prompting logic"
-version: "1.0.0"
-date: "2025-01-01"
-author: "GitHub Copilot"
-tags:
-  - "test"
-  - "complex"
-  - "variables"
----
-services:
-  {{ service_name }}:
-    image: {{ docker_image | default('nginx:latest') }}
-    container_name: {{ container_name | default(service_name) }}
-    restart: {{ restart_policy | default('unless-stopped') }}
-    
-    {% if traefik_http_port %}
-    ports:
-      - "{{ traefik_http_port }}:80"
-      {% if traefik_https_port %}
-      - "{{ traefik_https_port }}:443"
-      {% endif %}
-    {% endif %}
-    
-    {% if replica_count and replica_count > 1 %}
-    deploy:
-      replicas: {{ replica_count }}
-      update_config:
-        parallelism: 1
-        delay: 10s
-      restart_policy:
-        condition: on-failure
-    {% endif %}
-    
-    {% if traefik_entrypoints %}
-    labels:
-      - "traefik.enable=true"
-      {% for entrypoint in traefik_entrypoints %}
-      - "traefik.http.routers.{{ service_name }}.entrypoints={{ entrypoint }}"
-      {% endfor %}
-    {% endif %}
-    
-    environment:
-      - APP_NAME={{ service_name }}
-      {% if container_name %}
-      - CONTAINER_NAME={{ container_name }}
-      {% endif %}
-
-networks:
-  default:
-    name: {{ service_name }}_network

+ 0 - 17
library/compose/test-mixed/compose.yaml

@@ -1,17 +0,0 @@
----
-name: "Test Mixed Defaults"
-description: "Test template with mixed default scenarios"
-version: "0.0.1"
----
-services:
-  {{ service_name }}:
-    container_name: {{ container_name | default('test') }}
-    ports:
-      # http has no default (required)
-      - "{{ service_port['http'] }}:80"  
-      # https has a default (optional)
-      - "{{ service_port['https'] | default(8443) }}:443"
-      # admin has a default (optional)  
-      - "{{ service_port['admin'] | default(9090) }}:9090"
-    networks:
-      - {{ network_name }}

+ 0 - 66
library/compose/test-multivalue/compose.yaml

@@ -1,66 +0,0 @@
----
-name: "Test Multivalue Template"
-description: "Demo template showing multivalue variable usage"
-version: "0.0.1"
-date: "2025-01-07"
-author: "Test"
-tags:
-  - test
-  - multivalue
----
-services:
-  {{ service_name | default('app') }}:
-    container_name: {{ container_name | default('my-app') }}
-    image: nginx:alpine
-    
-    # Example: service_port can be a single value or dict
-    {% if service_port is mapping %}
-    ports:
-      {% for name, port in service_port.items() %}
-      - "{{ port }}:{{ port }}"  # {{ name }} port
-      {% endfor %}
-    {% elif service_port is iterable and service_port is not string %}
-    ports:
-      {% for port in service_port %}
-      - "{{ port }}:{{ port }}"
-      {% endfor %}
-    {% else %}
-    ports:
-      - "{{ service_port }}:80"
-    {% endif %}
-    
-    # Example: docker_network can be single or multiple
-    {% if docker_network is mapping %}
-    networks:
-      {% for name, net in docker_network.items() %}
-      {{ net }}:
-        aliases:
-          - {{ service_name }}-{{ name }}
-      {% endfor %}
-    {% elif docker_network is iterable and docker_network is not string %}
-    networks:
-      {% for net in docker_network %}
-      - {{ net }}
-      {% endfor %}
-    {% else %}
-    networks:
-      - {{ docker_network | default('default') }}
-    {% endif %}
-
-{% if docker_network is mapping %}
-networks:
-  {% for name, net in docker_network.items() %}
-  {{ net }}:
-    external: true
-  {% endfor %}
-{% elif docker_network is iterable and docker_network is not string %}
-networks:
-  {% for net in docker_network %}
-  {{ net }}:
-    external: true
-  {% endfor %}
-{% elif docker_network %}
-networks:
-  {{ docker_network }}:
-    external: true
-{% endif %}

+ 0 - 23
library/compose/tests/compose.yaml

@@ -1,23 +0,0 @@
----
-name: "Test Ubuntu Container"
-description: "A simple test compose file to run an Ubuntu container"
-version: "1.0.0"
-date: "2025-09-03"
-author: "Christian Lempa"
-tags:
-  - ubuntu
-  - test
-  - docker
-variables:
-  container_name: "ubuntu-test"
-  image_tag: "latest"
-  restart_policy: "unless-stopped"
----
-services:
-  {{ service_name }}:
-    image: ubuntu:latest
-    container_name: {{ container_name | default('ubuntu-test') }}
-    restart: {{ restart_policy | default('unless-stopped') }}
-    command: tail -f /dev/null
-    stdin_open: true
-    tty: true