Forráskód Böngészése

multi-file support implemented

xcad 4 hónapja
szülő
commit
45660126eb

+ 0 - 113
.github/copilot-instructions.md

@@ -1,113 +0,0 @@
-# copilot-instructions.md
-
-This file provides guidance to GitHub Copilot when working with code in this repository.
-
-## Project Overview
-
-This repository contains a sophisticated collection of templates (called boilerplates) for managing infrastructure across multiple technologies including Terraform, Docker, Ansible, Kubernetes, etc. The project also includes a Python CLI application that allows an easy management, creation, and deployment of boilerplates.
-
-## Repository Structure
-
-- `cli/` - Python CLI application source code
-  - `cli/core/` - Core functionality (app, config, commands, logging)
-  - `cli/modules/` - Technology-specific modules (terraform, docker, compose, etc.)
-- `library/` - Template collections organized by technology
-  - `library/terraform/` - OpenTofu/Terraform templates and examples
-  - `library/compose/` - Docker Compose configurations
-  - `library/proxmox/` - Packer templates for Proxmox
-  - `library/ansible/` - Ansible playbooks and configurations
-  - `library/kubernetes/` - Kubernetes deployments
-  - And more...
-
-## Development Setup
-
-### Installation and Dependencies
-
-```bash
-# Install in development mode
-pip install -e .
-
-# Install from requirements
-pip install -r requirements.txt
-```
-
-### Running the CLI
-
-```bash
-# Run via Python module
-python -m cli --help
-
-# Run via installed command
-boilerplate --help
-
-# Example module usage
-boilerplate terraform --help
-boilerplate compose config list
-```
-
-## Common Development Tasks
-
-### Testing and Validation
-
-```bash
-# Lint YAML files (used in CI)
-yamllint --strict -- $(git ls-files '*.yaml' '*.yml')
-
-# Run CLI with debug logging
-boilerplate --log-level DEBUG [command]
-```
-
-### Adding New Modules
-
-1. Create new module file: `cli/modules/[module_name].py`
-2. Implement module class inheriting from `BaseModule` in the file
-3. Add module to imports in `cli/__main__.py`
-4. Create corresponding template directory in `library/[module_name]/`
-
-## Architecture Notes
-
-### CLI Architecture
-
-- **Modular Design**: Each technology (terraform, docker, etc.) is implemented as a separate module
-- **Configuration Management**: Per-module configuration stored in `~/.boilerplates/[module].json`
-- **Template System**: Uses Jinja2 for template processing with frontmatter metadata
-- **Rich UI**: Uses Rich library for enhanced terminal output and tables
-
-### Key Components
-
-- `ConfigManager`: Handles module-specific configuration persistence
-- `BaseModule`: Abstract base class providing shared commands (config management)
-- Module Commands: Each module implements technology-specific operations
-- Template Library: Structured collection of boilerplates with metadata
-
-### Template Format
-
-Templates use YAML frontmatter for metadata:
-
-```yaml
----
-name: "Template Name"
-description: "Template description"
-version: "0.0.1"
-date: "2023-10-01"
-author: "Christian Lempa"
-tags:
-  - tag1
-  - tag2
----
-[Template content here]
-```
-
-## Important Rules and Conventions
-
-- **Docker Compose**: Default to `compose.yaml` filename (not `docker-compose.yml`)
-- **Logging Standards**: No emojis, avoid multi-lines, use proper log levels
-- **Comment Anchors**: Use for TODOs, FIXMEs, notes, and links in source code
-- **Indentation**: ALWAYS use 2 spaces for indentation!
-
-## Configuration
-
-- YAML linting configured with max 160 character line length
-- Python 3.9+ required
-- Rich markup mode enabled for enhanced CLI output
-- Logging configurable via `--log-level` flag

+ 45 - 36
AGENTS.md

@@ -58,40 +58,48 @@ The CLI application is built with a modular and extensible architecture.
 
 ### Template Format
 
-Templates use YAML frontmatter for metadata, followed by the actual template content with Jinja2 syntax. Example:
+Templates are directory-based. Each template is a directory containing all the necessary files and subdirectories for the boilerplate.
+
+#### Main Template File
+
+Every template directory must contain a main template file named either `template.yaml` or `template.yml`. This file serves as the entry point and contains the template's metadata and variable specifications in YAML frontmatter format.
+
+Example `template.yaml`:
 
 ```yaml
 ---
-kind: "compose|terraform|ansible|kubernetes|..."
+kind: "compose"
 metadata:
-  name: "Template Name"
-  description: "Template description"
-  version: "0.0.1"
-  date: "2023-10-01"
+  name: "My Nginx Template"
+  description: "A template for a simple Nginx service."
+  version: "0.1.0"
   author: "Christian Lempa"
-  tags:
-    - tag1
-    - tag2
 spec:
-  section1:
-    description: "Description of section1"
-    prompt: "Do you want to configure section1?"
-    toggle: "section1_enabled"
-    required: false|true
-    section1_enabled:
-      type: "bool"
-      description: "Enable section1"
-      default: false
-    section1_var2:
-      type: "string|int|bool|list|dict"
-      description: "Description of var1"
-      default: "default_value"
+  general:
+    vars:
+      nginx_version:
+        type: "string"
+        description: "The Nginx version to use."
+        default: "latest"
 ---
-# Actual template content with Jinja2 syntax
-services:
-  my_service:
-    image: "{{ section1_var2 | default('nginx') }}"
-    ...
+```
+
+#### Template Files
+
+-   **Jinja2 Templates (`.j2`)**: Any file within the template directory that has a `.j2` extension will be rendered by the Jinja2 engine. The `.j2` extension is removed from the final output file name (e.g., `config.json.j2` becomes `config.json`). These files can use `{% include %}` and `{% import %}` statements to share code with other files in the template directory.
+
+-   **Static Files**: Any file without a `.j2` extension is treated as a static file and will be copied to the output directory as-is, preserving its relative path and filename.
+
+#### Example Directory Structure
+
+```
+library/compose/my-nginx-template/
+├── template.yaml
+├── compose.yaml.j2
+├── config/
+│   └── nginx.conf.j2
+└── static/
+    └── README.md
 ```
 
 #### Variables
@@ -103,9 +111,8 @@ Variables are a cornerstone of the CLI, allowing for dynamic and customizable te
 Variables are sourced and merged from multiple locations, with later sources overriding earlier ones:
 
 1.  **Module `spec` (Lowest Precedence)**: Each module (e.g., `cli/modules/compose.py`) can define a base `spec` dictionary. This provides default variables and sections for all templates of that `kind`.
