Просмотр исходного кода

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 9 месяцев назад
Родитель
Сommit
c3ca59e687

+ 0 - 49
cli/core/config.py

@@ -3,7 +3,6 @@
 from dataclasses import dataclass, field
 from dataclasses import dataclass, field
 from pathlib import Path
 from pathlib import Path
 from typing import Any, Dict, List, Optional
 from typing import Any, Dict, List, Optional
-import json
 import logging
 import logging
 import yaml
 import yaml
 
 
@@ -215,51 +214,3 @@ def set_config(config):
   """
   """
   global _config
   global _config
   _config = 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 .config import get_config
 from .exceptions import TemplateNotFoundError, TemplateValidationError
 from .exceptions import TemplateNotFoundError, TemplateValidationError
 from .library import LibraryManager
 from .library import LibraryManager
-from .prompt import PromptHandler
+from .prompt import SimplifiedPromptHandler
 from .variables import VariableRegistry
 from .variables import VariableRegistry
 
 
 logger = logging.getLogger('boilerplates')
 logger = logging.getLogger('boilerplates')
@@ -67,11 +67,9 @@ class Module(ABC):
       if value:
       if value:
         console.print(f"{label}: [cyan]{value}[/cyan]")
         console.print(f"{label}: [cyan]{value}[/cyan]")
     
     
-    # Variable groups
+    # Variables
     if template.vars:
     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
     # Content
     if template.content:
     if template.content:
@@ -122,59 +120,62 @@ class Module(ABC):
   
   
   def _validate_template(self, template, template_id: str) -> None:
   def _validate_template(self, template, template_id: str) -> None:
     """Validate template and raise error if validation fails."""
     """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]:
   def _process_variables(self, template) -> Dict[str, Any]:
     """Process template variables with prompting."""
     """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 {}
       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
     # Use rich output if enabled
     if not get_config().use_rich_output:
     if not get_config().use_rich_output:
       # Simple fallback - just prompt for missing values
       # Simple fallback - just prompt for missing values
       values = defaults.copy()
       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
       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):
   def register_cli(self, app: Typer):
     """Register module commands with the main app."""
     """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.console import Console
 from rich.prompt import Prompt, Confirm, IntPrompt, FloatPrompt
 from rich.prompt import Prompt, Confirm, IntPrompt, FloatPrompt
-from rich.table import Table
-from rich import box
+import logging
 
 
 logger = logging.getLogger('boilerplates')
 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:
     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]:
   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:
       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:
       if not enabled:
+        # Skip all group variables
         return
         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:
         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:
           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:
       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:
         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
     # 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})")
       parts.append(f"({description})")
-    
     if current_value is not None:
     if current_value is not None:
       parts.append(f"[dim]({current_value})[/dim]")
       parts.append(f"[dim]({current_value})[/dim]")
     elif required:
     elif required:
       parts.append("[red](Required)[/red]")
       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:
         try:
-          idx = int(choice) - 1
-          if 0 <= idx < len(options):
-            return options[idx]
+          return IntPrompt.ask(prompt_msg, default=default)
         except ValueError:
         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 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 logging
 import re
 import re
 from jinja2 import Environment, BaseLoader, meta, nodes, TemplateSyntaxError
 from jinja2 import Environment, BaseLoader, meta, nodes, TemplateSyntaxError
@@ -7,9 +8,50 @@ import frontmatter
 from .exceptions import TemplateValidationError
 from .exceptions import TemplateValidationError
 
 
 
 
+@dataclass
 class Template:
 class Template:
   """Data class for template information extracted from frontmatter."""
   """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
   @staticmethod
   def _create_jinja_env() -> Environment:
   def _create_jinja_env() -> Environment:
     """Create standardized Jinja2 environment for consistent template processing."""
     """Create standardized Jinja2 environment for consistent template processing."""
