Explorar o código

new functions

xcad hai 9 meses
pai
achega
ae0b98fdbe

+ 74 - 0
cli/core/args.py

@@ -0,0 +1,74 @@
+from typing import Dict, List
+import logging
+
+logger = logging.getLogger(__name__)
+
+# NOTE: This helper supports both syntaxes:
+#   --var KEY=VALUE
+#   --var KEY VALUE
+# It also tolerates passing values via ctx.args when using allow_extra_args.
+
+def parse_var_inputs(var_items: List[str], extra_args: List[str]) -> Dict[str, str]:
+  overrides: Dict[str, str] = {}
+
+  # First, parse items collected by Typer's --var Option (usually KEY=VALUE forms)
+  for item in var_items:
+    if item is None:
+      continue
+    if "=" in item:
+      key, value = item.split("=", 1)
+      if key:
+        overrides[key] = value
+    else:
+      # If user provided just a key via --var KEY, try to find the next value in extra args
+      key = item
+      value = _pop_next_value(extra_args)
+      overrides[key] = value if value is not None else ""
+
+  # Next, scan extra_args for any leftover --var occurrences using space-separated form
+  i = 0
+  while i < len(extra_args):
+    tok = extra_args[i]
+    if tok in ("--var", "-v"):
+      name = None
+      value = None
+      # name may be next token; it can also be name=value
+      if i + 1 < len(extra_args):
+        nxt = extra_args[i + 1]
+        if "=" in nxt:
+          name, value = nxt.split("=", 1)
+          i += 1
+        else:
+          name = nxt
+          if i + 2 < len(extra_args):
+            valtok = extra_args[i + 2]
+            if not valtok.startswith("-"):
+              value = valtok
+              i += 2
+            else:
+              i += 1
+          else:
+            i += 1
+      if name:
+        overrides[name] = value if value is not None else ""
+    elif tok.startswith("--var=") or tok.startswith("-v="):
+      remainder = tok.split("=", 1)[1]
+      if "=" in remainder:
+        name, value = remainder.split("=", 1)
+      else:
+        name, value = remainder, _pop_next_value(extra_args[i + 1:])
+      if name:
+        overrides[name] = value if value is not None else ""
+    i += 1
+
+  return overrides
+
+
+def _pop_next_value(args: List[str]) -> str | None:
+  """Return the first non-flag token from args, if any, without modifying caller's list.
+  This is a best-effort for --var KEY VALUE when Typer didn't bind VALUE to --var.
+  """
+  for tok in args:
+    if not tok.startswith("-"):
+      return tok
+  return None

+ 238 - 19
cli/core/library.py

@@ -1,6 +1,6 @@
 from pathlib import Path
 from pathlib import Path
-import subprocess
 import logging
 import logging
+from .template import Template
 
 
 logger = logging.getLogger(__name__)
 logger = logging.getLogger(__name__)
 
 
@@ -14,33 +14,252 @@ class Library:
     self.priority = priority  # Higher priority = checked first
     self.priority = priority  # Higher priority = checked first
 
 
   def find_by_id(self, module_name, files, template_id):
   def find_by_id(self, module_name, files, template_id):
-    """Find a template by its ID in this library."""
-    pass
+    """Find a template by its ID in this library.
+    
+    Args:
+        module_name: The module name (e.g., 'compose', 'terraform')
+        files: List of files to look for in the template directory
+        template_id: The template ID to find
+    
+    Returns:
+        Path to the template directory if found
+        
+    Raises:
+        FileNotFoundError: If the template ID is not found in this library
+    """
+    logger.debug(f"Looking for template '{template_id}' in module '{module_name}' in library '{self.name}'")
+    
+    # Build the path to the specific template directory
+    template_path = self.path / module_name / template_id
+    
+    # Check if the template directory exists
+    if not template_path.exists():
+      raise FileNotFoundError(f"Template '{template_id}' not found in module '{module_name}' in library '{self.name}'")
+    
+    if not template_path.is_dir():
+      raise FileNotFoundError(f"Template '{template_id}' exists but is not a directory in module '{module_name}' in library '{self.name}'")
+    
+    # If files list is provided, verify at least one of the files exists
+    if files:
+      has_any_file = False
+      for file in files:
+        file_path = template_path / file
+        if file_path.exists():
+          has_any_file = True
+          break
+      
+      if not has_any_file:
+        raise FileNotFoundError(f"Template '{template_id}' found but missing any of the required files: {files}")
+    
+    logger.debug(f"Found template '{template_id}' at: {template_path}")
+    return template_path
+
+
+  def find(self, module_name, files, sort_results=False):
+    """Find templates in this library for a specific module.
+    
+    Args:
+        module_name: The module name (e.g., 'compose', 'terraform')
+        files: List of files to look for in template directories (optional filter)
+        sort_results: Whether to return results sorted alphabetically
+    
+    Returns:
+        List of Path objects representing template directories
+        
+    Raises:
+        FileNotFoundError: If the module directory is not found in this library
+    """
+    logger.debug(f"Looking for templates in module '{module_name}' in library '{self.name}'")
+    
+    # Build the path to the module directory
+    module_path = self.path / module_name
+    
+    # Check if the module directory exists
+    if not module_path.exists():
+      raise FileNotFoundError(f"Module '{module_name}' not found in library '{self.name}'")
+    
+    if not module_path.is_dir():
+      raise FileNotFoundError(f"Module '{module_name}' exists but is not a directory in library '{self.name}'")
+    
+    # Get all directories in the module path
+    template_dirs = []
+    try:
+      for item in module_path.iterdir():
+        if item.is_dir():
+          # If files list is provided, check if template has any of the required files
+          if files:
+            has_any_file = False
+            for file in files:
+              file_path = item / file
+              if file_path.exists():
+                has_any_file = True
+                break
+            
+            if has_any_file:
+              template_dirs.append(item)
+          else:
+            # No file requirements, include all directories
+            template_dirs.append(item)
+    except PermissionError as e:
+      raise FileNotFoundError(f"Permission denied accessing module '{module_name}' in library '{self.name}': {e}")
+    
+    # Sort if requested
+    if sort_results:
+      template_dirs.sort(key=lambda x: x.name.lower())
+    
+    logger.debug(f"Found {len(template_dirs)} templates in module '{module_name}'")
+    return template_dirs
 
 
-  def find(self, module_name, files, sorted=False):
-    """Find templates in this library for a specific module."""
-    pass
 
 
 class LibraryManager:
 class LibraryManager:
   """Manages multiple libraries and provides methods to find templates."""
   """Manages multiple libraries and provides methods to find templates."""
   
   
   # FIXME: For now this is static and only has one library
   # FIXME: For now this is static and only has one library
   def __init__(self):
   def __init__(self):
+
+    # get the root path of the repository
+    repo_root = Path(__file__).parent.parent.parent.resolve()
+
     self.libraries = [
     self.libraries = [
-      Library(name="default", path=Path(__file__).parent.parent / "libraries", priority=0)
+      Library(name="default", path=repo_root / "library", priority=0)
     ]
     ]
 
 
   def find_by_id(self, module_name, files, template_id):
   def find_by_id(self, module_name, files, template_id):
