Переглянути джерело

feat: add template schema versioning and variable-level dependencies (#1360)

- Add template schema versioning system with compatibility validation
- Add variable-level dependencies with format 'variable=value'
- Add --all flag to show/generate commands for debugging
- Add storage and config sections to compose module spec
- Bump compose module schema version to 1.1
- Add cli/core/version.py for semantic version comparison
- Add IncompatibleSchemaVersionError exception
- Move CLI version from __main__.py to __init__.py
- Update release workflow to validate version consistency
- Update CHANGELOG.md with new features
- Update AGENTS.md documentation
xcad 4 місяців тому
батько
коміт
0398935b5c

+ 23 - 40
.github/workflows/release-create-cli-release.yaml

@@ -28,54 +28,37 @@ jobs:
           echo "tag=$GITHUB_REF_NAME" >> $GITHUB_OUTPUT
           echo "Extracted version: $VERSION from tag $GITHUB_REF_NAME"
 
-      - name: Update version in pyproject.toml
+      - name: Validate version consistency
         run: |
-          VERSION="${{ steps.version.outputs.version }}"
-          sed -i "s/^version = .*/version = \"$VERSION\"/" pyproject.toml
-          echo "✓ Updated pyproject.toml with version $VERSION"
+          TAG_VERSION="${{ steps.version.outputs.version }}"
 
-      - name: Update version in cli/__main__.py
-        run: |
-          VERSION="${{ steps.version.outputs.version }}"
-          sed -i "s/^__version__ = .*/__version__ = \"$VERSION\"/" cli/__main__.py
-          echo "✓ Updated cli/__main__.py with version $VERSION"
-
-      - name: Verify changes
-        run: |
-          echo "=== pyproject.toml ==="
-          grep "^version" pyproject.toml
-          echo ""
-          echo "=== cli/__main__.py ==="
-          grep "^__version__" cli/__main__.py
-
-      - name: Commit and update tag
-        run: |
-          git config --local user.email "github-actions[bot]@users.noreply.github.com"
-          git config --local user.name "github-actions[bot]"
+          # Extract version from pyproject.toml
+          PYPROJECT_VERSION=$(grep '^version = ' pyproject.toml | sed 's/version = "\(.*\)"/\1/')
 
-          # Add changes
-          git add pyproject.toml cli/__main__.py
+          # Extract version from cli/__init__.py
+          CLI_VERSION=$(grep '^__version__ = ' cli/__init__.py | sed 's/__version__ = "\(.*\)"/\1/')
 
-          # Check if there are changes to commit
-          if git diff --staged --quiet; then
-            echo "No version changes needed"
-          else
-            # Commit the version updates
-            git commit -m "chore: bump version to ${{ steps.version.outputs.version }}"
-
-            # Delete the tag locally and remotely
-            git tag -d ${{ steps.version.outputs.tag }}
-            git push origin :refs/tags/${{ steps.version.outputs.tag }}
-
-            # Recreate the tag pointing to the new commit
-            git tag -a ${{ steps.version.outputs.tag }} -m "Release ${{ steps.version.outputs.tag }}"
+          echo "Tag version:        $TAG_VERSION"
+          echo "pyproject.toml:     $PYPROJECT_VERSION"
+          echo "cli/__init__.py:    $CLI_VERSION"
+          echo ""
 
-            # Push the new tag
-            git push origin ${{ steps.version.outputs.tag }}
+          # Check if all versions match
+          if [ "$TAG_VERSION" != "$PYPROJECT_VERSION" ]; then
+            echo "Error: Tag version ($TAG_VERSION) does not match pyproject.toml version ($PYPROJECT_VERSION)"
+            echo "Please update pyproject.toml to version $TAG_VERSION before creating the release."
+            exit 1
+          fi
 
-            echo "✓ Tag ${{ steps.version.outputs.tag }} updated to point to version bump commit"
+          if [ "$TAG_VERSION" != "$CLI_VERSION" ]; then
+            echo "Error: Tag version ($TAG_VERSION) does not match cli/__init__.py version ($CLI_VERSION)"
+            echo "Please update cli/__init__.py to version $TAG_VERSION before creating the release."
+            exit 1
           fi
 
+          echo "Version consistency check passed"
+          echo "All version strings match: $TAG_VERSION"
+
       - name: Set up Python
         uses: actions/setup-python@v6
         with:

+ 48 - 0
AGENTS.md

@@ -64,6 +64,7 @@ The project is stored in a public GitHub Repository, use issues, and branches fo
 - `cli/core/template.py` - Template Class for parsing, managing and rendering templates
 - `cli/core/variable.py` - Dataclass for Variable (stores variable metadata and values)
 - `cli/core/validators.py` - Semantic validators for template content (Docker Compose, YAML, etc.)
+- `cli/core/version.py` - Version comparison utilities for semantic versioning
 
 ### Modules
 
@@ -144,6 +145,7 @@ Requires `template.yaml` or `template.yml` with metadata and variables:
 ```yaml
 ---
 kind: compose
+schema: "1.0"  # Optional: Defaults to 1.0 if not specified
 metadata:
   name: My Nginx Template
   description: >
@@ -167,6 +169,52 @@ spec:
         default: latest
 ```
 
+### Template Schema Versioning
+
+Templates and modules use schema versioning to ensure compatibility. Each module defines a supported schema version, and templates declare which schema version they use.
+
+```yaml
+---
+kind: compose
+schema: "1.0"  # Defaults to 1.0 if not specified
+metadata:
+  name: My Template
+  version: 1.0.0
+  # ... other metadata fields
+spec:
+  # ... variable specifications
+```
+
+**How It Works:**
+- **Module Schema Version**: Each module (in `cli/modules/*.py`) defines `schema_version` (e.g., "1.0")
+- **Template Schema Version**: Each template declares `schema` at the top level (defaults to "1.0")
+- **Compatibility Check**: Template schema ≤ Module schema → Compatible
+- **Incompatibility**: Template schema > Module schema → `IncompatibleSchemaVersionError`
+
+**Behavior:**
+- Templates without `schema` field default to "1.0" (backward compatible)
+- Old templates (schema 1.0) work with newer modules (schema 1.1)
+- New templates (schema 1.2) fail on older modules (schema 1.1) with clear error
+- Version comparison uses 2-level versioning (major.minor format)
+
+**When to Use:**
+- Increment module schema version when adding new features (new variable types, sections, etc.)
+- Set template schema when using features from a specific schema
+- Example: Template using new variable type added in schema 1.1 should set `schema: "1.1"`
+
+**Module Example:**
+```python
+class ComposeModule(Module):
+  name = "compose"
+  description = "Manage Docker Compose configurations"
+  schema_version = "1.0"  # Current schema version supported
+```
+
+**Version Management:**
+- CLI version is defined in `cli/__init__.py` as `__version__`
+- pyproject.toml version must match `__version__` for releases
+- GitHub release workflow validates version consistency
+
 ### Template Files
 
 - **Jinja2 Templates (`.j2`)**: Rendered by Jinja2, `.j2` extension removed in output. Support `{% include %}` and `{% import %}`.

+ 31 - 3
CHANGELOG.md

@@ -7,7 +7,35 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
 
 ## [Unreleased]
 
-## [0.1.0] - 2025-10-14
+### Added
+- Template Schema Versioning (#1360)
+  - Templates can now declare schema version (defaults to "1.0" for backward compatibility)
+  - Modules validate template compatibility against supported schema version
+  - Incompatible templates show clear error with upgrade instructions
+  - New `cli/core/version.py` module for semantic version comparison
+  - New `IncompatibleSchemaVersionError` exception for version mismatches
+- Variable-level Dependencies
+  - Variables can now have `needs` dependencies with format `variable=value`
+  - Sections support new dependency format: `needs: "variable=value"`
+  - Backward compatible with old section-only dependencies (`needs: "section_name"`)
+  - `is_variable_satisfied()` method added to VariableCollection
+- Show/Generate `--all` flag
+  - Added `--all` flag to `show` and `generate` commands
+  - Shows all variables/sections regardless of needs satisfaction
+  - Useful for debugging and viewing complete template structure
+- Storage Configuration for Docker Compose
+  - New `storage` section in compose module spec
+  - New `config` section in compose module spec
+  - Support for multiple storage backends: local, mount, nfs, glusterfs
+  - Dedicated configuration options for each backend type
+
+### Changed
+- Compose module schema version bumped to "1.1"
+- Traefik TLS section now uses variable-level dependencies (`needs: "traefik_enabled=true"`)
+- Display manager hides sections/variables with unsatisfied needs by default (unless `--all` flag is used)
+- Dependency validation now supports both old (section) and new (variable=value) formats
+
+## [0.0.6] - 2025-01-XX
 
 ### Added
 - Support for required variables independent of section state (#1355)
@@ -31,6 +59,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
 
 Initial public release with core CLI functionality.
 
-[unreleased]: https://github.com/christianlempa/boilerplates/compare/v0.1.0...HEAD
-[0.1.0]: https://github.com/christianlempa/boilerplates/compare/v0.0.4...v0.1.0
+[unreleased]: https://github.com/christianlempa/boilerplates/compare/v0.0.6...HEAD
+[0.0.6]: https://github.com/christianlempa/boilerplates/compare/v0.0.4...v0.0.6
 [0.0.4]: https://github.com/christianlempa/boilerplates/releases/tag/v0.0.4

+ 1 - 1
cli/__init__.py

@@ -2,6 +2,6 @@
 Boilerplates CLI - A sophisticated command-line tool for managing infrastructure boilerplates.
 """
 
-__version__ = "0.0.1"
+__version__ = "0.0.7"
 __author__ = "Christian Lempa"
 __description__ = "CLI tool for managing infrastructure boilerplates"

+ 1 - 3
cli/__main__.py

@@ -16,11 +16,9 @@ from rich.console import Console
 import cli.modules
 from cli.core.registry import registry
 from cli.core import repo
+from cli import __version__
 # Using standard Python exceptions instead of custom ones
 
-# NOTE: Placeholder version - will be overwritten by release script (.github/workflows/release.yaml)
-__version__ = "0.0.0"
-
 app = Typer(
   help="CLI tool for managing infrastructure boilerplates.\n\n[dim]Easily generate, customize, and deploy templates for Docker Compose, Terraform, Kubernetes, and more.\n\n [white]Made with 💜 by [bold]Christian Lempa[/bold]",
   add_completion=True,

+ 156 - 33
cli/core/collection.py

@@ -142,21 +142,113 @@ class VariableCollection:
         f"but is type '{toggle_var.type}'"
       )
   
+  @staticmethod
+  def _parse_need(need_str: str) -> tuple[str, Optional[Any]]:
+    """Parse a need string into variable name and expected value.
+    
+    Supports two formats:
+    1. New format: "variable_name=value" - checks if variable equals value
+    2. Old format (backwards compatibility): "section_name" - checks if section is enabled
+    
+    Args:
+        need_str: Need specification string
+        
+    Returns:
+        Tuple of (variable_or_section_name, expected_value)
+        For old format, expected_value is None (means check section enabled)
+        For new format, expected_value is the string value after '='
+    
+    Examples:
+        "traefik_enabled=true" -> ("traefik_enabled", "true")
+        "storage_mode=nfs" -> ("storage_mode", "nfs")
+        "traefik" -> ("traefik", None)  # Old format: section name
+    """
+    if '=' in need_str:
+      # New format: variable=value
+      parts = need_str.split('=', 1)
+      return (parts[0].strip(), parts[1].strip())
+    else:
+      # Old format: section name (backwards compatibility)
+      return (need_str.strip(), None)
+  
+  def _is_need_satisfied(self, need_str: str) -> bool:
+    """Check if a single need condition is satisfied.
+    
+    Args:
+        need_str: Need specification ("variable=value" or "section_name")
+        
+    Returns:
+        True if need is satisfied, False otherwise
+    """
+    var_or_section, expected_value = self._parse_need(need_str)
+    
+    if expected_value is None:
+      # Old format: check if section is enabled (backwards compatibility)
+      section = self._sections.get(var_or_section)
+      if not section:
+        logger.warning(f"Need references missing section '{var_or_section}'")
+        return False
+      return section.is_enabled()
+    else:
+      # New format: check if variable has expected value
+      variable = self._variable_map.get(var_or_section)
+      if not variable:
+        logger.warning(f"Need references missing variable '{var_or_section}'")
+        return False
+      
+      # Convert both values for comparison
+      try:
+        actual_value = variable.convert(variable.value)
+        # Convert expected value using variable's type
+        expected_converted = variable.convert(expected_value)
+        
+        # Handle boolean comparisons specially
+        if variable.type == "bool":
+          return bool(actual_value) == bool(expected_converted)
+        
+        # String comparison for other types
+        return str(actual_value) == str(expected_converted) if actual_value is not None else False
+      except Exception as e:
+        logger.debug(f"Failed to compare need '{need_str}': {e}")
+        return False
+  
   def _validate_dependencies(self) -> None:
     """Validate section dependencies for cycles and missing references.
     
     Raises:
         ValueError: If circular dependencies or missing section references are found
     """
-    # Check for missing dependencies
+    # Check for missing dependencies in sections
     for section_key, section in self._sections.items():
       for dep in section.needs:
-        if dep not in self._sections:
-          raise ValueError(
-            f"Section '{section_key}' depends on '{dep}', but '{dep}' does not exist"
-          )
+        var_or_section, expected_value = self._parse_need(dep)
+        
+        if expected_value is None:
+          # Old format: validate section exists
+          if var_or_section not in self._sections:
+            raise ValueError(
+              f"Section '{section_key}' depends on '{var_or_section}', but '{var_or_section}' does not exist"
+            )
+        else:
+          # New format: validate variable exists
+          if var_or_section not in self._variable_map:
+            raise ValueError(
+              f"Section '{section_key}' has need '{dep}', but variable '{var_or_section}' does not exist"
+            )
+    
+    # Check for missing dependencies in variables
+    for var_name, variable in self._variable_map.items():
+      for dep in variable.needs:
+        dep_var, expected_value = self._parse_need(dep)
+        if expected_value is not None:  # Only validate new format
+          if dep_var not in self._variable_map:
+            raise ValueError(
+              f"Variable '{var_name}' has need '{dep}', but variable '{dep_var}' does not exist"
+            )
     
     # Check for circular dependencies using depth-first search
+    # Note: Only checks section-level dependencies in old format (section names)
+    # Variable-level dependencies (variable=value) don't create cycles in the same way
     visited = set()
     rec_stack = set()
     
@@ -166,14 +258,18 @@ class VariableCollection:
       
       section = self._sections[section_key]
       for dep in section.needs:
-        if dep not in visited:
-          if has_cycle(dep):
-            return True
-        elif dep in rec_stack:
-          raise ValueError(
-            f"Circular dependency detected: '{section_key}' depends on '{dep}', "
-            f"which creates a cycle"
-          )
+        # Only check circular deps for old format (section references)
+        dep_name, expected_value = self._parse_need(dep)
+        if expected_value is None and dep_name in self._sections:
+          # Old format section dependency - check for cycles
+          if dep_name not in visited:
+            if has_cycle(dep_name):
+              return True
+          elif dep_name in rec_stack:
+            raise ValueError(
+              f"Circular dependency detected: '{section_key}' depends on '{dep_name}', "
+              f"which creates a cycle"
+            )
       
       rec_stack.remove(section_key)
       return False
@@ -185,9 +281,9 @@ class VariableCollection:
   def is_section_satisfied(self, section_key: str) -> bool:
     """Check if all dependencies for a section are satisfied.
     
-    A dependency is satisfied if:
-    1. The dependency section exists
-    2. The dependency section is enabled (if it has a toggle)
+    Supports both formats:
+    - Old format: "section_name" - checks if section is enabled (backwards compatible)
+    - New format: "variable=value" - checks if variable has specific value
     
     Args:
         section_key: The key of the section to check
@@ -203,16 +299,38 @@ class VariableCollection:
     if not section.needs:
       return True
     
-    # Check each dependency
-    for dep_key in section.needs:
-      dep_section = self._sections.get(dep_key)
-      if not dep_section:
-        logger.warning(f"Section '{section_key}' depends on missing section '{dep_key}'")
+    # Check each dependency using the unified need satisfaction logic
+    for need in section.needs:
+      if not self._is_need_satisfied(need):
+        logger.debug(f"Section '{section_key}' need '{need}' is not satisfied")
         return False
-      
-      # Check if dependency is enabled
-      if not dep_section.is_enabled():
-        logger.debug(f"Section '{section_key}' dependency '{dep_key}' is disabled")
+    
+    return True
+  
+  def is_variable_satisfied(self, var_name: str) -> bool:
+    """Check if all dependencies for a variable are satisfied.
+    
+    A variable is satisfied if all its needs are met.
+    Needs are specified as "variable_name=value".
+    
+    Args:
+        var_name: The name of the variable to check
+        
+    Returns:
+        True if all dependencies are satisfied, False otherwise
+    """
+    variable = self._variable_map.get(var_name)
+    if not variable:
+      return False
+    
+    # No dependencies = always satisfied
+    if not variable.needs:
+      return True
+    
+    # Check each dependency
+    for need in variable.needs:
+      if not self._is_need_satisfied(need):
+        logger.debug(f"Variable '{var_name}' need '{need}' is not satisfied")
         return False
     
     return True
@@ -502,13 +620,16 @@ class VariableCollection:
     merged_section = self_section.clone()
     
     # Update section metadata from other (other takes precedence)
+    # Only override if explicitly provided in other AND has a value
     for attr in ('title', 'description', 'toggle'):
-      if getattr(other_section, attr):
-        setattr(merged_section, attr, getattr(other_section, attr))
+      other_value = getattr(other_section, attr)
+      if hasattr(other_section, '_explicit_fields') and attr in other_section._explicit_fields and other_value:
+        setattr(merged_section, attr, other_value)
     
     merged_section.required = other_section.required
-    if other_section.needs:
-      merged_section.needs = other_section.needs.copy()
+    # Respect explicit clears for dependencies (explicit null/empty clears, missing field preserves)
+    if hasattr(other_section, '_explicit_fields') and 'needs' in other_section._explicit_fields:
+      merged_section.needs = other_section.needs.copy() if other_section.needs else []
     
     # Merge variables
     for var_name, other_var in other_section.variables.items():
@@ -527,13 +648,15 @@ class VariableCollection:
           'extra': other_var.extra
         }
         
-        # Add fields that were explicitly provided and have values
+        # Add fields that were explicitly provided, even if falsy/empty
         for field, value in field_map.items():
-          if field in other_var._explicit_fields and value:
+          if field in other_var._explicit_fields:
             update[field] = value
         
-        # Special handling for value/default
-        if ('value' in other_var._explicit_fields or 'default' in other_var._explicit_fields) and other_var.value is not None:
+        # Special handling for value/default (allow explicit null to clear)
+        if 'value' in other_var._explicit_fields:
+          update['value'] = other_var.value
+        elif 'default' in other_var._explicit_fields:
           update['value'] = other_var.value
         
         merged_section.variables[var_name] = self_var.clone(update=update)

+ 53 - 16
cli/core/display.py

@@ -218,11 +218,17 @@ class DisplayManager:
 
         console.print(table)
 
-    def display_template_details(self, template: Template, template_id: str) -> None:
-        """Display template information panel and variables table."""
+    def display_template_details(self, template: Template, template_id: str, show_all: bool = False) -> None:
+        """Display template information panel and variables table.
+        
+        Args:
+            template: Template instance to display
+            template_id: ID of the template
+            show_all: If True, show all variables/sections regardless of needs satisfaction
+        """
         self._display_template_header(template, template_id)
         self._display_file_tree(template)
-        self._display_variables_table(template)
+        self._display_variables_table(template, show_all=show_all)
 
     def display_section_header(self, title: str, description: str | None) -> None:
         """Display a section header."""
@@ -288,6 +294,30 @@ class DisplayManager:
     def display_info(self, message: str, context: str | None = None) -> None:
         """Display an informational message."""
         self.display_message('info', message, context)
+    
+    def display_version_incompatibility(self, template_id: str, required_version: str, current_version: str) -> None:
+        """Display a version incompatibility error with upgrade instructions.
+        
+        Args:
+            template_id: ID of the incompatible template
+            required_version: Minimum CLI version required by template
+            current_version: Current CLI version
+        """
+        console_err.print()
+        console_err.print(f"[bold red]{IconManager.STATUS_ERROR} Version Incompatibility[/bold red]")
+        console_err.print()
+        console_err.print(f"Template '[cyan]{template_id}[/cyan]' requires CLI version [green]{required_version}[/green] or higher.")
+        console_err.print(f"Current CLI version: [yellow]{current_version}[/yellow]")
+        console_err.print()
+        console_err.print("[bold]Upgrade Instructions:[/bold]")
+        console_err.print(f"  {IconManager.UI_ARROW_RIGHT} Run: [cyan]pip install --upgrade boilerplates[/cyan]")
+        console_err.print(f"  {IconManager.UI_ARROW_RIGHT} Or install specific version: [cyan]pip install boilerplates=={required_version}[/cyan]")
+        console_err.print()
+        
+        logger.error(
+            f"Template '{template_id}' requires CLI version {required_version}, "
+            f"current version is {current_version}"
+        )
 
     def _display_template_header(self, template: Template, template_id: str) -> None:
         """Display the header for a template with library information."""
@@ -365,8 +395,13 @@ class DisplayManager:
         if file_tree.children:
             console.print(file_tree)
 
-    def _display_variables_table(self, template: Template) -> None:
-        """Display a table of variables for a template."""
+    def _display_variables_table(self, template: Template, show_all: bool = False) -> None:
+        """Display a table of variables for a template.
+        
+        Args:
+            template: Template instance
+            show_all: If True, show all variables/sections regardless of needs satisfaction
+        """
         if not (template.variables and template.variables.has_sections()):
             return
 
@@ -383,6 +418,10 @@ class DisplayManager:
         for section in template.variables.get_sections().values():
             if not section.variables:
                 continue
+            
+            # Skip sections with unsatisfied needs unless show_all is True
+            if not show_all and not template.variables.is_section_satisfied(section.key):
+                continue
 
             if not first_section:
                 variables_table.add_row("", "", "", "", style="bright_black")
@@ -394,27 +433,25 @@ class DisplayManager:
             is_dimmed = not (is_enabled and dependencies_satisfied)
 
             # Only show (disabled) if section has no dependencies (dependencies make it obvious)
-            disabled_text = " (disabled)" if (is_dimmed and not section.needs) else ""
+            # Empty list means no dependencies (same as None)
+            has_dependencies = section.needs and len(section.needs) > 0
+            disabled_text = " (disabled)" if (is_dimmed and not has_dependencies) else ""
             
             # For disabled sections, make entire heading bold and dim (don't include colored markup inside)
             if is_dimmed:
                 # Build text without internal markup, then wrap entire thing in bold bright_black (dimmed appearance)
                 required_part = " (required)" if section.required else ""
-                needs_part = ""
-                if section.needs:
-                    needs_list = ", ".join(section.needs)
-                    needs_part = f" (needs: {needs_list})"
-                header_text = f"[bold bright_black]{section.title}{required_part}{needs_part}{disabled_text}[/bold bright_black]"
+                header_text = f"[bold bright_black]{section.title}{required_part}{disabled_text}[/bold bright_black]"
             else:
                 # For enabled sections, include the colored markup
                 required_text = " [yellow](required)[/yellow]" if section.required else ""
-                needs_text = ""
-                if section.needs:
-                    needs_list = ", ".join(section.needs)
-                    needs_text = f" [dim](needs: {needs_list})[/dim]"
-                header_text = f"[bold]{section.title}{required_text}{needs_text}{disabled_text}[/bold]"
+                header_text = f"[bold]{section.title}{required_text}{disabled_text}[/bold]"
             variables_table.add_row(header_text, "", "", "")
             for var_name, variable in section.variables.items():
+                # Skip variables with unsatisfied needs unless show_all is True
+                if not show_all and not template.variables.is_variable_satisfied(var_name):
+                    continue
+                
                 row_style = "bright_black" if is_dimmed else None
                 
                 # Build default value display

+ 18 - 0
cli/core/exceptions.py

@@ -71,6 +71,24 @@ class TemplateValidationError(TemplateError):
     pass
 
 
+class IncompatibleSchemaVersionError(TemplateError):
+    """Raised when a template uses a schema version not supported by the module."""
+    
+    def __init__(self, template_id: str, template_schema: str, module_schema: str, module_name: str):
+        self.template_id = template_id
+        self.template_schema = template_schema
+        self.module_schema = module_schema
+        self.module_name = module_name
+        msg = (
+            f"Template '{template_id}' uses schema version {template_schema}, "
+            f"but module '{module_name}' only supports up to version {module_schema}.\n\n"
+            f"This template requires features not available in your current CLI version.\n"
+            f"Please upgrade the boilerplates CLI.\n\n"
+            f"Run: pip install --upgrade boilerplates"
+        )
+        super().__init__(msg)
+
+
 class TemplateRenderError(TemplateError):
     """Raised when template rendering fails."""
     

+ 25 - 5
cli/core/module.py

@@ -58,6 +58,9 @@ def parse_var_inputs(var_options: List[str], extra_args: List[str]) -> Dict[str,
 
 class Module(ABC):
   """Streamlined base module that auto-detects variables from templates."""
+  
+  # Schema version supported by this module (override in subclasses)
+  schema_version: str = "1.0"
 
   def __init__(self) -> None:
     if not all([self.name, self.description]):
@@ -92,6 +95,9 @@ class Module(ABC):
         
         template = Template(template_dir, library_name=library_name, library_type=library_type)
         
+        # Validate schema version compatibility
+        template._validate_schema_version(self.schema_version, self.name)
+        
         # If template ID needs qualification, set qualified ID
         if needs_qualification:
           template.set_qualified_id()
@@ -148,6 +154,9 @@ class Module(ABC):
         
         template = Template(template_dir, library_name=library_name, library_type=library_type)
         
+        # Validate schema version compatibility
+        template._validate_schema_version(self.schema_version, self.name)
+        
         # If template ID needs qualification, set qualified ID
         if needs_qualification:
           template.set_qualified_id()
@@ -177,6 +186,7 @@ class Module(ABC):
   def show(
     self,
     id: str,
+    all_vars: bool = Option(False, "--all", help="Show all variables/sections, even those with unsatisfied needs"),
   ) -> None:
     """Show template details."""
     logger.debug(f"Showing template '{id}' from module '{self.name}'")
@@ -203,7 +213,7 @@ class Module(ABC):
       # Re-sort sections after applying config (toggle values may have changed)
       template.variables.sort_sections()
     
-    self._display_template_details(template, id)
+    self._display_template_details(template, id, show_all=all_vars)
 
   def _apply_variable_defaults(self, template: Template) -> None:
     """Apply config defaults and CLI overrides to template variables.
@@ -496,6 +506,7 @@ class Module(ABC):
     dry_run: bool = Option(False, "--dry-run", help="Preview template generation without writing files"),
     show_files: bool = Option(False, "--show-files", help="Display generated file contents in plain text (use with --dry-run)"),
     quiet: bool = Option(False, "--quiet", "-q", help="Suppress all non-error output"),
+    all_vars: bool = Option(False, "--all", help="Show all variables/sections, even those with unsatisfied needs"),
   ) -> None:
     """Generate from template.
     
@@ -537,7 +548,7 @@ class Module(ABC):
       template.variables.sort_sections()
 
     if not quiet:
-      self._display_template_details(template, id)
+      self._display_template_details(template, id, show_all=all_vars)
       console.print()
 
     # Collect variable values
@@ -1024,6 +1035,9 @@ class Module(ABC):
     try:
       template = Template(template_dir, library_name=library_name, library_type=library_type)
       
+      # Validate schema version compatibility
+      template._validate_schema_version(self.schema_version, self.name)
+      
       # If the original ID was qualified, preserve it
       if '.' in id:
         template.id = id
@@ -1033,6 +1047,12 @@ class Module(ABC):
       logger.error(f"Failed to load template '{id}': {exc}")
       raise FileNotFoundError(f"Template '{id}' could not be loaded: {exc}") from exc
 
-  def _display_template_details(self, template: Template, id: str) -> None:
-    """Display template information panel and variables table."""
-    self.display.display_template_details(template, id)
+  def _display_template_details(self, template: Template, id: str, show_all: bool = False) -> None:
+    """Display template information panel and variables table.
+    
+    Args:
+        template: Template instance to display
+        id: Template ID
+        show_all: If True, show all variables/sections regardless of needs satisfaction
+    """
+    self.display.display_template_details(template, id, show_all=show_all)

+ 2 - 0
cli/core/section.py

@@ -29,6 +29,8 @@ class VariableSection:
     self.variables: OrderedDict[str, Variable] = OrderedDict()
     self.description: Optional[str] = data.get("description")
     self.toggle: Optional[str] = data.get("toggle")
+    # Track which fields were explicitly provided (to support explicit clears)
+    self._explicit_fields: set[str] = set(data.keys())
     # Default "general" section to required=True, all others to required=False
     self.required: bool = data.get("required", data["key"] == "general")
     # Section dependencies - can be string or list of strings

+ 39 - 1
cli/core/template.py

@@ -9,8 +9,10 @@ from .exceptions import (
     TemplateValidationError,
     TemplateRenderError,
     YAMLParseError,
-    ModuleLoadError
+    ModuleLoadError,
+    IncompatibleSchemaVersionError
 )
+from .version import is_compatible
 from pathlib import Path
 from typing import Any, Dict, List, Set, Optional, Literal
 from dataclasses import dataclass, field
@@ -321,6 +323,12 @@ class Template:
 
       # Validate 'kind' field (always needed)
       self._validate_kind(self._template_data)
+      
+      # Extract schema version (default to 1.0 for backward compatibility)
+      self.schema_version = str(self._template_data.get("schema", "1.0"))
+      logger.debug(f"Template schema version: {self.schema_version}")
+      
+      # Note: Schema version validation is done by the module when loading templates
 
       # NOTE: File collection is now lazy-loaded via the template_files property
       # This significantly improves performance when listing many templates
@@ -544,6 +552,36 @@ class Template:
     
     return filtered_specs
 
+  def _validate_schema_version(self, module_schema: str, module_name: str) -> None:
+    """Validate that template schema version is supported by the module.
+    
+    Args:
+        module_schema: Schema version supported by the module
+        module_name: Name of the module (for error messages)
+    
+    Raises:
+        IncompatibleSchemaVersionError: If template schema > module schema
+    """
+    template_schema = self.schema_version
+    
+    # Compare schema versions
+    if not is_compatible(module_schema, template_schema):
+      logger.error(
+        f"Template '{self.id}' uses schema version {template_schema}, "
+        f"but module '{module_name}' only supports up to {module_schema}"
+      )
+      raise IncompatibleSchemaVersionError(
+        template_id=self.id,
+        template_schema=template_schema,
+        module_schema=module_schema,
+        module_name=module_name
+      )
+    
+    logger.debug(
+      f"Template '{self.id}' schema version compatible: "
+      f"template uses {template_schema}, module supports {module_schema}"
+    )
+  
   @staticmethod
   def _validate_kind(template_data: dict) -> None:
     """Validate that template has required 'kind' field.

+ 22 - 1
cli/core/variable.py

@@ -41,7 +41,12 @@ class Variable:
     self.type: str = data.get("type", "str")
     self.options: Optional[List[Any]] = data.get("options", [])
     self.prompt: Optional[str] = data.get("prompt")
-    self.value: Any = data.get("value") if data.get("value") is not None else data.get("default")
+    if "value" in data:
+      self.value: Any = data.get("value")
+    elif "default" in data:
+      self.value: Any = data.get("default")
+    else:
+      self.value: Any = None
     self.origin: Optional[str] = data.get("origin")
     self.sensitive: bool = data.get("sensitive", False)
     # Optional extra explanation used by interactive prompts
@@ -52,6 +57,17 @@ class Variable:
     self.required: bool = data.get("required", False)
     # Original value before config override (used for display)
     self.original_value: Optional[Any] = data.get("original_value")
+    # Variable dependencies - can be string or list of strings in format "var_name=value"
+    needs_value = data.get("needs")
+    if needs_value:
+      if isinstance(needs_value, str):
+        self.needs: List[str] = [needs_value]
+      elif isinstance(needs_value, list):
+        self.needs: List[str] = needs_value
+      else:
+        raise ValueError(f"Variable '{self.name}' has invalid 'needs' value: must be string or list")
+    else:
+      self.needs: List[str] = []
 
     # Validate and convert the default/initial value if present
     if self.value is not None:
@@ -219,6 +235,10 @@ class Variable:
     if self.options is not None:  # Allow empty list
       result['options'] = self.options
     
+    # Store dependencies (single value if only one, list otherwise)
+    if self.needs:
+      result['needs'] = self.needs[0] if len(self.needs) == 1 else self.needs
+    
     return result
   
   def get_display_value(self, mask_sensitive: bool = True, max_length: int = 30, show_none: bool = True) -> str:
@@ -373,6 +393,7 @@ class Variable:
       'autogenerated': self.autogenerated,
       'required': self.required,
       'original_value': self.original_value,
+      'needs': self.needs.copy() if self.needs else None,
     }
     
     # Apply updates if provided

+ 110 - 0
cli/core/version.py

@@ -0,0 +1,110 @@
+"""Version comparison utilities for semantic versioning.
+
+This module provides utilities for parsing and comparing semantic version strings.
+Supports version strings in the format: major.minor (e.g., "1.0", "1.2")
+"""
+
+from __future__ import annotations
+
+import re
+from typing import Tuple
+import logging
+
+logger = logging.getLogger(__name__)
+
+
+def parse_version(version_str: str) -> Tuple[int, int]:
+  """Parse a semantic version string into a tuple of integers.
+  
+  Args:
+      version_str: Version string in format "major.minor" (e.g., "1.0", "1.2")
+      
+  Returns:
+      Tuple of (major, minor) as integers
+      
+  Raises:
+      ValueError: If version string is not in valid semantic version format
+      
+  Examples:
+      >>> parse_version("1.0")
+      (1, 0)
+      >>> parse_version("1.2")
+      (1, 2)
+  """
+  if not version_str:
+    raise ValueError("Version string cannot be empty")
+
+  # Remove 'v' prefix if present
+  version_str = version_str.lstrip('v')
+
+  # Match semantic version pattern: major.minor
+  pattern = r'^(\d+)\.(\d+)$'
+  match = re.match(pattern, version_str)
+
+  if not match:
+    raise ValueError(
+      f"Invalid version format '{version_str}'. "
+      "Expected format: major.minor (e.g., '1.0', '1.2')"
+    )
+
+  major, minor = match.groups()
+  return (int(major), int(minor))
+
+
+def compare_versions(version1: str, version2: str) -> int:
+  """Compare two semantic version strings.
+  
+  Args:
+      version1: First version string
+      version2: Second version string
+      
+  Returns:
+      -1 if version1 < version2
+       0 if version1 == version2
+       1 if version1 > version2
+       
+  Raises:
+      ValueError: If either version string is invalid
+      
+  Examples:
+      >>> compare_versions("1.0", "0.9")
+      1
+      >>> compare_versions("1.0", "1.0")
+      0
+      >>> compare_versions("1.0", "1.1")
+      -1
+  """
+  v1 = parse_version(version1)
+  v2 = parse_version(version2)
+
+  if v1 < v2:
+    return -1
+  if v1 > v2:
+    return 1
+  return 0
+
+
+def is_compatible(current_version: str, required_version: str) -> bool:
+  """Check if current version meets the minimum required version.
+  
+  Args:
+      current_version: Current version
+      required_version: Minimum required version
+      
+  Returns:
+      True if current_version >= required_version, False otherwise
+      
+  Examples:
+      >>> is_compatible("1.0", "0.9")
+      True
+      >>> is_compatible("1.0", "1.0")
+      True
+      >>> is_compatible("1.0", "1.1")
+      False
+  """
+  try:
+    return compare_versions(current_version, required_version) >= 0
+  except ValueError as e:
+    logger.warning("Version compatibility check failed: %s", e)
+    # If we can't parse versions, assume incompatible for safety
+    return False

+ 88 - 2
cli/modules/compose.py

@@ -106,11 +106,11 @@ spec = OrderedDict(
             "default": "web",
           },
         },
-      },
+      },  
       "traefik_tls": {
         "title": "Traefik TLS/SSL",
         "toggle": "traefik_tls_enabled",
-        "needs": "traefik",
+        "needs": "traefik_enabled=true",
         "description": "Enable HTTPS/TLS for Traefik with certificate management.",
         "vars": {
           "traefik_tls_enabled": {
@@ -274,6 +274,91 @@ spec = OrderedDict(
           },
         },
       },
+      "storage": {
+        "title": "Storage Configuration",
+        "toggle": "storage_enabled",
+        "description": "Configure persistent storage volumes",
+        "vars": {
+          "storage_enabled": {
+            "description": "Enable storage configuration",
+            "type": "bool",
+            "default": False,
+          },
+          "storage_mode": {
+            "description": "Storage backend",
+            "type": "enum",
+            "options": ["local", "mount", "nfs", "glusterfs"],
+            "default": "local",
+            "extra": "local=named volume, mount=bind mount, nfs=network filesystem, glusterfs=distributed storage",
+          },
+          "storage_host": {
+            "description": "Storage host/volume identifier",
+            "type": "str",
+            "extra": "local: volume name, mount: host path, nfs: server IP, glusterfs: server hostname",
+          },
+          "storage_path": {
+            "description": "NFS export path",
+            "type": "str",
+            "default": "/mnt/nfs",
+            "extra": "Only used when storage_mode=nfs",
+          },
+          "storage_nfs_options": {
+            "description": "NFS mount options",
+            "type": "str",
+            "default": "rw,nolock,soft",
+            "extra": "Only used when storage_mode=nfs. Comma-separated options.",
+          },
+          "storage_glusterfs_volume": {
+            "description": "GlusterFS volume name",
+            "type": "str",
+            "default": "gv0",
+            "extra": "Only used when storage_mode=glusterfs",
+          },
+        },
+      },
+      "config": {
+        "title": "Config Storage",
+        "toggle": "config_enabled",
+        "description": "Configure persistent storage for configuration files",
+        "vars": {
+          "config_enabled": {
+            "description": "Enable config storage configuration",
+            "type": "bool",
+            "default": False,
+          },
+          "config_mode": {
+            "description": "Storage backend for configuration",
+            "type": "enum",
+            "options": ["local", "mount", "nfs", "glusterfs"],
+            "default": "mount",
+            "extra": "local=named volume, mount=bind mount, nfs=network filesystem, glusterfs=distributed storage",
+          },
+          "config_host": {
+            "description": "Config storage host/volume identifier",
+            "type": "str",
+            "default": "./config",
+            "extra": "local: volume name, mount: host path, nfs: server IP, glusterfs: server hostname",
+          },
+          "config_path": {
+            "description": "NFS export path for config",
+            "type": "str",
+            "default": "/mnt/nfs/config",
+            "extra": "Only used when config_mode=nfs",
+          },
+          "config_nfs_options": {
+            "description": "NFS mount options for config",
+            "type": "str",
+            "default": "rw,nolock,soft",
+            "extra": "Only used when config_mode=nfs. Comma-separated options.",
+          },
+          "config_glusterfs_volume": {
+            "description": "GlusterFS volume name for config",
+            "type": "str",
+            "default": "gv0",
+            "extra": "Only used when config_mode=glusterfs",
+          },
+        },
+      },
     }
   )
 
@@ -283,6 +368,7 @@ class ComposeModule(Module):
 
   name = "compose"
   description = "Manage Docker Compose configurations"
+  schema_version = "1.1"  # Current schema version supported by this module
 
 
 registry.register(ComposeModule)

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

@@ -88,7 +88,7 @@ spec:
   traefik_tls:
     title: "Traefik TLS Settings"
     description: "Configure TLS/SSL with Let's Encrypt ACME"
-    needs: "traefik"
+    needs: null
     vars:
       traefik_tls_enabled:
         type: "bool"

+ 10 - 1
pyproject.toml

@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
 
 [project]
 name = "boilerplates"
-version = "0.0.0"  # NOTE: Placeholder - will be overwritten by release script
+version = "0.0.7"
 description = "CLI tool for managing infrastructure boilerplates"
 readme = "README.md"
 requires-python = ">=3.9"
@@ -30,3 +30,12 @@ boilerplates = "cli.__main__:run"
 [tool.setuptools.packages.find]
 include = ["cli*"]
 exclude = ["tests*", "scripts*"]
+
+[tool.pylint.format]
+indent-string = '  '
+max-line-length = 120
+
+[tool.pylint.messages_control]
+disable = [
+    "bad-indentation",
+]