瀏覽代碼

new functions

xcad 5 月之前
父節點
當前提交
ae0b98fdbe
共有 9 個文件被更改,包括 1119 次插入151 次删除
  1. 74 0
      cli/core/args.py
  2. 238 19
      cli/core/library.py
  3. 101 18
      cli/core/module.py
  4. 291 0
      cli/core/prompt.py
  5. 215 80
      cli/core/template.py
  6. 125 0
      cli/core/variables.py
  7. 56 30
      cli/modules/compose.py
  8. 3 3
      library/compose/n8n/compose.yaml
  9. 16 1
      library/compose/traefik/compose.yaml

+ 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
-import subprocess
 import logging
+from .template import Template
 
 logger = logging.getLogger(__name__)
 
@@ -14,33 +14,252 @@ class Library:
     self.priority = priority  # Higher priority = checked first
 
   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:
   """Manages multiple libraries and provides methods to find templates."""
   
   # FIXME: For now this is static and only has one library
   def __init__(self):
+
+    # get the root path of the repository
+    repo_root = Path(__file__).parent.parent.parent.resolve()
+
     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):
-    """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 pathlib import Path
-from typing import Optional, Dict, Any
+from typing import Optional, Dict, Any, List
 import logging
-from typer import Typer, Option, Argument
+from typer import Typer, Option, Argument, Context
 from rich.console import Console
 
 from .library import LibraryManager
+from .template import Template
+from .prompt import PromptHandler
+from .args import parse_var_inputs
 
 logger = logging.getLogger(__name__)
 console = Console()
@@ -54,24 +57,31 @@ class Module(ABC):
   def list(self):
     """List all templates."""
     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:
       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:
       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
 
   def show(self, id: str = Argument(..., help="Template ID")):
     """Show template details."""
     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:
       logger.debug(f"Template '{id}' not found in module '{self.name}'")
@@ -93,9 +103,9 @@ class Module(ABC):
       if value:
         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
     if template.content:
@@ -105,19 +115,91 @@ class Module(ABC):
   def generate(
     self,
     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}'")
-    # 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:
       logger.error(f"Template '{id}' not found for generation 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):
     """Register module commands with the main app."""
@@ -125,6 +207,7 @@ class Module(ABC):
     module_app = Typer()
     module_app.command()(self.list)
     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)
     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 typing import Any, Dict, List, Set, Tuple, Optional
 from dataclasses import dataclass, field
@@ -11,13 +12,13 @@ logger = logging.getLogger(__name__)
 
 @dataclass
 class Template:
-  """Data class for template information extracted from frontmatter."""
-  
+  """Represents a template file with frontmatter and content."""
+
   # Required fields
   file_path: Path
   content: str = ""
-  
-  # Frontmatter fields with defaults
+
+  # Frontmatter metadata
   id: str = ""
   name: str = ""
   description: str = "No description available"
@@ -27,104 +28,238 @@ class Template:
   module: str = ""
   tags: 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
-  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}")
+
     try:
       frontmatter_data, content = cls._parse_frontmatter(file_path)
+      template_id = file_path.parent.name
+
       template = cls(
         file_path=file_path,
         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
+
+    except FileNotFoundError:
+      logger.error(f"Template file not found: {file_path}")
+      raise
     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
   def _create_jinja_env() -> Environment:
     """Create standardized Jinja2 environment for consistent template processing."""
     return Environment(
       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
   def _parse_frontmatter(file_path: Path) -> Tuple[Dict[str, Any], str]:
     """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)
     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):
-  """Docker Compose module."""
-  
+  """Docker Compose module.
+
+  Flat variable names only. Simple, explicit toggles (e.g., traefik_enabled) instead of dotted sections.
+  """
+
   name = "compose"
   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 = {
-    # 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": {"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
 registry.register(ComposeModule)

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

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

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

@@ -14,6 +14,15 @@ variables:
   acme_email:
     display: "ACME Email"
     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:
   traefik:
@@ -30,7 +39,13 @@ services:
       - ./config/:/etc/traefik/:ro
       - ./certs/:/var/traefik/certs/:rw
     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:
       - frontend
     restart: unless-stopped