-    """Find a template by its ID across all libraries."""
-    for library in self.libraries:
-      template = library.find_by_id(module_name, files, template_id)
-      if template:
-        return template
+    """Find a template by its ID across all libraries.
+    
+    Args:
+        module_name: The module name (e.g., 'compose', 'terraform')
+        files: List of files to look for in the template directory
+        template_id: The template ID to find
+    
+    Returns:
+        Path to the template directory if found, None otherwise
+    """
+    logger.debug(f"Searching for template '{template_id}' in module '{module_name}' across all libraries")
+    
+    for library in sorted(self.libraries, key=lambda x: x.priority, reverse=True):
+      try:
+        template_path = library.find_by_id(module_name, files, template_id)
+        logger.debug(f"Found template '{template_id}' in library '{library.name}'")
+        return template_path
+      except FileNotFoundError:
+        # Continue searching in next library
+        continue
+    
+    logger.debug(f"Template '{template_id}' not found in any library")
+    return None
+  
+  def find(self, module_name, files, sort_results=False):
+    """Find templates across all libraries for a specific module.
+    
+    Args:
+        module_name: The module name (e.g., 'compose', 'terraform')
+        files: List of files to look for in template directories (optional filter)
+        sort_results: Whether to return results sorted alphabetically
+    
+    Returns:
+        List of Path objects representing template directories from all libraries
+    """
+    logger.debug(f"Searching for templates in module '{module_name}' across all libraries")
+    
+    all_templates = []
+    
+    for library in sorted(self.libraries, key=lambda x: x.priority, reverse=True):
+      try:
+        templates = library.find(module_name, files, sort_results=False)  # Sort at the end
+        all_templates.extend(templates)
+        logger.debug(f"Found {len(templates)} templates in library '{library.name}'")
+      except FileNotFoundError:
+        # Module not found in this library, continue with next
+        logger.debug(f"Module '{module_name}' not found in library '{library.name}'")
+        continue
+    
+    # Remove duplicates based on template name (directory name)
+    seen_names = set()
+    unique_templates = []
+    for template in all_templates:
+      if template.name not in seen_names:
+        unique_templates.append(template)
+        seen_names.add(template.name)
+    
+    # Sort if requested
+    if sort_results:
+      unique_templates.sort(key=lambda x: x.name.lower())
+    
+    logger.debug(f"Found {len(unique_templates)} unique templates total")
+    return unique_templates
+  
+  def load_template_by_id(self, module_name, files, template_id, module_variables=None):
+    """Load a template by its ID as a Template object.
+    
+    Args:
+        module_name: The module name (e.g., 'compose', 'terraform')
+        files: List of files to look for in the template directory
+        template_id: The template ID to find
+        module_variables: Optional dict of module-specific variables to merge
+    
+    Returns:
+        Template object if found, None otherwise
+    """
+    template_path = self.find_by_id(module_name, files, template_id)
+    if not template_path:
+      return None
+    
+    # Find the actual template file in the directory
+    template_file = None
+    for file_name in files:
+      candidate_file = template_path / file_name
+      if candidate_file.exists():
+        template_file = candidate_file
+        break
+    
+    if not template_file:
+      logger.error(f"Template '{template_id}' directory found but no template file exists")
+      return None
+    
+    # Load the template from file
+    try:
+      if module_variables:
+        logger.debug(f"Passing {len(module_variables)} module variables to template: {list(module_variables.keys())}")
+      else:
+        logger.debug("No module variables provided")
+      template = Template.from_file(template_file, module_variables=module_variables)
+      return template
+    except Exception as e:
+      logger.error(f"Error loading template '{template_id}': {e}")
+      return None
   
   
-  def find(self, module_name, files, sorted=False):
-    """Find templates across all libraries for a specific module."""
-    for library in self.libraries:
-      templates = library.find(module_name, files, sorted=sorted)
-      if templates:
-        return templates
-    return []
+  def load_templates(self, module_name, files, sort_results=False, module_variables=None):
+    """Load all templates for a module as Template objects.
+    
+    Args:
+        module_name: The module name (e.g., 'compose', 'terraform')
+        files: List of files to look for in template directories
+        sort_results: Whether to return results sorted alphabetically
+        module_variables: Optional dict of module-specific variables to merge
+    
+    Returns:
+        List of Template objects
+    """
+    template_paths = self.find(module_name, files, sort_results)
+    
+    templates = []
+    for template_path in template_paths:
+      try:
+        # Find the actual template file in the directory
+        template_file = None
+        for file_name in files:
+          candidate_file = template_path / file_name
+          if candidate_file.exists():
+            template_file = candidate_file
+            break
+        
+        if template_file:
+          template = Template.from_file(template_file, module_variables=module_variables)
+          templates.append(template)
+        else:
+          logger.warning(f"Template directory '{template_path.name}' found but no template file exists")
+      except Exception as e:
+        logger.warning(f"Error loading template from {template_path}: {e}")
+    
+    return templates

+ 101 - 18
cli/core/module.py

@@ -1,11 +1,14 @@
 from abc import ABC
 from abc import ABC
 from pathlib import Path
 from pathlib import Path
-from typing import Optional, Dict, Any
+from typing import Optional, Dict, Any, List
 import logging
 import logging
-from typer import Typer, Option, Argument
+from typer import Typer, Option, Argument, Context
 from rich.console import Console
 from rich.console import Console
 
 
 from .library import LibraryManager
 from .library import LibraryManager
+from .template import Template
+from .prompt import PromptHandler
+from .args import parse_var_inputs
 
 
 logger = logging.getLogger(__name__)
 logger = logging.getLogger(__name__)
 console = Console()
 console = Console()
@@ -54,24 +57,31 @@ class Module(ABC):
   def list(self):
   def list(self):
     """List all templates."""
     """List all templates."""
     logger.debug(f"Listing templates for module '{self.name}'")
     logger.debug(f"Listing templates for module '{self.name}'")
-    templates = self.libraries.find(self.name, self.files, sorted=True)
+    templates = self.libraries.load_templates(
+      self.name, 
+      self.files, 
+      sort_results=True,
+      module_variables=getattr(self, 'variables_spec', {})
+    )
     
     
     if templates:
     if templates:
       logger.info(f"Listing {len(templates)} templates for module '{self.name}'")
       logger.info(f"Listing {len(templates)} templates for module '{self.name}'")
+      for template in templates:
+        console.print(f"[cyan]{template.id}[/cyan] - {template.name}")
     else:
     else:
       logger.info(f"No templates found for module '{self.name}'")
       logger.info(f"No templates found for module '{self.name}'")
     
     
-    # Display templates without enrichment (enrichment only needed for generation)
-    for template in templates:
-      console.print(f"[cyan]{template.id}[/cyan] - {template.name}")
-    
     return templates
     return templates
 
 
   def show(self, id: str = Argument(..., help="Template ID")):
   def show(self, id: str = Argument(..., help="Template ID")):
     """Show template details."""
     """Show template details."""
     logger.debug(f"Showing template '{id}' from module '{self.name}'")
     logger.debug(f"Showing template '{id}' from module '{self.name}'")
