Bladeren bron

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 8 maanden geleden
bovenliggende
commit
0398935b5c

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

@@ -28,54 +28,37 @@ jobs:
           echo "tag=$GITHUB_REF_NAME" >> $GITHUB_OUTPUT
           echo "tag=$GITHUB_REF_NAME" >> $GITHUB_OUTPUT
           echo "Extracted version: $VERSION from tag $GITHUB_REF_NAME"
           echo "Extracted version: $VERSION from tag $GITHUB_REF_NAME"
 
 
-      - name: Update version in pyproject.toml
+      - name: Validate version consistency
         run: |
         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
           fi
 
 
+          echo "Version consistency check passed"
+          echo "All version strings match: $TAG_VERSION"
+
       - name: Set up Python
       - name: Set up Python
         uses: actions/setup-python@v6
         uses: actions/setup-python@v6
         with:
         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/template.py` - Template Class for parsing, managing and rendering templates
 - `cli/core/variable.py` - Dataclass for Variable (stores variable metadata and values)
 - `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/validators.py` - Semantic validators for template content (Docker Compose, YAML, etc.)
+- `cli/core/version.py` - Version comparison utilities for semantic versioning
 
 
 ### Modules
 ### Modules
 
 
@@ -144,6 +145,7 @@ Requires `template.yaml` or `template.yml` with metadata and variables:
 ```yaml
 ```yaml
 ---
 ---
 kind: compose
 kind: compose
+schema: "1.0"  # Optional: Defaults to 1.0 if not specified
 metadata:
 metadata:
   name: My Nginx Template
   name: My Nginx Template
   description: >
   description: >
@@ -167,6 +169,52 @@ spec:
         default: latest
         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
 ### Template Files
 
 
 - **Jinja2 Templates (`.j2`)**: Rendered by Jinja2, `.j2` extension removed in output. Support `{% include %}` and `{% import %}`.
 - **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]
 ## [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
 ### Added
 - Support for required variables independent of section state (#1355)
 - 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.
 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
 [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.
 Boilerplates CLI - A sophisticated command-line tool for managing infrastructure boilerplates.
 """
 """
 
 
-__version__ = "0.0.1"
+__version__ = "0.0.7"
 __author__ = "Christian Lempa"
 __author__ = "Christian Lempa"
 __description__ = "CLI tool for managing infrastructure boilerplates"
 __description__ = "CLI tool for managing infrastructure boilerplates"

+ 1 - 3
cli/__main__.py

@@ -16,11 +16,9 @@ from rich.console import Console
 import cli.modules
 import cli.modules
 from cli.core.registry import registry
 from cli.core.registry import registry
 from cli.core import repo
 from cli.core import repo
+from cli import __version__
 # Using standard Python exceptions instead of custom ones
 # 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(
 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]",
   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,
   add_completion=True,

+ 156 - 33
cli/core/collection.py

@@ -142,21 +142,113 @@ class VariableCollection:
         f"but is type '{toggle_var.type}'"
         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:
   def _validate_dependencies(self) -> None:
     """Validate section dependencies for cycles and missing references.
     """Validate section dependencies for cycles and missing references.
     
     
     Raises:
     Raises:
         ValueError: If circular dependencies or missing section references are found
         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 section_key, section in self._sections.items():
       for dep in section.needs:
       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
     # 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()
     visited = set()
     rec_stack = set()
     rec_stack = set()
     
     
@@ -166,14 +258,18 @@ class VariableCollection:
       
       
       section = self._sections[section_key]
       section = self._sections[section_key]
       for dep in section.needs:
       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)
       rec_stack.remove(section_key)
       return False
       return False
@@ -185,9 +281,9 @@ class VariableCollection:
   def is_section_satisfied(self, section_key: str) -> bool:
   def is_section_satisfied(self, section_key: str) -> bool:
     """Check if all dependencies for a section are satisfied.
     """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:
     Args:
         section_key: The key of the section to check
         section_key: The key of the section to check
@@ -203,16 +299,38 @@ class VariableCollection:
     if not section.needs:
     if not section.needs:
       return True
       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
         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 False
     
     
     return True
     return True
@@ -502,13 +620,16 @@ class VariableCollection:
     merged_section = self_section.clone()
     merged_section = self_section.clone()
     
     
     # Update section metadata from other (other takes precedence)
     # 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'):
     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
     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
     # Merge variables
     for var_name, other_var in other_section.variables.items():
     for var_name, other_var in other_section.variables.items():
@@ -527,13 +648,15 @@ class VariableCollection:
           'extra': other_var.extra
           '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():
         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
             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
           update['value'] = other_var.value
         
         
         merged_section.variables[var_name] = self_var.clone(update=update)
         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)
         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_template_header(template, template_id)
         self._display_file_tree(template)
         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:
     def display_section_header(self, title: str, description: str | None) -> None:
         """Display a section header."""
         """Display a section header."""
@@ -288,6 +294,30 @@ class DisplayManager:
     def display_info(self, message: str, context: str | None = None) -> None:
     def display_info(self, message: str, context: str | None = None) -> None:
         """Display an informational message."""
         """Display an informational message."""
         self.display_message('info', message, context)
         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:
     def _display_template_header(self, template: Template, template_id: str) -> None:
         """Display the header for a template with library information."""
         """Display the header for a template with library information."""
@@ -365,8 +395,13 @@ class DisplayManager:
         if file_tree.children:
         if file_tree.children:
             console.print(file_tree)
             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()):
         if not (template.variables and template.variables.has_sections()):
             return
             return
 
 
@@ -383,6 +418,10 @@ class DisplayManager:
         for section in template.variables.get_sections().values():
         for section in template.variables.get_sections().values():
             if not section.variables:
             if not section.variables:
                 continue
                 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:
             if not first_section:
                 variables_table.add_row("", "", "", "", style="bright_black")
                 variables_table.add_row("", "", "", "", style="bright_black")
@@ -394,27 +433,25 @@ class DisplayManager:
             is_dimmed = not (is_enabled and dependencies_satisfied)
             is_dimmed = not (is_enabled and dependencies_satisfied)
 
 
             # Only show (disabled) if section has no dependencies (dependencies make it obvious)
             # 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)
             # For disabled sections, make entire heading bold and dim (don't include colored markup inside)
             if is_dimmed:
             if is_dimmed:
                 # Build text without internal markup, then wrap entire thing in bold bright_black (dimmed appearance)
                 # Build text without internal markup, then wrap entire thing in bold bright_black (dimmed appearance)
                 required_part = " (required)" if section.required else ""
                 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:
             else:
                 # For enabled sections, include the colored markup
                 # For enabled sections, include the colored markup
                 required_text = " [yellow](required)[/yellow]" if section.required else ""
                 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, "", "", "")
             variables_table.add_row(header_text, "", "", "")
             for var_name, variable in section.variables.items():
             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
                 row_style = "bright_black" if is_dimmed else None
                 
                 
                 # Build default value display
                 # Build default value display

+ 18 - 0
cli/core/exceptions.py

@@ -71,6 +71,24 @@ class TemplateValidationError(TemplateError):
     pass
     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):
 class TemplateRenderError(TemplateError):
     """Raised when template rendering fails."""
     """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):
 class Module(ABC):
   """Streamlined base module that auto-detects variables from templates."""
   """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:
   def __init__(self) -> None:
     if not all([self.name, self.description]):
     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)
         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 template ID needs qualification, set qualified ID
         if needs_qualification:
         if needs_qualification:
           template.set_qualified_id()
           template.set_qualified_id()
@@ -148,6 +154,9 @@ class Module(ABC):
         
         
         template = Template(template_dir, library_name=library_name, library_type=library_type)
         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 template ID needs qualification, set qualified ID
         if needs_qualification:
         if needs_qualification:
           template.set_qualified_id()
           template.set_qualified_id()
@@ -177,6 +186,7 @@ class Module(ABC):
   def show(
   def show(
     self,
     self,
     id: str,
     id: str,
+    all_vars: bool = Option(False, "--all", help="Show all variables/sections, even those with unsatisfied needs"),
   ) -> None:
   ) -> None:
     """Show template details."""
     """Show template details."""
     logger.debug(f"Showing template '{id}' from module '{self.name}'")
     logger.debug(f"Showing template '{id}' from module '{self.name}'")
@@ -203,7 +213,7 @@ class Module(ABC):
       # Re-sort sections after applying config (toggle values may have changed)
       # Re-sort sections after applying config (toggle values may have changed)
       template.variables.sort_sections()
       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:
   def _apply_variable_defaults(self, template: Template) -> None:
     """Apply config defaults and CLI overrides to template variables.
     """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"),
     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)"),
     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"),
     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:
   ) -> None:
     """Generate from template.
     """Generate from template.
     
     
@@ -537,7 +548,7 @@ class Module(ABC):
       template.variables.sort_sections()
       template.variables.sort_sections()
 
 
     if not quiet:
     if not quiet:
-      self._display_template_details(template, id)
+      self._display_template_details(template, id, show_all=all_vars)
       console.print()
       console.print()
 
 
     # Collect variable values
     # Collect variable values
@@ -1024,6 +1035,9 @@ class Module(ABC):
     try:
     try:
       template = Template(template_dir, library_name=library_name, library_type=library_type)
       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 the original ID was qualified, preserve it
       if '.' in id:
       if '.' in id:
         template.id = id
         template.id = id
@@ -1033,6 +1047,12 @@ class Module(ABC):
       logger.error(f"Failed to load template '{id}': {exc}")
       logger.error(f"Failed to load template '{id}': {exc}")
       raise FileNotFoundError(f"Template '{id}' could not be loaded: {exc}") from 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.variables: OrderedDict[str, Variable] = OrderedDict()
     self.description: Optional[str] = data.get("description")
     self.description: Optional[str] = data.get("description")
     self.toggle: Optional[str] = data.get("toggle")
     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
     # Default "general" section to required=True, all others to required=False
     self.required: bool = data.get("required", data["key"] == "general")
     self.required: bool = data.get("required", data["key"] == "general")
     # Section dependencies - can be string or list of strings
     # Section dependencies - can be string or list of strings

+ 39 - 1
cli/core/template.py

@@ -9,8 +9,10 @@ from .exceptions import (
     TemplateValidationError,
     TemplateValidationError,
     TemplateRenderError,
     TemplateRenderError,
     YAMLParseError,
     YAMLParseError,
-    ModuleLoadError
+    ModuleLoadError,
+    IncompatibleSchemaVersionError
 )
 )
+from .version import is_compatible
 from pathlib import Path
 from pathlib import Path
 from typing import Any, Dict, List, Set, Optional, Literal
 from typing import Any, Dict, List, Set, Optional, Literal
 from dataclasses import dataclass, field
 from dataclasses import dataclass, field
@@ -321,6 +323,12 @@ class Template:
 
 
       # Validate 'kind' field (always needed)
       # Validate 'kind' field (always needed)
       self._validate_kind(self._template_data)
       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
       # NOTE: File collection is now lazy-loaded via the template_files property
       # This significantly improves performance when listing many templates
       # This significantly improves performance when listing many templates
@@ -544,6 +552,36 @@ class Template:
     
     
     return filtered_specs
     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
   @staticmethod
   def _validate_kind(template_data: dict) -> None:
   def _validate_kind(template_data: dict) -> None:
     """Validate that template has required 'kind' field.
     """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.type: str = data.get("type", "str")
     self.options: Optional[List[Any]] = data.get("options", [])
     self.options: Optional[List[Any]] = data.get("options", [])
     self.prompt: Optional[str] = data.get("prompt")
     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.origin: Optional[str] = data.get("origin")
     self.sensitive: bool = data.get("sensitive", False)
     self.sensitive: bool = data.get("sensitive", False)
     # Optional extra explanation used by interactive prompts
     # Optional extra explanation used by interactive prompts
@@ -52,6 +57,17 @@ class Variable:
     self.required: bool = data.get("required", False)
     self.required: bool = data.get("required", False)
     # Original value before config override (used for display)
     # Original value before config override (used for display)
     self.original_value: Optional[Any] = data.get("original_value")
     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
     # Validate and convert the default/initial value if present
     if self.value is not None:
     if self.value is not None:
@@ -219,6 +235,10 @@ class Variable:
     if self.options is not None:  # Allow empty list
     if self.options is not None:  # Allow empty list
       result['options'] = self.options
       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
     return result
   
   
   def get_display_value(self, mask_sensitive: bool = True, max_length: int = 30, show_none: bool = True) -> str:
   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,
       'autogenerated': self.autogenerated,
       'required': self.required,
       'required': self.required,
       'original_value': self.original_value,
       'original_value': self.original_value,
+      'needs': self.needs.copy() if self.needs else None,
     }
     }
     
     
     # Apply updates if provided
     # 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",
             "default": "web",
           },
           },
         },
         },
-      },
+      },  
       "traefik_tls": {
       "traefik_tls": {
         "title": "Traefik TLS/SSL",
         "title": "Traefik TLS/SSL",
         "toggle": "traefik_tls_enabled",
         "toggle": "traefik_tls_enabled",
-        "needs": "traefik",
+        "needs": "traefik_enabled=true",
         "description": "Enable HTTPS/TLS for Traefik with certificate management.",
         "description": "Enable HTTPS/TLS for Traefik with certificate management.",
         "vars": {
         "vars": {
           "traefik_tls_enabled": {
           "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"
   name = "compose"
   description = "Manage Docker Compose configurations"
   description = "Manage Docker Compose configurations"
+  schema_version = "1.1"  # Current schema version supported by this module
 
 
 
 
 registry.register(ComposeModule)
 registry.register(ComposeModule)

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

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

+ 10 - 1
pyproject.toml

@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
 
 
 [project]
 [project]
 name = "boilerplates"
 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"
 description = "CLI tool for managing infrastructure boilerplates"
 readme = "README.md"
 readme = "README.md"
 requires-python = ">=3.9"
 requires-python = ">=3.9"
@@ -30,3 +30,12 @@ boilerplates = "cli.__main__:run"
 [tool.setuptools.packages.find]
 [tool.setuptools.packages.find]
 include = ["cli*"]
 include = ["cli*"]
 exclude = ["tests*", "scripts*"]
 exclude = ["tests*", "scripts*"]
+
+[tool.pylint.format]
+indent-string = '  '
+max-line-length = 120
+
+[tool.pylint.messages_control]
+disable = [
+    "bad-indentation",
+]