@@ -19,46 +61,27 @@ class Template:
       lstrip_blocks=True,         # Strip leading whitespace from block tags  
       lstrip_blocks=True,         # Strip leading whitespace from block tags  
       keep_trailing_newline=False  # Remove trailing newlines
       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
   @classmethod
   def from_file(cls, file_path: Path) -> "Template":
   def from_file(cls, file_path: Path) -> "Template":
     """Create a Template instance from a file path."""
     """Create a Template instance from a file path."""
     try:
     try:
       frontmatter_data, content = cls._parse_frontmatter(file_path)
       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(
       return cls(
         file_path=file_path,
         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
   @staticmethod
   def _parse_frontmatter(file_path: Path) -> Tuple[Dict[str, Any], str]:
   def _parse_frontmatter(file_path: Path) -> Tuple[Dict[str, Any], str]:
@@ -67,18 +90,15 @@ class Template:
       post = frontmatter.load(f)
       post = frontmatter.load(f)
     return post.metadata, post.content
     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:
     Returns:
-        Tuple of (all_variable_names, variable_defaults, variable_usage_patterns)
+        Tuple of (all_variable_names, variable_defaults, dict_access_patterns)
     """
     """
     try:
     try:
       env = self._create_jinja_env()
       env = self._create_jinja_env()
@@ -87,13 +107,32 @@ class Template:
       # Start with variables found by Jinja2's meta utility
       # Start with variables found by Jinja2's meta utility
       all_variables = meta.find_undeclared_variables(ast)
       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
       # Extract default values from | default() filters
       defaults = {}
       defaults = {}
@@ -104,7 +143,7 @@ class Template:
             defaults[node.node.name] = node.args[0].value
             defaults[node.node.name] = node.args[0].value
           # Handle dict access defaults: {{ var['key'] | default(value) }}
           # Handle dict access defaults: {{ var['key'] | default(value) }}
           elif isinstance(node.node, nodes.Getitem):
           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
               var_name = node.node.node.name
               key = node.node.arg.value
               key = node.node.arg.value
               if var_name not in defaults:
               if var_name not in defaults:
@@ -112,81 +151,67 @@ class Template:
               if not isinstance(defaults[var_name], dict):
               if not isinstance(defaults[var_name], dict):
                 defaults[var_name] = {}
                 defaults[var_name] = {}
               defaults[var_name][key] = node.args[0].value
               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:
     except Exception as e:
       logging.getLogger('boilerplates').debug(f"Error parsing template variables: {e}")
       logging.getLogger('boilerplates').debug(f"Error parsing template variables: {e}")
       return set(), {}, {}
       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.
     """Validate template integrity.
     
     
     Args:
     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:
     Returns:
         List of validation error messages. Empty list if valid.
         List of validation error messages. Empty list if valid.
+    
+    Raises:
+        TemplateValidationError: If validation fails (critical errors only).
     """
     """
     errors = []
     errors = []
     
     
-    # Check for Jinja2 syntax errors
+    # Check for Jinja2 syntax errors (critical - should raise immediately)
     try:
     try:
       env = self._create_jinja_env()
       env = self._create_jinja_env()
       env.from_string(self.content)
       env.from_string(self.content)
     except TemplateSyntaxError as e:
     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:
     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
     # Check for missing required frontmatter fields
     if not self.name or self.name == self.file_path.parent.name:
     if not self.name or self.name == self.file_path.parent.name:
@@ -200,38 +225,6 @@ class Template:
       errors.append("Template has no content")
       errors.append("Template has no content")
     
     
     return errors
     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:
   def render(self, variable_values: Dict[str, Any]) -> str:
     """Render the template with the provided variable values."""
     """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 dataclasses import dataclass, field
 from collections import OrderedDict
 from collections import OrderedDict
 
 
 
 
 @dataclass
 @dataclass
 class Variable:
 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
   name: str
   description: str = ""
   description: str = ""
   default: Any = None
   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]:
   def to_prompt_config(self) -> Dict[str, Any]:
     """Convert to prompt configuration."""
     """Convert to prompt configuration."""
@@ -21,69 +29,61 @@ class Variable:
       'name': self.name,
       'name': self.name,
       'description': self.description, 
       'description': self.description, 
       'type': self.type,
       'type': self.type,
-      'options': self.options,
-      'required': self.required,
-      'default': self.default,
-      'multivalue': self.multivalue
+      'default': self.default
     }
     }
 
 
 
 
 class VariableRegistry:
 class VariableRegistry:
-  """Variable management for modules."""
+  """Simplified variable registry with automatic grouping via dotted notation."""
   
   
   def __init__(self):
   def __init__(self):
     self.variables: Dict[str, Variable] = OrderedDict()
     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."""
     """Register a variable."""
     self.variables[var.name] = var
     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.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):
 class AnsibleModule(Module):
   """Module for managing Ansible playbooks and configurations."""
   """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"]
   files = ["docker-compose.yml", "compose.yml", "compose.yaml"]
   
   
   def _init_variables(self):
   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",
       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",
       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",
       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",
       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",
       name="traefik",
-      description="Enable Traefik",
+      description="Enable Traefik reverse proxy",
       type="boolean",
       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
 # Register the module

+ 7 - 11
cli/modules/docker.py

@@ -1,16 +1,12 @@
 from ..core.module import Module
 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):
 class DockerModule(Module):
   """Module for managing Docker configurations and files."""
   """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.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):
 class GitHubActionsModule(Module):
   """Module for managing GitHub Actions workflows."""
   """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.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):
 class GitLabCIModule(Module):
   """Module for managing GitLab CI/CD pipelines."""
   """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.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):
 class KestraModule(Module):
   """Module for managing Kestra workflows and configurations."""
   """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.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):
 class KubernetesModule(Module):
   """Module for managing Kubernetes manifests and configurations."""
   """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.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):
 class PackerModule(Module):
   """Module for managing Packer templates and configurations."""
   """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.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):
 class VagrantModule(Module):
   """Module for managing Vagrant configurations and files."""
   """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 %}
     {% endif %}
     {% if swarm %}
     {% if swarm %}
     deploy:
     deploy:
-      replicas: {{ swarm_replicas | default(1) }}
+      replicas: {{ swarm.replicas | default(1) }}
       {% if traefik %}
       {% if traefik %}
       labels:
       labels:
         - traefik.enable={{ traefik }}
         - traefik.enable={{ traefik }}
         - traefik.http.services.{{ container_name }}.loadbalancer.server.port=80
         - traefik.http.services.{{ container_name }}.loadbalancer.server.port=80
         - traefik.http.routers.{{ container_name }}.entrypoints=websecure
         - 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 }}
         - traefik.http.routers.{{ container_name }}.service={{ container_name }}
       {% endif %}
       {% endif %}
     {% endif %}
     {% endif %}
-    {% if not traefik %}
+    {% if not traefik and service_port %}
     ports:
     ports:
-      - "{{ service_port['http']  }}:80"
+      - "{{ service_port['http'] | default(8080) }}:80"
       - "{{ service_port['https'] | default(8443) }}:443"
       - "{{ service_port['https'] | default(8443) }}:443"
+      {% if nginx_dashboard %}
+      - "{{ nginx_dashboard.port['dashboard'] | default(8081) }}:8080"
+      {% endif %}
     {% endif %}
     {% endif %}
     # volumes:
     # volumes:
     #   - ./config/default.conf:/etc/nginx/conf.d/default.conf:ro
     #   - ./config/default.conf:/etc/nginx/conf.d/default.conf:ro
@@ -42,17 +45,23 @@ services:
       - traefik.enable={{ traefik  }}
       - traefik.enable={{ traefik  }}
       - traefik.http.services.{{ container_name }}.loadbalancer.server.port=80
       - traefik.http.services.{{ container_name }}.loadbalancer.server.port=80
       - traefik.http.routers.{{ container_name }}.entrypoints=websecure
       - 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 }}
       - traefik.http.routers.{{ container_name }}.service={{ container_name }}
     {% endif %}
     {% endif %}
+    {% if network %}
     networks:
     networks:
-      - {{ docker_network | default('bridge') }}
+      - {{ network.name | default('bridge') }}
+    {% endif %}
     {% if not swarm %}
     {% if not swarm %}
     restart: unless-stopped
     restart: unless-stopped
     {% endif %}
     {% endif %}
 
 
+{% if network %}
 networks:
 networks:
-  {{ docker_network | default('bridge') }}:
+  {{ network.name | default('bridge') }}:
+    {% if network.external | default(true) %}
     external: 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