-    # Get template directly from library without enrichment (not needed for display)
-    template = self.libraries.find_by_id(self.name, self.files, id)
+    template = self.libraries.load_template_by_id(
+      self.name, 
+      self.files, 
+      id,
+      module_variables=getattr(self, 'variables_spec', {})
+    )
     
     
     if not template:
     if not template:
       logger.debug(f"Template '{id}' not found in module '{self.name}'")
       logger.debug(f"Template '{id}' not found in module '{self.name}'")
@@ -93,9 +103,9 @@ class Module(ABC):
       if value:
       if value:
         console.print(f"{label}: [cyan]{value}[/cyan]")
         console.print(f"{label}: [cyan]{value}[/cyan]")
     
     
-    # Variables (show raw template variables without module enrichment)
-    if template.vars:
-      console.print(f"Variables: [cyan]{', '.join(sorted(template.vars))}[/cyan]")
+    # Variables (show template variables)
+    if template.variables:
+      console.print(f"Variables: [cyan]{', '.join(template.variables.get_variable_names())}[/cyan]")
     
     
     # Content
     # Content
     if template.content:
     if template.content:
@@ -105,19 +115,91 @@ class Module(ABC):
   def generate(
   def generate(
     self,
     self,
     id: str = Argument(..., help="Template ID"),
     id: str = Argument(..., help="Template ID"),
-    out: Optional[Path] = Option(None, "--out", "-o")
+    out: Optional[Path] = Option(None, "--out", "-o"),
+    interactive: bool = Option(True, "--interactive/--no-interactive", "-i/-n", help="Enable interactive prompting for variables"),
+    var: Optional[List[str]] = Option(None, "--var", "-v", help="Variable override (repeatable). Use KEY=VALUE or --var KEY VALUE"),
+    ctx: Context = None,
   ):
   ):
-    """Generate from template."""
+    """Generate from template.
+
+    Supports variable overrides via:
+      --var KEY=VALUE
+      --var KEY VALUE
+    """
 
 
     logger.info(f"Starting generation for template '{id}' from module '{self.name}'")
     logger.info(f"Starting generation for template '{id}' from module '{self.name}'")
-    # Fetch template from library
-    template = self.libraries.find_by_id(self.name, self.files, id)
+    template = self.libraries.load_template_by_id(
+      self.name, 
+      self.files, 
+      id,
+      module_variables=getattr(self, 'variables_spec', {})
+    )
     
     
     if not template:
     if not template:
       logger.error(f"Template '{id}' not found for generation in module '{self.name}'")
       logger.error(f"Template '{id}' not found for generation in module '{self.name}'")
       raise FileNotFoundError(f"Template '{id}' not found in module '{self.name}'")
       raise FileNotFoundError(f"Template '{id}' not found in module '{self.name}'")
 
 