-2.  **Template `spec`**: The `spec` block within a template file's frontmatter can override or extend the module's `spec`. This allows a template to customize variable descriptions, defaults, or add new variables.
-3.  **Jinja2 `default` Filter**: A `default` filter used directly in the template content (e.g., `{{ my_var | default('value') }}`) will override any `default` value defined in the `spec` blocks.
-4.  **CLI Overrides (`--var`) (Highest Precedence)**: Providing a variable via the command line (`--var KEY=VALUE`) has the highest priority and will override any default or previously set value.
+2.  **Template `spec`**: The `spec` block within the `template.yaml` or `template.yml` file can override or extend the module's `spec`. This is the single source of truth for defaults within the template.
+3.  **CLI Overrides (`--var`) (Highest Precedence)**: Providing a variable via the command line (`--var KEY=VALUE`) has the highest priority and will override any default or previously set value.
 
 The `Variable.origin` attribute is updated to reflect this chain (e.g., `module -> template -> cli`).
 
@@ -122,13 +129,15 @@ The `Variable.origin` attribute is updated to reflect this chain (e.g., `module
 - During an interactive session, the CLI will first ask the user to enable or disable the section by prompting for the toggle variable (e.g., "Enable advanced settings?").
 - If the section is disabled (the toggle is `false`), all other variables within that section are skipped, and the section is visually dimmed in the summary table. This provides a clean way to manage optional or advanced configurations.
 
+## Future Improvements
+
 ### Managing TODOs as GitHub Issues
 
 We use a convention to manage TODO items as GitHub issues directly from the codebase. This allows us to track our work and link it back to the specific code that needs attention.
 
 The format for a TODO item is:
 
-`* TODO[<issue-number>-<slug>] <description>`
+`TODO[<issue-number>-<slug>] <description>`
 
 -   `<issue-number>`: The GitHub issue number.
 -   `<slug>`: A short, descriptive slug for the epic or feature.
@@ -142,16 +151,16 @@ gh issue create --title "<title>" --body "<description>" --assignee "@me" --proj
 
 After creating the issue, update the TODO line in the `AGENTS.md` file with the issue number and a descriptive slug.
 
-## Future Improvements
-
 ### Work in Progress
 
 * TODO[1242-secret-support] Consider creating a "secret" variable type that automatically handles sensitive data and masks input during prompts, which also should be set via .env file and not directly in the compose files or other templates.
-  * Implement multi-file support for templates, allowing jinja2 in other files as well
-  * Mask secrets in rendering output (e.g. when displaying the final docker-compose file, mask secret values)
-  * Add support for --out to specify a directory
+* TODO[1244-mask-secrets] Mask secrets in rendering output (e.g. when displaying the final docker-compose file, mask secret values)
+* TODO[1245-out-directory] Add support for --out to specify a directory
 * TODO[1246-validation-rules] Add support for more complex validation rules for environment variables, such as regex patterns or value ranges.
 * TODO[1247-user-overrides] Add configuration support to allow users to override module and template spec with their own (e.g. defaults -> compose -> spec -> general ...)
 * TODO[1248-installation-script] Add an installation script when cloning the repo and setup necessary commands
 * TODO[1249-update-script] Add an automatic update script to keep the tool up-to-date with the latest version from the repository.
 * TODO[1250-compose-deploy] Add compose deploy command to deploy a generated compose project to a local or remote docker environment
+* TODO[1251-centralize-display-logic] Create a DisplayManager class to handle all rich rendering.
+* TODO[1252-simplify-variable-handling] Refactor Variable and VariableCollection classes to simplify validation and initialization.
+* TODO[1253-streamline-prompting] Refactor PromptHandler to streamline validation and default value logic.

+ 12 - 47
cli/core/library.py

@@ -26,12 +26,11 @@ class Library:
     self.path = path
     self.priority = priority  # Higher priority = checked first
 
-  def find_by_id(self, module_name: str, files: list[str], template_id: str) -> tuple[Path, str]:
+  def find_by_id(self, module_name: str, template_id: str) -> tuple[Path, str]:
     """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:
@@ -45,35 +44,19 @@ class Library:
     # 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():
+    # Check if the template directory and either template.yaml or template.yml exist
+    if not (template_path.is_dir() and ((template_path / "template.yaml").exists() or (template_path / "template.yml").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, self.name
 
 
-  def find(self, module_name: str, files: list[str], sort_results: bool = False) -> list[tuple[Path, str]]:
+  def find(self, module_name: str, sort_results: bool = False) -> list[tuple[Path, str]]:
     """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:
@@ -88,31 +71,15 @@ class Library:
     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}'")
+      raise FileNotFoundError(f"Module '{module_name}' not found in library '{self.name}'")
     
-    # Get all directories in the module path
+    # Get all directories in the module path that contain a template.yaml or template.yml file
     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, self.name))
-          else:
-            # No file requirements, include all directories
-            template_dirs.append((item, self.name))
+        if item.is_dir() and ((item / "template.yaml").exists() or (item / "template.yml").exists()):
+          template_dirs.append((item, self.name))
     except PermissionError as e:
       raise FileNotFoundError(f"Permission denied accessing module '{module_name}' in library '{self.name}': {e}")
     
@@ -142,12 +109,11 @@ class LibraryManager:
       Library(name="default", path=repo_root / "library", priority=0)
     ]
 
-  def find_by_id(self, module_name: str, files: list[str], template_id: str) -> Optional[tuple[Path, str]]:
+  def find_by_id(self, module_name: str, template_id: str) -> Optional[tuple[Path, str]]:
     """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:
@@ -157,7 +123,7 @@ class LibraryManager:
     
     for library in sorted(self.libraries, key=lambda x: x.priority, reverse=True):
       try:
-        template_path, lib_name = library.find_by_id(module_name, files, template_id)
+        template_path, lib_name = library.find_by_id(module_name, template_id)
         logger.debug(f"Found template '{template_id}' in library '{library.name}'")
         return template_path, lib_name
       except FileNotFoundError:
@@ -167,12 +133,11 @@ class LibraryManager:
     logger.debug(f"Template '{template_id}' not found in any library")
     return None
   
-  def find(self, module_name: str, files: list[str], sort_results: bool = False) -> list[tuple[Path, str]]:
+  def find(self, module_name: str, sort_results: bool = False) -> list[tuple[Path, str]]:
     """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:
@@ -184,7 +149,7 @@ class LibraryManager:
     
     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
+        templates = library.find(module_name, sort_results=False)  # Sort at the end
         all_templates.extend(templates)
         logger.debug(f"Found {len(templates)} templates in library '{library.name}'")
       except FileNotFoundError:

+ 86 - 102
cli/core/module.py

@@ -1,25 +1,28 @@
 from __future__ import annotations
 
+import logging
 from abc import ABC
 from pathlib import Path
-from typing import Optional, Dict, Any, List
-import logging
-from typer import Typer, Option, Argument, Context
+from typing import Any, Dict, List, Optional
+
 from rich.console import Console
 from rich.panel import Panel
+from rich.prompt import Prompt
 from rich.table import Table
+from rich.tree import Tree
+from typer import Argument, Context, Option, Typer
 
 from .library import LibraryManager
-from .template import Template
 from .prompt import PromptHandler
+from .template import Template
 
 logger = logging.getLogger(__name__)
 console = Console()
 
 
-# -------------------------------
+# ------------------------------- 
 # SECTION: Helper Functions
-# -------------------------------
+# ------------------------------- 
 
 def parse_var_inputs(var_options: list[str], extra_args: list[str]) -> dict[str, Any]:
   """Parse variable inputs from --var options and extra args.
@@ -61,17 +64,16 @@ class Module(ABC):
   """Streamlined base module that auto-detects variables from templates."""
   
   name: str | None = None
-  description: str | None = None  
-  files: list[str] | None = None
+  description: str | None = None
 
   def __init__(self) -> None:
-    if not all([self.name, self.description, self.files]):
+    if not all([self.name, self.description]):
       raise ValueError(
-        f"Module {self.__class__.__name__} must define name, description, and files"
+        f"Module {self.__class__.__name__} must define name and description"
       )
     
     logger.info(f"Initializing module '{self.name}'")
-    logger.debug(f"Module '{self.name}' configuration: files={self.files}, description='{self.description}'")
+    logger.debug(f"Module '{self.name}' configuration: description='{self.description}'")
     self.libraries = LibraryManager()
 
   # --------------------------
@@ -83,23 +85,14 @@ class Module(ABC):
     logger.debug(f"Listing templates for module '{self.name}'")
     templates = []
 
-    entries = self.libraries.find(self.name, self.files, sort_results=True)
+    entries = self.libraries.find(self.name, sort_results=True)
     for template_dir, library_name in entries:
-      # Find the first matching template file
-      template_file = None
-      for file_name in self.files:
-        candidate = template_dir / file_name
-        if candidate.exists():
-          template_file = candidate
-          break
-      
-      if template_file:
-        try:
-          template = Template(template_file, library_name=library_name)
-          templates.append(template)
-        except Exception as exc:
-          logger.error(f"Failed to load template from {template_file}: {exc}")
-          continue
+      try:
+        template = Template(template_dir, library_name=library_name)
+        templates.append(template)
+      except Exception as exc:
+        logger.error(f"Failed to load template from {template_dir}: {exc}")
+        continue
     
     if templates:
       logger.info(f"Listing {len(templates)} templates for module '{self.name}'")
@@ -145,82 +138,64 @@ 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", help="Output directory"),
     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,
   ) -> None:
-    """Generate from template.
-
-    Supports variable overrides via:
-      --var KEY=VALUE
-      --var KEY VALUE
-    """
+    """Generate from template."""
 
     logger.info(f"Starting generation for template '{id}' from module '{self.name}'")
     template = self._load_template_by_id(id)
 
-    # Build variable overrides from Typer-collected options and any extra args BEFORE displaying template
-    extra_args = []
-    try:
-      if ctx is not None and hasattr(ctx, "args"):
-        extra_args = list(ctx.args)
-    except Exception:
-      extra_args = []
-
+    extra_args = list(ctx.args) if ctx and hasattr(ctx, "args") else []
     cli_overrides = parse_var_inputs(var or [], extra_args)
     if cli_overrides:
       logger.info(f"Received {len(cli_overrides)} variable overrides from CLI")
-      # Apply CLI overrides to template variables before display
       if template.variables:
         successful_overrides = template.variables.apply_overrides(cli_overrides, " -> cli")
         if successful_overrides:
           logger.debug(f"Applied CLI overrides for: {', '.join(successful_overrides)}")
 
-    # Show template details with CLI overrides already applied
     self._display_template_details(template, id)
-    console.print()  # Add spacing before variable collection
+    console.print()
 
-    # Collect variable values interactively if enabled
     variable_values = {}
     if interactive and template.variables:
       prompt_handler = PromptHandler()
-      
-      # Collect values with simplified sectioned flow
       collected_values = prompt_handler.collect_variables(template.variables)
-      
       if collected_values:
         variable_values.update(collected_values)
         logger.info(f"Collected {len(collected_values)} variable values from user input")
 
-    # CLI overrides are already applied to the template variables, so collect all current values
-    # This includes defaults, interactive changes, and CLI overrides
     if template.variables:
       variable_values.update(template.variables.get_all_values())
 
-    # Render template with collected values
     try:
-      rendered_content = template.render(variable_values)
+      # Validate all variables before rendering
+      if template.variables:
+        template.variables.validate_all()
+      
+      rendered_files = 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("\n\n[bold blue]Generated Template:[/bold blue]")
-        console.print("─" * 50)
-        console.print(rendered_content)
-        logger.info("Template output to stdout")
-        
+      output_dir = out
+      if not output_dir:
+        output_dir_str = Prompt.ask("Enter output directory", default=".")
+        output_dir = Path(output_dir_str)
+      
+      for file_path, content in rendered_files.items():
+        full_path = output_dir / file_path
+        full_path.parent.mkdir(parents=True, exist_ok=True)
+        with open(full_path, 'w', encoding='utf-8') as f:
+          f.write(content)
+        console.print(f"[green]Generated file: {full_path}[/green]")
+      
+      logger.info(f"Template written to directory: {output_dir}")
+
     except Exception as e:
-      logger.error(f"Error rendering template '{id}': {str(e)}")
-      console.print(f"[red]Error generating template: {str(e)}[/red]")
+      logger.error(f"Error rendering template '{id}': {e}")
+      console.print(f"[red]Error generating template: {e}[/red]")
       raise
 
   # !SECTION
@@ -234,23 +209,18 @@ class Module(ABC):
     """Register module commands with the main app."""
     logger.debug(f"Registering CLI commands for module '{cls.name}'")
     
-    # Create a module instance
     module_instance = cls()
     
-    # Create subapp for this module
     module_app = Typer(help=cls.description)
     
-    # Register commands directly on the instance
     module_app.command("list")(module_instance.list)
     module_app.command("show")(module_instance.show)
     
-    # Generate command needs special handling for context
     module_app.command(
       "generate", 
       context_settings={"allow_extra_args": True, "ignore_unknown_options": True}
     )(module_instance.generate)
     
-    # Add the module subapp to main app
     app.add_typer(module_app, name=cls.name, help=cls.description)
     logger.info(f"Module '{cls.name}' CLI commands registered")
 
@@ -261,49 +231,64 @@ class Module(ABC):
   # --------------------------
 
   def _load_template_by_id(self, template_id: str) -> Template:
-    result = self.libraries.find_by_id(self.name, self.files, template_id)
+    result = self.libraries.find_by_id(self.name, template_id)
     if not result:
       logger.debug(f"Template '{template_id}' not found in module '{self.name}'")
       raise FileNotFoundError(f"Template '{template_id}' not found in module '{self.name}'")
 
     template_dir, library_name = result
     
-    # Find the first matching template file
-    template_file = None
-    for file_name in self.files:
-      candidate = template_dir / file_name
-      if candidate.exists():
-        template_file = candidate
-        break
-    
-    if not template_file:
-      raise FileNotFoundError(f"Template directory '{template_dir}' missing expected files {self.files}")
-    
     try:
-      return Template(template_file, library_name=library_name)
-    except ValueError as exc:
-      # FIXME: Refactor error handling chain to avoid redundant exception wrapping
-      # ValueError (like validation errors) already logged - just re-raise with context
+      return Template(template_dir, library_name=library_name)
+    except (ValueError, FileNotFoundError) as exc:
       raise FileNotFoundError(f"Template '{template_id}' validation failed in module '{self.name}'") from exc
     except Exception as exc:
-      logger.error(f"Failed to load template from {template_file}: {exc}")
-      raise FileNotFoundError(f"Template file for '{template_id}' not found in module '{self.name}'") from exc
+      logger.error(f"Failed to load template from {template_dir}: {exc}")
+      raise FileNotFoundError(f"Template '{template_id}' could not be loaded in module '{self.name}'") from exc
 
   def _display_template_details(self, template: Template, template_id: str) -> None:
-    """Display template information panel and variables table.
+    """Display template information panel and variables table."""
     
-    Args:
-      template: The Template object to display
-      template_id: The template ID for display purposes
-    """
-    # Show template info panel
+    # Print the main panel
     console.print(Panel(
       f"[bold]{template.metadata.name or 'Unnamed Template'}[/bold]\n\n{template.metadata.description or 'No description available'}", 
       title=f"Template: {template_id}", 
       subtitle=f"Module: {self.name}"
     ))
     
-    # Show variables table if any variables exist
+    # Build the file structure tree
+    file_tree = Tree("[bold blue]Template File Structure:[/bold blue]")
+    
+    # Create a dictionary to hold the tree nodes for directories
+    # This will allow us to build a proper tree structure
+    tree_nodes = {Path('.'): file_tree} # Root of the template directory
+
+    for template_file in sorted(template.template_files, key=lambda f: f.relative_path):
+        parts = template_file.relative_path.parts
+        current_path = Path('.')
+        current_node = file_tree
+
+        # Build the directory path in the tree
+        for part in parts[:-1]: # Iterate through directories
+            current_path = current_path / part
+            if current_path not in tree_nodes:
+                new_node = current_node.add(f"\uf07b [bold blue]{part}[/bold blue]") # Folder icon
+                tree_nodes[current_path] = new_node
+                current_node = new_node
+            else:
+                current_node = tree_nodes[current_path]
+
+        # Add the file to the appropriate directory node
+        if template_file.file_type == 'j2':
+            current_node.add(f"[green]\ue235 {template_file.relative_path.name}[/green]") # Jinja2 file icon
+        elif template_file.file_type == 'static':
+            current_node.add(f"[yellow]\uf15b {template_file.relative_path.name}[/yellow]") # Generic file icon
+            
+    # Print the file tree separately if it has content
+    if file_tree.children: # Check if any files were added to the branches
+        console.print() # Add spacing
+        console.print(file_tree) # Print the Tree object directly
+
     if template.variables and template.variables._set:
       console.print()  # Add spacing
       
@@ -377,5 +362,4 @@ class Module(ABC):
       
       console.print(variables_table)
 
-# !SECTION
-
+# !SECTION

+ 1 - 1
cli/core/registry.py

@@ -28,7 +28,7 @@ class ModuleRegistry:
     
     self._modules[module_class.name] = module_class
     logger.info(f"Registered module '{module_class.name}' (total modules: {len(self._modules)})")
-    logger.debug(f"Module '{module_class.name}' details: description='{module_class.description}', files={module_class.files}")
+    logger.debug(f"Module '{module_class.name}' details: description='{module_class.description}'")
   
   def iter_module_classes(self) -> Iterator[tuple[str, Type]]:
     """Yield registered module classes without instantiating them."""

+ 242 - 184
cli/core/template.py

@@ -2,15 +2,29 @@ from __future__ import annotations
 
 from .variables import Variable, VariableCollection
 from pathlib import Path
-from typing import Any, Dict, List, Set
+from typing import Any, Dict, List, Set, Optional, Literal
 from dataclasses import dataclass, field
 import logging
-from jinja2 import Environment, BaseLoader, meta, nodes
+import os
+from jinja2 import Environment, FileSystemLoader, meta
 import frontmatter
 
 logger = logging.getLogger(__name__)
 
 
+# -----------------------
+# SECTION: TemplateFile Class
+# -----------------------
+
+@dataclass
+class TemplateFile:
+    """Represents a single file within a template directory."""
+    relative_path: Path
+    file_type: Literal['j2', 'static']
+    output_path: Path # The path it will have in the output directory
+
+# !SECTION
+
 # -----------------------
 # SECTION: Metadata Class
 # -----------------------
@@ -25,7 +39,7 @@ class TemplateMetadata:
   version: str
   module: str = ""
   tags: List[str] = field(default_factory=list)
-  files: List[str] = field(default_factory=list)
+  # files: List[str] = field(default_factory=list) # No longer needed, as TemplateFile handles this
   library: str = "unknown"
 
   def __init__(self, post: frontmatter.Post, library_name: str | None = None) -> None:
@@ -43,7 +57,7 @@ class TemplateMetadata:
     self.version = metadata_section.get("version", "")
     self.module = metadata_section.get("module", "")
     self.tags = metadata_section.get("tags", []) or []
-    self.files = metadata_section.get("files", []) or []
+    # self.files = metadata_section.get("files", []) or [] # No longer needed
     self.library = library_name or "unknown"
 
   @staticmethod
@@ -68,168 +82,158 @@ class TemplateMetadata:
 
 @dataclass
 class Template:
-  """Represents a template file with frontmatter and content."""
+  """Represents a template directory."""
 
-  def __init__(self, file_path: Path, library_name: str) -> None:
-    """Create a Template instance from a file path."""
-    logger.debug(f"Loading template from file: {file_path}")
+  def __init__(self, template_dir: Path, library_name: str) -> None:
+    """Create a Template instance from a directory path."""
+    logger.debug(f"Loading template from directory: {template_dir}")
+    self.template_dir = template_dir
+    self.id = template_dir.name
+    self.library_name = library_name
+
+    # Initialize caches for lazy loading
+    self.__module_specs: Optional[dict] = None
+    self.__merged_specs: Optional[dict] = None
+    self.__jinja_env: Optional[Environment] = None
+    self.__used_variables: Optional[Set[str]] = None
+    self.__variables: Optional[VariableCollection] = None
+    self.__template_files: Optional[List[TemplateFile]] = None # New attribute
 
     try:
-      # Parse frontmatter and content from the file
-      logger.debug(f"Loading template from file: {file_path}")
-      with open(file_path, "r", encoding="utf-8") as f:
-        post = frontmatter.load(f)
+      # Find and parse the main template file (template.yaml or template.yml)
+      main_template_path = self._find_main_template_file()
+      with open(main_template_path, "r", encoding="utf-8") as f:
+        self._post = frontmatter.load(f) # Store post for later access to spec
 
-      # Load metadata using the TemplateMetadata constructor
-      self.metadata = TemplateMetadata(post, library_name)
+      # Load metadata (always needed)
+      self.metadata = TemplateMetadata(self._post, library_name)
       logger.debug(f"Loaded metadata: {self.metadata}")
 
-      # Validate 'kind' field presence
-      self._validate_kind(post)
+      # Validate 'kind' field (always needed)
+      self._validate_kind(self._post)
 
-      # Load module specifications
-      kind = post.metadata.get("kind", None)
-      module_specs = {}
-      if kind:
-        try:
-          import importlib
-          module = importlib.import_module(f"..modules.{kind}", package=__package__)
-          module_specs = getattr(module, 'spec', {})
-        except Exception as e:
-          raise ValueError(f"Error loading module specifications for kind '{kind}': {str(e)}")
-      
-      # Loading template variable specs - merge template specs with module specs
-      template_specs = post.metadata.get("spec", {})
-      
-      # Deep merge specs: merge vars within sections instead of replacing entire sections
-      # Preserve order: start with module spec order, then append template-only sections
-      merged_specs = {}
-      
-      # First, process all sections from module spec (preserves order)
-      for section_key in module_specs.keys():
-        module_section = module_specs.get(section_key, {})
-        template_section = template_specs.get(section_key, {})
+      # Collect file paths (relatively lightweight, needed for various lazy loads)
+      # This will now populate self.template_files
+      self._collect_template_files()
+
+      logger.info(f"Loaded template '{self.id}' (v{self.metadata.version})")
+
+    except (ValueError, FileNotFoundError) as e:
+      logger.error(f"Error loading template from {template_dir}: {e}")
+      raise
+    except Exception as e:
+      logger.error(f"An unexpected error occurred while loading template {template_dir}: {e}")
+      raise
+
+  def _find_main_template_file(self) -> Path:
+    """Find the main template file (template.yaml or template.yml)."""
+    for filename in ["template.yaml", "template.yml"]:
+      path = self.template_dir / filename
+      if path.exists():
+        return path
+    raise FileNotFoundError(f"Main template file (template.yaml or template.yml) not found in {self.template_dir}")
+
+  def _load_module_specs(self, kind: str) -> dict:
+    """Load specifications from the corresponding module."""
+    if not kind:
+      return {}
+    try:
+      import importlib
+      module = importlib.import_module(f"..modules.{kind}", package=__package__)
+      return getattr(module, 'spec', {})
+    except Exception as e:
+      raise ValueError(f"Error loading module specifications for kind '{kind}': {e}")
+
+  def _merge_specs(self, module_specs: dict, template_specs: dict) -> dict:
+    """Deep merge template specs with module specs."""
+    merged_specs = {}
+    for section_key in module_specs.keys():
+      module_section = module_specs.get(section_key, {})
+      template_section = template_specs.get(section_key, {})
+      merged_section = {**module_section}
+      for key in ['title', 'prompt', 'description', 'toggle', 'required']:
+        if key in template_section:
+          merged_section[key] = template_section[key]
+      module_vars = module_section.get('vars') if isinstance(module_section.get('vars'), dict) else {}
+      template_vars = template_section.get('vars') if isinstance(template_section.get('vars'), dict) else {}
+      merged_section['vars'] = {**module_vars, **template_vars}
+      merged_specs[section_key] = merged_section
+    
+    for section_key in template_specs.keys():
+      if section_key not in module_specs:
+        merged_specs[section_key] = {**template_specs[section_key]}
         
-        # Start with module section as base
-        merged_section = {**module_section}
+    return merged_specs
+
+  def _collect_template_files(self) -> None:
+    """Collects all TemplateFile objects in the template directory."""
+    template_files: List[TemplateFile] = []
+    
+    for root, _, files in os.walk(self.template_dir):
+      for filename in files:
+        file_path = Path(root) / filename
+        relative_path = file_path.relative_to(self.template_dir)
         
-        # Merge template section metadata (title, prompt, etc.)
-        for key in ['title', 'prompt', 'description', 'toggle', 'required']:
-          if key in template_section:
-            merged_section[key] = template_section[key]
+        # Skip the main template file
+        if filename in ["template.yaml", "template.yml"]:
+          continue
         
-        # Merge vars: template vars extend/override module vars
-        module_vars = module_section.get('vars', {})
-        template_vars = template_section.get('vars', {})
-        merged_section['vars'] = {**module_vars, **template_vars}
+        if filename.endswith(".j2"):
+          file_type: Literal['j2', 'static'] = 'j2'
+          output_path = relative_path.with_suffix('') # Remove .j2 suffix
+        else:
+          file_type = 'static'
+          output_path = relative_path # Static files keep their name
         
-        merged_specs[section_key] = merged_section
-      
-      # Then, add any sections that exist only in template spec
-      for section_key in template_specs.keys():
-        if section_key not in module_specs:
-          template_section = template_specs[section_key]
-          merged_section = {**template_section}
-          merged_specs[section_key] = merged_section
-      
-      logger.debug(f"Loaded specs: {merged_specs}")
-
-      self.file_path = file_path
-      self.id = file_path.parent.name
-
-      self.content = post.content
-      logger.debug(f"Loaded content: {self.content}")
-
-      # Extract variables used in template and their defaults
-      self.jinja_env = self._create_jinja_env()
-      ast = self.jinja_env.parse(self.content)
-      used_variables: Set[str] = meta.find_undeclared_variables(ast)
-      default_values: Dict[str, str] = self._extract_jinja_defaults(ast)
-      logger.debug(f"Used variables: {used_variables}, defaults: {default_values}")
-
-      # Validate that all used variables are defined in specs
-      self._validate_variable_definitions(used_variables, merged_specs)
-
-      # Filter specs to only used variables and merge in Jinja defaults
-      filtered_specs = {}
-      for section_key, section_data in merged_specs.items():
-        if "vars" in section_data:
-          filtered_vars = {}
-          for var_name, var_data in section_data["vars"].items():
-            if var_name in used_variables:
-              # Determine origin: check where this variable comes from
-              module_has_var = (section_key in module_specs and 
-                               var_name in module_specs.get(section_key, {}).get("vars", {}))
-              template_has_var = (section_key in template_specs and 
-                                 var_name in template_specs.get(section_key, {}).get("vars", {}))
-              
-              if module_has_var and template_has_var:
-                origin = "module -> template"  # Template overrides module
-              elif template_has_var and not module_has_var:
-                origin = "template"  # Template-only variable
-              else:
-                origin = "module"  # Module-only variable
-              
-              # Merge in Jinja default and origin if present
-              var_data_with_origin = {**var_data, "origin": origin}
-              if var_name in default_values:
-                var_data_with_origin["default"] = default_values[var_name]
-              elif "default" not in var_data_with_origin:
-                var_data_with_origin["default"] = ""
-                logger.warning(f"No default specified for variable '{var_name}' in template '{self.id}'")
-              
-              filtered_vars[var_name] = var_data_with_origin
+        template_files.append(TemplateFile(relative_path=relative_path, file_type=file_type, output_path=output_path))
           
-          if filtered_vars:  # Only include sections that have used variables
-            filtered_specs[section_key] = {**section_data, "vars": filtered_vars}
+    self.__template_files = template_files
 
-      # Create VariableCollection from filtered specs
-      self.variables = VariableCollection(filtered_specs)
-
-      logger.info(f"Loaded template '{self.id}' (v{self.metadata.version})")
+  def _extract_all_used_variables(self) -> Set[str]:
+    """Extract all undeclared variables from all .j2 files in the template directory."""
+    used_variables: Set[str] = set()
+    for template_file in self.template_files: # Iterate over TemplateFile objects
+      if template_file.file_type == 'j2':
+        file_path = self.template_dir / template_file.relative_path
+        try:
+          with open(file_path, "r", encoding="utf-8") as f:
+            content = f.read()
+            ast = self.jinja_env.parse(content) # Use lazy-loaded jinja_env
+            used_variables.update(meta.find_undeclared_variables(ast))
+        except Exception as e:
+          logger.warning(f"Could not parse Jinja2 variables from {file_path}: {e}")
+    return used_variables
 
-    except ValueError as e:
-      # FIXME: Refactor error handling to avoid redundant catching and re-raising
-      # ValueError already logged in validation method - don't duplicate
-      raise
-    except FileNotFoundError:
-      logger.error(f"Template file not found: {file_path}")
-      raise
-    except Exception as e:
-      logger.error(f"Error loading template from {file_path}: {str(e)}")
-      raise
+  def _filter_specs_to_used(self, used_variables: set, merged_specs: dict, module_specs: dict, template_specs: dict) -> dict:
+    """Filter specs to only include variables used in the templates."""
+    filtered_specs = {}
+    for section_key, section_data in merged_specs.items():
+      if "vars" in section_data and isinstance(section_data["vars"], dict):
+        filtered_vars = {}
+        for var_name, var_data in section_data["vars"].items():
+          if var_name in used_variables:
+            module_has_var = var_name in module_specs.get(section_key, {}).get("vars", {})
+            template_has_var = var_name in template_specs.get(section_key, {}).get("vars", {})
+            
+            if module_has_var and template_has_var:
+              origin = "module -> template"
+            elif template_has_var:
+              origin = "template"
+            else:
+              origin = "module"
+            
+            var_data_with_origin = {**var_data, "origin": origin}
+            
+            filtered_vars[var_name] = var_data_with_origin
+        
+        if filtered_vars:
+          filtered_specs[section_key] = {**section_data, "vars": filtered_vars}
+    return filtered_specs
 
   # ---------------------------
   # SECTION: Validation Methods
   # ---------------------------
 
-  @staticmethod
-  def _extract_jinja_defaults(ast: nodes.Node) -> dict[str, str]:
-    """Extract default values from Jinja2 template variables with default filters."""
-    defaults = {}
-    
-    def visit_node(node):
-      """Recursively visit AST nodes to find default filter usage."""
-      if isinstance(node, nodes.Filter):
-        # Check if this is a 'default' filter
-        if node.name == 'default' and len(node.args) > 0:
-          # Get the variable being filtered
-          if isinstance(node.node, nodes.Name):
-            var_name = node.node.name
-            # Get the default value (first argument to default filter)
-            default_arg = node.args[0]
-            if isinstance(default_arg, nodes.Const):
-              defaults[var_name] = str(default_arg.value)
-            elif isinstance(default_arg, nodes.Name):
-              defaults[var_name] = default_arg.name
-      
-      # Recursively visit child nodes
-      for child in node.iter_child_nodes():
-        visit_node(child)
-    
-    visit_node(ast)
-    return defaults
-
   @staticmethod
   def _validate_kind(post: frontmatter.Post) -> None:
     """Validate that template has required 'kind' field."""
@@ -237,48 +241,32 @@ class Template:
       raise ValueError("Template format error: missing 'kind' field")
 
   def _validate_variable_definitions(self, used_variables: set[str], merged_specs: dict[str, Any]) -> None:
-    """Validate that all variables used in Jinja2 content are defined in the spec.
-    
-    Args:
-      used_variables: Set of variable names found in the Jinja2 template content
-      merged_specs: Combined module and template specifications
-      
-    Raises:
-      ValueError: If any used variables are not defined in the spec
-    """
-    # Collect all defined variables from all sections
+    """Validate that all variables used in Jinja2 content are defined in the spec."""
     defined_variables = set()
     for section_data in merged_specs.values():
       if "vars" in section_data and isinstance(section_data["vars"], dict):
         defined_variables.update(section_data["vars"].keys())
     
-    # Find variables used in template but not defined in spec
     undefined_variables = used_variables - defined_variables
-    
     if undefined_variables:
-      # Sort for consistent error messages
       undefined_list = sorted(undefined_variables)
-      
-      # Create detailed error message
       error_msg = (
-        f"Template validation error in '{self.id}': "
-        f"Variables used in template content but not defined in spec: {undefined_list}\n\n"
-        f"Please add these variables to your template spec or module spec. "
-        f"Example:\n"
-        f"spec:\n"
-        f"  general:\n"
-        f"    vars:\n"
+          f"Template validation error in '{self.id}': "
+          f"Variables used in template content but not defined in spec: {undefined_list}\n\n"
+          f"Please add these variables to your template's template.yaml spec. "
+          f"Each variable must have a default value.\n\n"
+          f"Example:\n"
+          f"spec:\n"
+          f"  general:\n"
+          f"    vars:\n"
       )
-      
-      # Add example spec entries for each undefined variable
       for var_name in undefined_list:
-        error_msg += (
-          f"      {var_name}:\n"
-          f"        type: str\n"
-          f"        description: Description for {var_name}\n"
-          f"        default: \"\"\n"
-        )
-      
+          error_msg += (
+              f"      {var_name}:\n"
+              f"        type: str\n"
+              f"        description: Description for {var_name}\n"
+              f"        default: <your_default_value_here>\n"
+          )
       logger.error(error_msg)
       raise ValueError(error_msg)
 
@@ -289,19 +277,89 @@ class Template:
   # ---------------------------------
 
   @staticmethod
-  def _create_jinja_env() -> Environment:
+  def _create_jinja_env(searchpath: Path) -> Environment:
     """Create standardized Jinja2 environment for consistent template processing."""
     return Environment(
-      loader=BaseLoader(),
+      loader=FileSystemLoader(searchpath),
       trim_blocks=True,
       lstrip_blocks=True,
       keep_trailing_newline=False,
     )
 
-  def render(self, variables: dict[str, Any]) -> str:
-    """Render the template with the given variables."""
+  def render(self, variables: dict[str, Any]) -> Dict[str, str]:
+    """Render all .j2 files in the template directory."""
     logger.debug(f"Rendering template '{self.id}' with variables: {variables}")
-    template = self.jinja_env.from_string(self.content)
-    return template.render(**variables)
+    rendered_files = {}
+    for template_file in self.template_files: # Iterate over TemplateFile objects
+      if template_file.file_type == 'j2':
+        try:
+          template = self.jinja_env.get_template(str(template_file.relative_path)) # Use lazy-loaded jinja_env
+          rendered_content = template.render(**variables)
+          rendered_files[str(template_file.output_path)] = rendered_content
+        except Exception as e:
+          logger.error(f"Error rendering template file {template_file.relative_path}: {e}")
+          raise
+      elif template_file.file_type == 'static':
+          # For static files, just read their content and add to rendered_files
+          # This ensures static files are also part of the output dictionary
+          file_path = self.template_dir / template_file.relative_path
+          try:
+              with open(file_path, "r", encoding="utf-8") as f:
+                  content = f.read()
+                  rendered_files[str(template_file.output_path)] = content
+          except Exception as e:
+              logger.error(f"Error reading static file {file_path}: {e}")
+              raise
+          
+    return rendered_files
   
   # !SECTION
+
+  # ---------------------------
+  # SECTION: Lazy Loaded Properties
+  # ---------------------------
+
+  @property
+  def template_files(self) -> List[TemplateFile]:
+      if self.__template_files is None:
+          self._collect_template_files() # Populate self.__template_files
+      return self.__template_files
+
+  @property
+  def template_specs(self) -> dict:
+      return self._post.metadata.get("spec", {})
+
+  @property
+  def module_specs(self) -> dict:
+      if self.__module_specs is None:
+          kind = self._post.metadata.get("kind")
+          self.__module_specs = self._load_module_specs(kind)
+      return self.__module_specs
+
+  @property
+  def merged_specs(self) -> dict:
+      if self.__merged_specs is None:
+          self.__merged_specs = self._merge_specs(self.module_specs, self.template_specs)
+      return self.__merged_specs
+
+  @property
+  def jinja_env(self) -> Environment:
+      if self.__jinja_env is None:
+          self.__jinja_env = self._create_jinja_env(self.template_dir)
+      return self.__jinja_env
+
+  @property
+  def used_variables(self) -> Set[str]:
+      if self.__used_variables is None:
+          self.__used_variables = self._extract_all_used_variables()
+      return self.__used_variables
+
+  @property
+  def variables(self) -> VariableCollection:
+      if self.__variables is None:
+          # Validate that all used variables are defined
+          self._validate_variable_definitions(self.used_variables, self.merged_specs)
+          # Filter specs to only used variables
+          filtered_specs = self._filter_specs_to_used(self.used_variables, self.merged_specs, self.module_specs, self.template_specs)
+          self.__variables = VariableCollection(filtered_specs)
+      return self.__variables

+ 22 - 1
cli/core/variables.py

@@ -61,6 +61,11 @@ class Variable:
       except ValueError as exc:
         raise ValueError(f"Invalid default for variable '{self.name}': {exc}")
 
+  def validate(self, value: Any) -> None:
+    """Validate a value based on the variable's type and constraints."""
+    if self.type not in ["bool"] and (value is None or value == ""):
+      raise ValueError("value cannot be empty")
+
   # -------------------------
   # SECTION: Type Conversion
   # -------------------------
@@ -340,8 +345,24 @@ class VariableCollection:
       # Log errors but don't stop the process
       logger.warning(f"Some CLI overrides failed: {'; '.join(errors)}")
     
-    return successful_overrides
+  def validate_all(self) -> None:
+    """Validate all variables in the collection, skipping disabled sections."""
+    for section in self._set.values():
+      # Check if the section is disabled by a toggle
+      if section.toggle:
+        toggle_var = section.variables.get(section.toggle)
+        if toggle_var and not toggle_var.get_typed_value():
+          logger.debug(f"Skipping validation for disabled section: '{section.key}'")
+          continue  # Skip this entire section
+
+      for var_name, variable in section.variables.items():
+        try:
+          variable.validate(variable.value)
+        except ValueError as e:
+          raise ValueError(f"Validation failed for variable '{var_name}': {e}") from e
 
   # !SECTION
 
 # !SECTION
+
+# !SECTION

+ 0 - 2
cli/modules/ansible.py

@@ -8,7 +8,5 @@ class AnsibleModule(Module):
   
   name: str = "ansible"
   description: str = "Manage Ansible playbooks and configurations"
-  files: list[str] = ["playbook.yml", "playbook.yaml", "main.yml", "main.yaml", 
-                      "site.yml", "site.yaml"]
 
 registry.register(AnsibleModule)

+ 0 - 13
cli/modules/compose.py

@@ -11,12 +11,10 @@ spec = OrderedDict(
           "service_name": {
             "description": "Service name",
             "type": "str",
-            "default": "",
           },
           "container_name": {
             "description": "Container name",
             "type": "str",
-            "default": "",
           },
           "container_timezone": {
             "description": "Container timezone (e.g., Europe/Berlin)",
@@ -38,7 +36,6 @@ spec = OrderedDict(
           "container_hostname": {
             "description": "Container internal hostname",
             "type": "str",
-            "default": "",
           },
         },
       },
@@ -90,7 +87,6 @@ spec = OrderedDict(
           "traefik_host": {
             "description": "Domain name for your service",
             "type": "hostname",
-            "default": "",
           },
           "traefik_entrypoint": {
             "description": "HTTP entrypoint (non-TLS)",
@@ -110,7 +106,6 @@ spec = OrderedDict(
           "traefik_tls_certresolver": {
             "description": "Traefik certificate resolver name",
             "type": "str",
-            "default": "",
           },
         },
       },
@@ -162,17 +157,14 @@ spec = OrderedDict(
           "database_name": {
             "description": "Database name",
             "type": "str",
-            "default": "",
           },
           "database_user": {
             "description": "Database user",
             "type": "str",
-            "default": "",
           },
           "database_password": {
             "description": "Database password",
             "type": "str",
-            "default": "",
           },
         },
       },
@@ -190,7 +182,6 @@ spec = OrderedDict(
           "email_host": {
             "description": "SMTP server hostname",
             "type": "str",
-            "default": "",
           },
           "email_port": {
             "description": "SMTP server port",
@@ -200,17 +191,14 @@ spec = OrderedDict(
           "email_username": {
             "description": "SMTP username",
             "type": "str",
-            "default": "",
           },
           "email_password": {
             "description": "SMTP password",
             "type": "str",
-            "default": "",
           },
           "email_from": {
             "description": "From email address",
             "type": "str",
-            "default": "",
           },
           "email_use_tls": {
             "description": "Use TLS encryption",
@@ -233,7 +221,6 @@ class ComposeModule(Module):
 
   name = "compose"
   description = "Manage Docker Compose configurations"
-  files = ["compose.yaml", "compose.yml", "docker-compose.yaml", "docker-compose.yml"]
 
 
 registry.register(ComposeModule)

+ 0 - 1
cli/modules/docker.py

@@ -8,7 +8,6 @@ class DockerModule(Module):
   
   name: str = "docker"
   description: str = "Manage Docker configurations and files"
-  files: list[str] = ["Dockerfile", "dockerfile", ".dockerignore"]
 
 # Register the module
 registry.register(DockerModule)

+ 0 - 1
cli/modules/github_actions.py

@@ -8,7 +8,6 @@ class GitHubActionsModule(Module):
   
   name: str = "github-actions"
   description: str = "Manage GitHub Actions workflows"
-  files: list[str] = ["action.yml", "action.yaml", "workflow.yml", "workflow.yaml"]
 
 # Register the module
 registry.register(GitHubActionsModule)

+ 0 - 1
cli/modules/gitlab_ci.py

@@ -8,7 +8,6 @@ class GitLabCIModule(Module):
   
   name: str = "gitlab-ci"
   description: str = "Manage GitLab CI/CD pipelines"
-  files: list[str] = [".gitlab-ci.yml", ".gitlab-ci.yaml", "gitlab-ci.yml", "gitlab-ci.yaml"]
 
 # Register the module
 registry.register(GitLabCIModule)

+ 0 - 1
cli/modules/kestra.py

@@ -8,7 +8,6 @@ class KestraModule(Module):
   
   name: str = "kestra"
   description: str = "Manage Kestra workflows and configurations"
-  files: list[str] = ["inputs.yaml", "variables.yaml", "webhook.yaml", "flow.yml", "flow.yaml"]
 
 # Register the module
 registry.register(KestraModule)

+ 0 - 2
cli/modules/kubernetes.py

@@ -8,8 +8,6 @@ class KubernetesModule(Module):
   
   name: str = "kubernetes"
   description: str = "Manage Kubernetes manifests and configurations"
-  files: list[str] = ["deployment.yml", "deployment.yaml", "service.yml", "service.yaml", 
-                      "manifest.yml", "manifest.yaml", "values.yml", "values.yaml"]
 
 # Register the module
 registry.register(KubernetesModule)

+ 0 - 1
cli/modules/packer.py

@@ -8,7 +8,6 @@ class PackerModule(Module):
   
   name: str = "packer"
   description: str = "Manage Packer templates and configurations"
-  files: list[str] = ["template.pkr.hcl", "build.pkr.hcl", "variables.pkr.hcl", "sources.pkr.hcl"]
 
 # Register the module
 registry.register(PackerModule)

+ 0 - 1
cli/modules/terraform.py

@@ -8,7 +8,6 @@ class TerraformModule(Module):
   
   name: str = "terraform"
   description: str = "Manage Terraform configurations"
-  files: list[str] = ["main.tf", "variables.tf", "outputs.tf", "versions.tf"]
 
 # Register the module
 registry.register(TerraformModule)

+ 0 - 1
cli/modules/vagrant.py

@@ -8,7 +8,6 @@ class VagrantModule(Module):
   
   name: str = "vagrant"
   description: str = "Manage Vagrant configurations and files"
-  files: list[str] = ["Vagrantfile", "vagrantfile"]
 
 # Register the module
 registry.register(VagrantModule)

+ 0 - 37
library/compose/nginx/compose.yaml → library/compose/nginx/compose.yaml.j2

@@ -1,37 +1,3 @@
----
-kind: "compose"
-metadata:
-  name: "Nginx"
-  description: "An open-source web server"
-  version: "0.0.1"
-  date: "2023-10-01"
-  author: "Christian Lempa"
-  tags:
-    - nginx
-    - web
-    - reverse-proxy
-spec:
-  ports:
-    vars:
-      ports_http:
-        description: "HTTP port for nginx service"
-        type: int
-        default: 8080
-      ports_https:
-        description: "HTTPS port for nginx service"
-        type: int
-        default: 8443
-  nginx:
-    vars:
-      nginx_dashboard_enabled:
-        description: "Enable nginx dashboard"
-        type: bool
-        default: false
-      nginx_dashboard_port:
-        description: "Nginx dashboard port"
-        type: int
-        default: 8081
----
 services:
   {{ service_name | default('nginx') }}:
     image: docker.io/library/nginx:1.28.0-alpine
@@ -56,9 +22,6 @@ services:
     ports:
       - "{{ ports_http | default(8080) }}:80"
       - "{{ ports_https | default(8443) }}:443"
-      {% if nginx_dashboard_enabled %}
-      - "{{ nginx_dashboard_port | default(8081) }}:8080"
-      {% endif %}
     {% endif %}
     # volumes:
     #   - ./config/default.conf:/etc/nginx/conf.d/default.conf:ro

+ 26 - 0
library/compose/nginx/template.yaml

@@ -0,0 +1,26 @@
+---
+kind: "compose"
+metadata:
+  name: "Nginx"
+  description: "An open-source web server"
+  version: "0.0.1"
+  date: "2023-10-01"
+  author: "Christian Lempa"
+  tags:
+    - nginx
+    - web
+    - reverse-proxy
+spec:
+  ports:
+    vars:
+      ports_http:
+        description: "HTTP port for nginx service"
+        type: int
+        default: 8080
+      ports_https:
+        description: "HTTPS port for nginx service"
+        type: int
+        default: 8443
+  nginx:
+    vars:
+---