-    # PLACEHOLDER FOR TEMPLATE GENERATION LOGIC
+    # Build variable overrides from Typer-collected options and any extra args
+    extra_args = []
+    try:
+      if ctx is not None and hasattr(ctx, "args"):
+        extra_args = list(ctx.args)
+    except Exception:
+      extra_args = []
+
+    cli_overrides = parse_var_inputs(var or [], extra_args)
+    if cli_overrides:
+      logger.info(f"Received {len(cli_overrides)} variable overrides from CLI")
+
+    # Collect variable values interactively if enabled
+    variable_values = {}
+    if interactive and template.variables:
+      prompt_handler = PromptHandler()
+      
+      # Collect values with sectioned flow
+      collected_values = prompt_handler.collect_variables(
+        variables=template.variables,
+        template_name=template.name,
+        module_name=self.name,
+        template_var_order=template.template_var_names,
+        module_var_order=template.module_var_names,
+      )
+      
+      if collected_values:
+        variable_values.update(collected_values)
+        logger.info(f"Collected {len(collected_values)} variable values from user input")
+        
+        # Display summary of collected values
+        prompt_handler.display_variable_summary(collected_values, template.name)
+
+    # Apply CLI overrides last to take highest precedence
+    if cli_overrides:
+      variable_values.update(cli_overrides)
+
+    # Render template with collected values
+    try:
+      rendered_content = template.render(variable_values)
+      logger.info(f"Successfully rendered template '{id}'")
+      
+      # Output handling
+      if out:
+        # Write to specified file
+        out.parent.mkdir(parents=True, exist_ok=True)
+        with open(out, 'w', encoding='utf-8') as f:
+          f.write(rendered_content)
+        console.print(f"[green]Generated template to: {out}[/green]")
+        logger.info(f"Template written to file: {out}")
+      else:
+        # Output to stdout
+        console.print("[bold blue]Generated Template:[/bold blue]")
+        console.print("─" * 50)
+        console.print(rendered_content)
+        logger.info("Template output to stdout")
+        
+    except Exception as e:
+      logger.error(f"Error rendering template '{id}': {str(e)}")
+      console.print(f"[red]Error generating template: {str(e)}[/red]")
+      raise
 
 
   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."""
@@ -125,6 +207,7 @@ class Module(ABC):
     module_app = Typer()
     module_app = Typer()
     module_app.command()(self.list)
     module_app.command()(self.list)
     module_app.command()(self.show)
     module_app.command()(self.show)
-    module_app.command()(self.generate)
+    # Allow extra args so we can parse --var overrides ourselves
+    module_app.command(context_settings={"allow_extra_args": True, "ignore_unknown_options": True})(self.generate)
     app.add_typer(module_app, name=self.name, help=self.description)
     app.add_typer(module_app, name=self.name, help=self.description)
     logger.info(f"Module '{self.name}' CLI commands registered")
     logger.info(f"Module '{self.name}' CLI commands registered")

+ 291 - 0
cli/core/prompt.py

@@ -0,0 +1,291 @@
+from typing import Dict, Any, List
+import logging
+from rich.console import Console
+from rich.prompt import Prompt, Confirm, IntPrompt
+from rich.table import Table
+from rich.panel import Panel
+from rich.text import Text
+
+from .variables import Variable, VariableCollection
+
+logger = logging.getLogger(__name__)
+console = Console()
+
+
+class PromptHandler:
+  """Interactive prompt handler for collecting template variables.
+
+  Simplified design:
+  - Single entrypoint: collect_variables(VariableCollection)
+  - Asks only for variables that don't have values
+  - Clear, compact output with a summary table
+  """
+
+  def __init__(self):
+    self.console = Console()
+
+  def collect_variables(
+    self,
+    variables: VariableCollection,
+    template_name: str = "",
+    module_name: str = "",
+    template_var_order: List[str] = None,
+    module_var_order: List[str] = None,
+  ) -> Dict[str, Any]:
+    """Collect values for variables that need input with an ordered, sectioned flow.
+
+    Sections (in order):
+    1) General (always)
+    2) <Template Name> Specific (always) - variables defined in frontmatter
+    3) Each *_enabled section (ask to enable -> then prompt variables)
+    """
+    template_var_order = template_var_order or []
+    module_var_order = module_var_order or []
+
+    # Build lookup maps for easy access and ordering
+    vars_map = variables.variables
+
+    # Partition variables
+    toggles: Dict[str, Variable] = {}
+    section_vars: Dict[str, List[Variable]] = {}
+    general_vars: List[Variable] = []
+    template_specific_vars: List[Variable] = []
+
+    # Determine which names are template-specific by provided order
+    template_specific_names = set(template_var_order)
+
+    for name, var in vars_map.items():
+      # Classify template-specific first
+      if name in template_specific_names:
+        template_specific_vars.append(var)
+        continue
+
+      # Identify section toggles by *_enabled convention
+      if name.endswith("_enabled") and var.type == "bool":
+        section = name[: -len("_enabled")]
+        toggles[section] = var
+        section_vars.setdefault(section, [])
+        continue
+
+      # If it begins with a section prefix, associate with that section
+      prefix = name.split("_", 1)[0]
+      if prefix in toggles:
+        section_vars.setdefault(prefix, []).append(var)
+      else:
+        general_vars.append(var)
+
+    # Helper: compute which need values
+    def needs_value(v: Variable) -> bool:
+      return v.value is None or v.value == ""
+
+    # Order preservation based on source order lists
+    def order_by(names: List[str], items: List[Variable]) -> List[Variable]:
+      order_index = {n: i for i, n in enumerate(names)}
+      return sorted(items, key=lambda v: order_index.get(v.name, 1_000_000))
+
+    general_needing = [v for v in general_vars if needs_value(v)]
+    template_needing = [v for v in template_specific_vars if needs_value(v)]
+    sections_needing = {s: [v for v in section_vars.get(s, []) if needs_value(v)] for s in section_vars.keys()}
+
+    # Count for header
+    total_needed = len(general_needing) + len(template_needing) + sum(len(lst) for lst in sections_needing.values())
+
+    collected: Dict[str, Any] = {}
+
+    # General (always)
+    general_needing = order_by(module_var_order, general_needing)
+    if general_needing:
+      self.console.print("[bold magenta]General Configuration[/bold magenta]")
+      self.console.print("─" * 50, style="dim")
+      # Required first (no default)
+      for var in [v for v in general_needing if needs_value(v)]:
+        collected[var.name] = self._prompt_required(var)
+      # Show current values (non-empty), ask if user wants to change
+      current = [v for v in general_vars if not needs_value(v)]
+      self._maybe_reconfigure(current, collected)
+      self.console.print()
+
+    # Template-specific (always)
+    template_needing = order_by(template_var_order, template_needing)
+    if template_specific_vars:
+      self.console.print(f"[bold magenta]{template_name} Specific[/bold magenta]")
+      self.console.print("─" * 50, style="dim")
+      # Warning on overrides
+      for v in template_specific_vars:
+        if v.name in module_var_order:
+          self.console.print(f"[yellow]Warning:[/yellow] Template Specific variable '{v.name}' is also defined by {module_name}; template value takes precedence.")
+      # Required first
+      for var in [v for v in template_needing if needs_value(v)]:
+        collected[var.name] = self._prompt_required(var)
+      # Reconfigure current values
+      current = [v for v in template_specific_vars if not needs_value(v)]
+      self._maybe_reconfigure(current, collected)
+      self.console.print()
+
+    # Toggle sections in declaration order
+    for section in toggles.keys():
+      toggle_var = toggles[section]
+      # Ask to enable (general/template are always-on)
+      enabled = self._prompt_bool(f"Enable {section.replace('_', ' ').title()}?", toggle_var.get_typed_value())
+      collected[toggle_var.name] = enabled
+      if not enabled:
+        continue
+
+      # Required first
+      needing = order_by(module_var_order, sections_needing.get(section, []))
+      if needing or section_vars.get(section):
+        self.console.print(f"[bold magenta]{section.replace('_', ' ').title()} Configuration[/bold magenta]")
+        self.console.print("─" * 50, style="dim")
+        for var in [v for v in needing if needs_value(v)]:
+          collected[var.name] = self._prompt_required(var)
+        # Reconfigure
+        current = [v for v in section_vars.get(section, []) if not needs_value(v)]
+        self._maybe_reconfigure(current, collected)
+        self.console.print()
+
+    logger.info(f"Variable collection completed. Collected {len(collected)} values")
+    return collected
+
+  def _prompt_required(self, variable: Variable) -> Any:
+    """Prompt for a required variable; empty answers are not allowed."""
+    while True:
+      val = self._prompt_variable(variable)
+      if val is None or (isinstance(val, str) and val.strip() == ""):
+        self.console.print("[red]This field is required. Please enter a value.[/red]")
+        continue
+      return val
+
+  def _maybe_reconfigure(self, variables: List[Variable], collected: Dict[str, Any]):
+    """Show current values inline and ask if user wants to change them; if yes, prompt with defaults."""
+    vars_with_values = [(v.name, v.get_typed_value()) for v in variables]
+    if not vars_with_values:
+      return
+
+    # Build concise single-line presentation: Current Values: var=value, var2=value
+    line = Text()
+    line.append("Current Values: ", style="white")
+    for idx, (name, value) in enumerate(vars_with_values):
+      if idx > 0:
+        line.append(", ", style="white")
+      line.append(name, style="cyan")
+      line.append("=", style="white")
+      display = str(value) if value is not None else ""
+      line.append(display, style="green")
+    self.console.print(line)
+
+    if Confirm.ask("Change any of these values?", default=False):
+      for v in variables:
+        default_before = v.value
+        new_val = self._prompt_variable(v)
+        # If user pressed enter with empty string for str type, keep previous
+        if new_val == "" and isinstance(default_before, str):
+          continue
+        collected[v.name] = new_val
+
+
+  def _prompt_variable(self, variable: Variable) -> Any:
+    """Prompt for a single variable value based on its type."""
+    logger.debug(f"Prompting for variable '{variable.name}' (type: {variable.type})")
+
+    prompt_text = variable.prompt or variable.description or variable.name
+
+    # Friendly hint for common semantic types
+    if variable.type in ["hostname", "email", "url"]:
+      prompt_text += f" ({variable.type})"
+
+    # Show default value if available
+    default_value = variable.value
+
+    try:
+      if variable.type == "bool":
+        return self._prompt_bool(prompt_text, default_value)
+      if variable.type == "int":
+        return self._prompt_int(prompt_text, default_value)
+      if variable.type == "enum":
+        return self._prompt_enum(prompt_text, variable.options or [], default_value)
+      return self._prompt_string(prompt_text, default_value)
+    except Exception as e:
+      logger.error(f"Error prompting for variable '{variable.name}': {str(e)}")
+      return self._prompt_string(prompt_text, default_value)
+
+  def _prompt_string(self, prompt_text: str, default: Any = None) -> str:
+    value = Prompt.ask(
+      prompt_text,
+      default=str(default) if default is not None else "",
+      show_default=False,
+    )
+    return value.strip() if value else ""
+
+  def _prompt_bool(self, prompt_text: str, default: Any = None) -> bool:
+    default_bool = None
+    if default is not None:
+      default_bool = default if isinstance(default, bool) else str(default).lower() in ("true", "1", "yes", "on")
+    return Confirm.ask(prompt_text, default=default_bool)
+
+  def _prompt_int(self, prompt_text: str, default: Any = None) -> int:
+    default_int = None
+    if default is not None:
+      try:
+        default_int = int(default)
+      except (ValueError, TypeError):
+        logger.warning(f"Invalid default integer value: {default}")
+    return IntPrompt.ask(prompt_text, default=default_int)
+
+  def _prompt_enum(self, prompt_text: str, options: List[str], default: Any = None) -> str:
+    if not options:
+      logger.warning("Enum variable has no options, falling back to string prompt")
+      return self._prompt_string(prompt_text, default)
+
+    self.console.print(f"  Options: {', '.join(options)}", style="dim")
+
+    if default and default not in options:
+      logger.warning(f"Default value '{default}' not in options {options}")
+      default = None
+
+    while True:
+      value = Prompt.ask(
+        prompt_text,
+        default=str(default) if default else options[0],
+        show_default=False,
+      )
+      if value in options:
+        return value
+      self.console.print(f"  [red]Invalid choice. Please select from: {', '.join(options)}[/red]")
+
+  def display_variable_summary(self, collected_values: Dict[str, Any], template_name: str = ""):
+    """Display a summary of collected variable values."""
+    if not collected_values:
+      return
+
+    title = "Variable Summary"
+    if template_name:
+      title += f" - {template_name}"
+
+    table = Table(title=title, show_header=True, header_style="bold blue")
+    table.add_column("Variable", style="cyan", min_width=20)
+    table.add_column("Value", style="green")
+    table.add_column("Type", style="dim", justify="center")
+
+    for var_name in sorted(collected_values.keys()):
+      value = collected_values[var_name]
+      if isinstance(value, bool):
+        display_value = "true" if value else "false"  # No emojis per logging rules
+        var_type = "bool"
+      elif isinstance(value, int):
+        display_value = str(value)
+        var_type = "int"
+      else:
+        display_value = str(value) if value else ""
+        var_type = "str"
+
+      if len(display_value) > 50:
+        display_value = display_value[:47] + "..."
+
+      table.add_row(var_name, display_value, var_type)
+
+    self.console.print()
+    self.console.print(table)
+    self.console.print()
+
+# TODO: Add validation hooks (URL, email, hostname) if needed
+# NOTE: Keep prompts single-line, clean, and with proper log levels per rules

+ 215 - 80
cli/core/template.py

@@ -1,3 +1,4 @@
+from .variables import Variable, VariableCollection
 from pathlib import Path
 from pathlib import Path
 from typing import Any, Dict, List, Set, Tuple, Optional
 from typing import Any, Dict, List, Set, Tuple, Optional
 from dataclasses import dataclass, field
 from dataclasses import dataclass, field
@@ -11,13 +12,13 @@ logger = logging.getLogger(__name__)
 
 
 @dataclass
 @dataclass
 class Template:
 class Template:
-  """Data class for template information extracted from frontmatter."""
-  
+  """Represents a template file with frontmatter and content."""
+
   # Required fields
   # Required fields
   file_path: Path
   file_path: Path
   content: str = ""
   content: str = ""
-  
-  # Frontmatter fields with defaults
+
+  # Frontmatter metadata
   id: str = ""
   id: str = ""
   name: str = ""
   name: str = ""
   description: str = "No description available"
   description: str = "No description available"
@@ -27,104 +28,238 @@ class Template:
   module: str = ""
   module: str = ""
   tags: List[str] = field(default_factory=list)
   tags: List[str] = field(default_factory=list)
   files: List[str] = field(default_factory=list)
   files: List[str] = field(default_factory=list)
-  
-  # Template variable analysis results
-  vars: Dict[str, Any] = field(default_factory=dict, init=False)
 
 
+  # Extracted/merged variables
+  variables: VariableCollection = field(default_factory=VariableCollection, init=False)
+  # Source tracking for prompting and ordering
+  template_var_names: List[str] = field(default_factory=list, init=False)
+  module_var_names: List[str] = field(default_factory=list, init=False)
+
+  def render(self, variable_values: Optional[Dict[str, Any]] = None) -> str:
+    """Render the template with given variable overrides."""
+    if variable_values:
+      for name, value in variable_values.items():
+        var = self.variables.get_variable(name)
+        if var:
+          var.value = value
+
+    env = self._create_jinja_env()
+    context = self.variables.to_jinja_context()
+    template = env.from_string(self.content)
+    return template.render(context)
 
 
+  def get_variable_names(self) -> List[str]:
+    """List variable names in insertion order."""
+    return self.variables.get_variable_names()
 
 
   @classmethod
   @classmethod
-  def from_file(cls, file_path: Path) -> "Template":
-    """Create a Template instance from a file path.
-    
-    Args:
-        file_path: Path to the template file
-    """
+  def from_file(cls, file_path: Path, module_variables: Dict[str, Any] = None) -> "Template":
+    """Create a Template instance from a file path."""
     logger.debug(f"Loading template from file: {file_path}")
     logger.debug(f"Loading template from file: {file_path}")
+
     try:
     try:
       frontmatter_data, content = cls._parse_frontmatter(file_path)
       frontmatter_data, content = cls._parse_frontmatter(file_path)
+      template_id = file_path.parent.name
+
       template = cls(
       template = cls(
         file_path=file_path,
         file_path=file_path,
         content=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', [])
+        id=template_id,
+        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", []),
       )
       )
-      # Store frontmatter variables - module enrichment will handle the integration
-      template.frontmatter_variables = frontmatter_data.get('variables', {})
-      
-      if template.frontmatter_variables:
-        logger.debug(f"Template '{template.id}' has {len(template.frontmatter_variables)} frontmatter variables: {list(template.frontmatter_variables.keys())}")
-      
-      logger.info(f"Loaded template '{template.id}' (v{template.version or 'unversioned'}")
-      logger.debug(f"Template details: author='{template.author}', tags={template.tags}")
+
+      logger.info(f"Loaded template '{template.id}' (v{template.version or 'unversioned'})")
+
+      # Extract and merge variables (only those actually used)
+      variables, tpl_names, mod_names = cls._merge_variables(content, frontmatter_data, module_variables or {})
+      template.variables = variables
+      template.template_var_names = tpl_names
+      template.module_var_names = mod_names
+
+      logger.debug(
+        f"Final variables for template '{template.id}': {template.variables.get_variable_names()}"
+      )
+
       return template
       return template
+
+    except FileNotFoundError:
+      logger.error(f"Template file not found: {file_path}")
+      raise
     except Exception as e:
     except Exception as e:
-      # If frontmatter parsing fails, create a basic Template object
-      logger.warning(f"Failed to parse frontmatter for {file_path}: {e}. Creating basic template.")
-      return cls(file_path=file_path)
-  
-  @staticmethod
-  def _build_dotted_name(node) -> Optional[str]:
-    """Build full dotted variable name from Jinja2 Getattr node.
-    
-    Returns:
-        Dotted variable name (e.g., 'traefik.host') or None if invalid
-    """
-    current = 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)
-      return '.'.join(parts)
-    return None
+      logger.error(f"Error loading template from {file_path}: {str(e)}")
+      raise
 
 
   @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."""
     return Environment(
     return Environment(
       loader=BaseLoader(),
       loader=BaseLoader(),
-      trim_blocks=True,  # Remove first newline after block tags
-      lstrip_blocks=True,  # Strip leading whitespace from block tags
-      keep_trailing_newline=False  # Remove trailing newlines
+      trim_blocks=True,
+      lstrip_blocks=True,
+      keep_trailing_newline=False,
     )
     )
-  
-  def _get_ast(self):
-    """Get cached AST or create and cache it."""
-    if self._jinja_ast is None:
-      env = self._create_jinja_env()
-      self._jinja_ast = env.parse(self.content)
-    return self._jinja_ast
-  
-  def _get_used_variables(self) -> Set[str]:
-    """Get variables actually used in template (cached)."""
-    ast = self._get_ast()
-    used_variables = meta.find_undeclared_variables(ast)
-    initial_count = len(used_variables)
-    
-    # Handle dotted notation variables
-    dotted_vars = []
-    for node in ast.find_all(nodes.Getattr):
-      dotted_name = Template._build_dotted_name(node)
-      if dotted_name:
-        used_variables.add(dotted_name)
-        dotted_vars.append(dotted_name)
-    
-    if dotted_vars:
-      logger.debug(f"Found {len(dotted_vars)} dotted variables in addition to {initial_count} simple variables")
-    
-    return used_variables
-  
+
   @staticmethod
   @staticmethod
   def _parse_frontmatter(file_path: Path) -> Tuple[Dict[str, Any], str]:
   def _parse_frontmatter(file_path: Path) -> Tuple[Dict[str, Any], str]:
     """Parse frontmatter and content from a file."""
     """Parse frontmatter and content from a file."""
-    with open(file_path, 'r', encoding='utf-8') as f:
+    with open(file_path, "r", encoding="utf-8") as f:
       post = frontmatter.load(f)
       post = frontmatter.load(f)
     return post.metadata, post.content
     return post.metadata, post.content
+
+  @staticmethod
+  def _extract_variables_from_frontmatter(frontmatter_data: Dict[str, Any]) -> Dict[str, Variable]:
+    """Extract variables from the 'variables:' section in frontmatter as Variable objects.
+
+    Example:
+      variables:
+        var_name:
+          description: "..."
+          type: "str"
+    """
+    variables_data = frontmatter_data.get("variables")
+    result: Dict[str, Variable] = {}
+
+    if not variables_data:
+      return result
+
+    try:
+      if isinstance(variables_data, dict):
+        for name, var_config in variables_data.items():
+          if isinstance(var_config, dict):
+            variable = Variable.from_dict(name, var_config)
+            result[name] = variable
+          else:
+            logger.warning(
+              f"Invalid variable configuration for '{name}': expected dict, got {type(var_config).__name__}"
+            )
+      else:
+        raise ValueError(
+          "Variables must be a dictionary. Use format: variables: { var_name: { type: 'str' } }"
+        )
+    except Exception as e:
+      logger.error(f"Error parsing variables from frontmatter: {str(e)}")
+      return {}
+
+    logger.debug(
+      f"Extracted {len(result)} variables (insertion order preserved): {list(result.keys())}"
+    )
+    return result
+
+  @staticmethod
+  def _extract_template_variables(content: str) -> Set[str]:
+    """Extract variable names used in Jinja2 template content (flat names only).
+
+    Strategy:
+    - Use Jinja2 AST to find undeclared variables
+    - Ignore dotted and bracket access (templates should use flat names only)
+    """
+    try:
+      env = Template._create_jinja_env()
+      ast = env.parse(content)
+      root_variables = meta.find_undeclared_variables(ast)
+      logger.debug(f"Found variables: {sorted(root_variables)}")
+      return set(root_variables)
+    except TemplateSyntaxError as e:
+      logger.warning(f"Template syntax error while analyzing variables: {e}")
+      return set()
+    except Exception as e:
+      logger.warning(f"Error analyzing template variables: {e}")
+      return set()
+
+  @staticmethod
+  def _extract_jinja_defaults(content: str) -> Dict[str, str]:
+    """Extract default values from Jinja2 | default() filters for flat names."""
+    defaults: Dict[str, str] = {}
+    try:
+      # Flat var names only (no dots). Single or double quotes supported
+      default_pattern = r"{{\s*([a-zA-Z_][a-zA-Z0-9_]*)\s*\|\s*default\(\s*['\"]([^'\"]*)['\"]\s*\)"
+      matches = re.findall(default_pattern, content)
+      for var_name, default_value in matches:
+        defaults[var_name.strip()] = default_value
+      logger.debug(f"Found Jinja2 defaults: {defaults}")
+      return defaults
+    except Exception as e:
+      logger.warning(f"Error extracting Jinja2 defaults: {e}")
+      return {}
+
+  @staticmethod
+  def _merge_variables(
+    content: str,
+    frontmatter_data: Dict[str, Any],
+    module_variables: Dict[str, Any],
+  ) -> Tuple[VariableCollection, List[str], List[str]]:
+    """Merge module + frontmatter vars, auto-create missing, and apply Jinja defaults.
+
+    Precedence (highest to lowest when a value exists):
+      1. Template frontmatter variables
+      2. Jinja | default() values (only if no value is set)
+      3. Module variables
+      4. Auto-created variables for what's used in content
+    """
+    used_variables = Template._extract_template_variables(content)
+    jinja_defaults = Template._extract_jinja_defaults(content)
+
+    if not used_variables:
+      logger.debug("No variables found in template content")
+      return VariableCollection()
+
+    variables = VariableCollection()
+
+    logger.debug(
+      f"Processing module variables: {list(module_variables.keys()) if module_variables else []}"
+    )
+
+    # Compatibility bridge: if module defines *_enabled toggles and legacy roots are used
+    # (e.g., 'traefik' in template), ensure '<root>_enabled' is also included and map defaults.
+    toggle_roots = {k[:-len('_enabled')] for k in module_variables.keys() if k.endswith('_enabled')}
+
+    # Add missing toggles for used legacy roots
+    bridged_used = set(used_variables)
+    for root in toggle_roots:
+      if root in used_variables:
+        bridged_used.add(f"{root}_enabled")
+
+    # Map Jinja defaults from legacy roots to *_enabled toggles
+    bridged_defaults = dict(jinja_defaults)
+    for root in toggle_roots:
+      if root in jinja_defaults and f"{root}_enabled" not in bridged_defaults:
+        bridged_defaults[f"{root}_enabled"] = jinja_defaults[root]
+
+    # 1) Module variables (lowest precedence)
+    variables.add_from_dict(module_variables, bridged_used, label="module")
+
+    # 2) Frontmatter variables (override module specs)
+    template_vars = Template._extract_variables_from_frontmatter(frontmatter_data)
+    variables.add_from_dict(template_vars, bridged_used, label="template")
+
+    # Track source ordering lists
+    template_var_names_ordered: List[str] = [n for n in template_vars.keys() if n in bridged_used]
+    module_var_names_ordered: List[str] = [n for n in module_variables.keys() if n in bridged_used]
+
+    # 3) Auto-create missing variables for anything used in the template
+    defined_names = set(variables.variables.keys())
+    missing = bridged_used - defined_names
+
+    # Auto-create missing variables (flat names only). Skip legacy roots if their *_enabled exists.
+    for name in sorted(missing):
+      if name in toggle_roots:
+        # Will be provided via alias from '<root>_enabled'
+        logger.debug(f"Skipping auto-create for legacy root '{name}' (alias provided by *_enabled)")
+        continue
+      variables.variables[name] = Variable(name=name, type="str")
+      logger.debug(f"Auto-created variable '{name}' (flat)")
+
+    # Apply Jinja defaults last (only fill if still empty)
+    variables.apply_jinja_defaults(bridged_defaults)
+
+    logger.debug(
+      f"Smart merge: {len(bridged_used)} used, {len(variables)} defined = {len(variables)} final variables"
+    )
+    return variables, template_var_names_ordered, module_var_names_ordered

+ 125 - 0
cli/core/variables.py

@@ -0,0 +1,125 @@
+from dataclasses import dataclass, field
+from typing import Any, Dict, List, Optional, Set
+import logging
+
+logger = logging.getLogger(__name__)
+
+
+@dataclass
+class Variable:
+  """Represents a single templating variable.
+  
+  Supported types: str, int, float, bool, enum
+  """
+  name: str
+  description: Optional[str] = None
+  type: str = "str"  # str, int, float, bool, enum
+  options: Optional[List[Any]] = field(default_factory=list)
+  prompt: Optional[str] = None
+  value: Any = None
+
+  @classmethod
+  def from_dict(cls, name: str, data: dict) -> "Variable":
+    """Unified constructor for dict-based specs (module or frontmatter).
+    Accepts keys: description/display, type, options, prompt, value/default.
+    """
+    return cls(
+      name=name,
+      description=data.get("description") or data.get("display", ""),
+      type=data.get("type", "str"),
+      options=data.get("options", []),
+      prompt=data.get("prompt"),
+      value=data.get("value") if data.get("value") is not None else data.get("default")
+    )
+
+  def get_typed_value(self) -> Any:
+    """Return the value converted to the appropriate Python type."""
+    if self.value is None:
+      return None
+
+    if self.type == "bool":
+      if isinstance(self.value, bool):
+        return self.value
+      return str(self.value).lower() in ("true", "1", "yes", "on")
+    if self.type == "int":
+      return int(self.value)
+    if self.type == "float":
+      return float(self.value)
+    return str(self.value)
+
+
+@dataclass
+class VariableCollection:
+  """Manages variables with merge precedence and builds Jinja context.
+
+  Flat model: context is a simple name -> typed value mapping.
+  """
+
+  variables: Dict[str, Variable] = field(default_factory=dict)
+
+  def add_from_dict(self, specs: Dict[str, Any], used_vars: Set[str], label: str = "spec") -> None:
+    """Generic adder that accepts a mapping of name -> (Variable | dict spec).
+
+    - Preserves declaration order
+    - Filters by used_vars
+    - Uses Variable.from_dict for dict specs
+    """
+    used = set(used_vars)
+    for name in specs.keys():
+      if name not in used:
+        continue
+      spec = specs[name]
+      if isinstance(spec, Variable):
+        self.variables[name] = spec
+        logger.debug(f"Added {label} variable '{name}': {spec.description} (type: {spec.type})")
+      elif isinstance(spec, dict):
+        variable = Variable.from_dict(name, spec)
+        self.variables[name] = variable
+        logger.debug(f"Added {label} variable '{name}' (dict): {variable.description} (type: {variable.type})")
+      else:
+        logger.warning(
+          f"Invalid {label} variable for '{name}': expected Variable or dict, got {type(spec).__name__}"
+        )
+
+  def apply_jinja_defaults(self, jinja_defaults: Dict[str, str]) -> None:
+    """Apply Jinja2 defaults to variables that do not have a value yet."""
+    for var_name, default_value in jinja_defaults.items():
+      if var_name in self.variables:
+        if self.variables[var_name].value is None or self.variables[var_name].value == "":
+          self.variables[var_name].value = default_value
+          logger.debug(f"Applied Jinja2 default to '{var_name}': {default_value}")
+
+  def to_jinja_context(self) -> Dict[str, Any]:
+    """Convert the collection to a flat dict suitable for Jinja rendering.
+
+    Compatibility: for any '<section>_enabled' boolean, also expose '<section>' for
+    legacy templates that expect a truthy/falsey root variable.
+    """
+    context: Dict[str, Any] = {}
+
+    # First pass: direct mapping
+    for var_name, variable in self.variables.items():
+      value = variable.get_typed_value()
+      if value is None:
+        value = ""  # Avoid None in Jinja output
+      context[var_name] = value
+
+    # Second pass: alias *_enabled -> root
+    for var_name, variable in self.variables.items():
+      if var_name.endswith("_enabled"):
+        root = var_name[: -len("_enabled")]
+        context[root] = bool(variable.get_typed_value())
+
+    return context
+
+  def get_variable_names(self) -> List[str]:
+    """Get variable names in insertion order."""
+    return list(self.variables.keys())
+
+  def get_variable(self, name: str) -> Optional[Variable]:
+    """Get a specific variable by name."""
+    return self.variables.get(name)
+
+  def __len__(self) -> int:
+    """Number of variables in the collection."""
+    return len(self.variables)

+ 56 - 30
cli/modules/compose.py

@@ -3,45 +3,71 @@ from ..core.registry import registry
 
 
 
 
 class ComposeModule(Module):
 class ComposeModule(Module):
-  """Docker Compose module."""
-  
+  """Docker Compose module.
+
+  Flat variable names only. Simple, explicit toggles (e.g., traefik_enabled) instead of dotted sections.
+  """
+
   name = "compose"
   name = "compose"
   description = "Manage Docker Compose configurations"
   description = "Manage Docker Compose configurations"
-  files = ["docker-compose.yml", "docker-compose.yaml", "compose.yml", "compose.yaml"]
+  # Per rule: prefer compose.yaml first, legacy names kept as fallback
+  files = ["compose.yaml", "compose.yml", "docker-compose.yaml", "docker-compose.yml"]
 
 
+  # Common Compose variables used across templates. Only variables actually used
+  # in a given template are kept during merge.
   variables_spec = {
   variables_spec = {
-    # Root
-    "service_name": {"type": "str", "display": "Service Name", "description": "Service name"},
-    "container_name": {"type": "str", "display": "Container Name", "description": "Custom container name (leave empty to use service name)"},
-    "container_timezone": {"type": "str", "display": "Container Timezone", "description": "Container timezone (e.g., Europe/Berlin, America/New_York)"},
-    "container_loglevel": {"type": "enum", "display": "Log Level", "description": "Container log level", "default": "info", "options": ["debug", "info", "warn", "error"]},
-    "container_hostname": {"type": "str", "display": "Container Hostname", "description": "Container hostname (shows up in logs and networking)"},
-    "restart_policy": {"type": "enum", "display": "Restart Policy", "description": "Container restart policy", "default": "unless-stopped", "options": ["unless-stopped", "always", "on-failure", "no"]},
+    # General
+    "service_name": {"description": "Service name", "type": "str"},
+    "container_name": {"description": "Container name", "type": "str"},
+    "container_timezone": {"description": "Container timezone (e.g., Europe/Berlin)", "type": "str"},
+    "container_loglevel": {
+      "description": "Container log level",
+      "type": "enum",
+      "options": ["debug", "info", "warn", "error"],
+      "default": "info",
+    },
+    "restart_policy": {
+      "description": "Container restart policy",
+      "type": "enum",
+      "options": ["unless-stopped", "always", "on-failure", "no"],
+      "default": "unless-stopped",
+    },
 
 
-    # Ports
-    "ports": {"type": "bool", "display": "Enable Ports", "description": "Enable port mapping"},
+    # Networking
+    "network_enabled": {"description": "Enable custom network block", "type": "bool", "default": False},
+    "network_name": {"description": "Docker network name", "type": "str", "default": "bridge"},
+    "network_external": {"description": "Use existing Docker network", "type": "bool", "default": True},
 
 
-    # Network
-    "network": {"type": "bool", "display": "Enable Network", "description": "Enable custom network configuration"},
-    "network.name": {"type": "str", "display": "Network Name", "description": "Docker network name (e.g., frontend, backend, bridge)", "default": "bridge"},
-    "network.external": {"type": "bool", "display": "External Network", "description": "Use existing network (must be created before running)"},
+    # Ports
+    "ports_enabled": {"description": "Expose ports via 'ports' mapping", "type": "bool", "default": False},
+    "service_port_http": {"description": "HTTP service port (host)", "type": "int", "default": 8080},
+    "service_port_https": {"description": "HTTPS service port (host)", "type": "int", "default": 8443},
 
 
     # Traefik
     # Traefik
-    "traefik": {"type": "bool", "display": "Enable Traefik", "description": "Enable Traefik reverse proxy (requires Traefik to be running separately)"},
-    "traefik.host": {"type": "hostname", "display": "Host Domain", "description": "Domain name for your service (e.g., app.example.com)"},
-    "traefik.entrypoint": {"type": "str", "display": "HTTP Entrypoint", "description": "HTTP entrypoint for non-TLS traffic (e.g., web, http)", "default": "web"},
-    "traefik.tls": {"type": "bool", "display": "Enable TLS", "description": "Enable HTTPS/TLS (requires valid domain and DNS configuration)"},
-    "traefik.tls.entrypoint": {"type": "str", "display": "TLS Entrypoint", "description": "TLS entrypoint for HTTPS traffic (e.g., websecure, https)", "default": "websecure"},
-    "traefik.tls.certresolver": {"type": "str", "display": "Cert Resolver", "description": "Certificate resolver name (e.g., letsencrypt, staging)"},
-
-    # PostgreSQL
-    "postgres": {"type": "bool", "display": "Enable PostgreSQL", "description": "Enable PostgreSQL database"},
-    "postgres.host": {"type": "str", "display": "PostgreSQL Host", "description": "PostgreSQL host (e.g., localhost, postgres, db.example.com)"},
-
-    # Swarm
-    "swarm": {"type": "bool", "display": "Enable Swarm", "description": "Enable Docker Swarm mode (requires Docker Swarm to be initialized)"},
-    "swarm.replicas": {"type": "int", "display": "Replicas", "description": "Number of container instances", "default": 1},
+    "traefik_enabled": {"description": "Enable Traefik reverse proxy integration", "type": "bool", "default": False},
+    "traefik_host": {"description": "Domain name for your service", "type": "hostname"},
+    "traefik_entrypoint": {"description": "HTTP entrypoint (non-TLS)", "type": "str", "default": "web"},
+    "traefik_tls_enabled": {"description": "Enable HTTPS/TLS", "type": "bool", "default": True},
+    "traefik_tls_entrypoint": {"description": "TLS entrypoint", "type": "str", "default": "websecure"},
+    "traefik_tls_certresolver": {"description": "Traefik certificate resolver name", "type": "str"},
+
+    # Docker Swarm
+    "swarm_enabled": {"description": "Enable Docker Swarm mode", "type": "bool", "default": False},
+    "swarm_replicas": {"description": "Number of replicas in Swarm", "type": "int", "default": 1},
+
+    # Nginx example
+    "nginx_dashboard_enabled": {"description": "Enable Nginx dashboard", "type": "bool", "default": False},
+    "nginx_dashboard_port": {"description": "Nginx dashboard port (host)", "type": "int", "default": 8081},
+
+    # PostgreSQL integration
+    "postgres_enabled": {"description": "Enable PostgreSQL integration", "type": "bool", "default": False},
+    "postgres_host": {"description": "PostgreSQL host", "type": "str", "default": "postgres"},
+    "postgres_port": {"description": "PostgreSQL port", "type": "int", "default": 5432},
+    "postgres_database": {"description": "PostgreSQL database name", "type": "str"},
+    "postgres_user": {"description": "PostgreSQL user", "type": "str"},
+    "postgres_password": {"description": "PostgreSQL password", "type": "str"},
   }
   }
 
 
+
 # Register the module
 # Register the module
 registry.register(ComposeModule)
 registry.register(ComposeModule)

+ 3 - 3
library/compose/n8n/compose.yaml

@@ -43,7 +43,7 @@ services:
       - data:/home/node/.n8n
       - data:/home/node/.n8n
     {% if network %}
     {% if network %}
     networks:
     networks:
-      - {{ network.name | default('bridge') }}
+      - {{ network_name | default('bridge') }}
     {% endif %}
     {% endif %}
     {% if traefik %}
     {% if traefik %}
     labels:
     labels:
@@ -70,8 +70,8 @@ volumes:
 
 
 {% if network %}
 {% if network %}
 networks:
 networks:
-  {{ network.name | default('bridge') }}:
-  {% if network.external %}
+  {{ network_name | default('bridge') }}:
+  {% if network_external %}
     external: true
     external: true
   {% endif %}
   {% endif %}
 {% endif %}
 {% endif %}

+ 16 - 1
library/compose/traefik/compose.yaml

@@ -14,6 +14,15 @@ variables:
   acme_email:
   acme_email:
     display: "ACME Email"
     display: "ACME Email"
     description: "Email address for ACME (Let's Encrypt) registration"
     description: "Email address for ACME (Let's Encrypt) registration"
+    type: "str"
+  traefik.host:
+    display: "Traefik Host"
+    description: "Domain name for Traefik dashboard"
+    type: "str"
+  database.name:
+    display: "Database Name"
+    description: "Name of the database"
+    type: "str"
 ---
 ---
 services:
 services:
   traefik:
   traefik:
@@ -30,7 +39,13 @@ services:
       - ./config/:/etc/traefik/:ro
       - ./config/:/etc/traefik/:ro
       - ./certs/:/var/traefik/certs/:rw
       - ./certs/:/var/traefik/certs/:rw
     environment:
     environment:
-      - CF_DNS_API_TOKEN=your-cloudflare-api-token  # <-- Change this to your Cloudflare API Token
+      - CF_DNS_API_TOKEN={{ acme_email }}  # Using template variable
+      {% if traefik.host -%}
+      - TRAEFIK_HOST={{ traefik.host }}
+      {% endif %}
+      {% if database.name -%}
+      - DB_NAME={{ database.name }}
+      {% endif %}
     networks:
     networks:
       - frontend
       - frontend
     restart: unless-stopped
     restart: unless-stopped