xcad 8 months ago
parent
commit
a862bc157f

+ 15 - 3
CHANGELOG.md

@@ -7,18 +7,30 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
 
 ## [Unreleased]
 
+## [0.1.0] - 2025-10-14
+
+### Added
+- Support for required variables independent of section state (#1355)
+  - Variables can now be marked with `required: true` in template specs
+  - Required variables are always prompted, validated, and included in rendering
+  - Display shows yellow `(required)` indicator for required variables
+  - Required variables from disabled sections are still collected and available
+
 ### Changed
 - Improved error handling and display output consistency
 - Updated dependency PyYAML to v6.0.3 (Python 3.14 compatibility)
 - Updated dependency rich to v14.2.0 (Python 3.14 compatibility)
 
 ### Fixed
-- Repository fetch fails when library directory already exists
-- Install script reliability improvements
+- Absolute paths without leading slash treated as relative paths in generate command (#1357)
+  - Paths like `Users/xcad/Projects/test` are now correctly normalized to `/Users/xcad/Projects/test`
+  - Supports common Unix/Linux root directories: Users/, home/, usr/, opt/, var/, tmp/
+- Repository fetch fails when library directory already exists (#1279)
 
 ## [0.0.4] - 2025-01-XX
 
 Initial public release with core CLI functionality.
 
-[unreleased]: https://github.com/christianlempa/boilerplates/compare/v0.0.4...HEAD
+[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
 [0.0.4]: https://github.com/christianlempa/boilerplates/releases/tag/v0.0.4

+ 5 - 0
cli/__main__.py

@@ -91,6 +91,11 @@ def main(
   ctx.ensure_object(dict)
   ctx.obj['log_level'] = log_level
   
+  # Check for local config.yaml and show indicator
+  local_config = Path.cwd() / "config.yaml"
+  if local_config.exists() and local_config.is_file():
+    console.print(f"[dim]→ Using local config: config.yaml[/dim]")
+  
   # If no subcommand is provided, show help and friendly intro
   if ctx.invoked_subcommand is None:
     console.print(ctx.get_help())

+ 36 - 14
cli/core/collection.py

@@ -303,8 +303,9 @@ class VariableCollection:
     """Get variable values only from sections with satisfied dependencies.
     
     This respects both toggle states and section dependencies, ensuring that:
-    - Variables from disabled sections (toggle=false) are excluded
+    - Variables from disabled sections (toggle=false) are excluded EXCEPT required variables
     - Variables from sections with unsatisfied dependencies are excluded
+    - Required variables are always included if their section dependencies are satisfied
     
     Returns:
         Dictionary of variable names to values for satisfied sections only
@@ -312,19 +313,25 @@ class VariableCollection:
     satisfied_values = {}
     
     for section_key, section in self._sections.items():
-      # Skip sections with unsatisfied dependencies
+      # Skip sections with unsatisfied dependencies (even required variables need satisfied deps)
       if not self.is_section_satisfied(section_key):
         logger.debug(f"Excluding variables from section '{section_key}' - dependencies not satisfied")
         continue
       
-      # Skip disabled sections (toggle check)
-      if not section.is_enabled():
-        logger.debug(f"Excluding variables from section '{section_key}' - section is disabled")
-        continue
+      # Check if section is enabled
+      is_enabled = section.is_enabled()
       
-      # Include all variables from this satisfied section
-      for var_name, variable in section.variables.items():
-        satisfied_values[var_name] = variable.convert(variable.value)
+      if is_enabled:
+        # Include all variables from enabled section
+        for var_name, variable in section.variables.items():
+          satisfied_values[var_name] = variable.convert(variable.value)
+      else:
+        # Section is disabled - only include required variables
+        logger.debug(f"Section '{section_key}' is disabled - including only required variables")
+        for var_name, variable in section.variables.items():
+          if variable.required:
+            logger.debug(f"Including required variable '{var_name}' from disabled section '{section_key}'")
+            satisfied_values[var_name] = variable.convert(variable.value)
     
     return satisfied_values
 
@@ -381,17 +388,32 @@ class VariableCollection:
     return successful
   
   def validate_all(self) -> None:
-    """Validate all variables in the collection, skipping disabled and unsatisfied sections."""
+    """Validate all variables in the collection.
+    
+    Validates:
+    - All variables in enabled sections with satisfied dependencies
+    - Required variables even if their section is disabled (but dependencies must be satisfied)
+    """
     errors: list[str] = []
 
     for section_key, section in self._sections.items():
-      # Skip sections with unsatisfied dependencies or disabled via toggle
-      if not self.is_section_satisfied(section_key) or not section.is_enabled():
-        logger.debug(f"Skipping validation for section '{section_key}'")
+      # Skip sections with unsatisfied dependencies (even for required variables)
+      if not self.is_section_satisfied(section_key):
+        logger.debug(f"Skipping validation for section '{section_key}' - dependencies not satisfied")
         continue
+      
+      # Check if section is enabled
+      is_enabled = section.is_enabled()
+      
+      if not is_enabled:
+        logger.debug(f"Section '{section_key}' is disabled - validating only required variables")
 
-      # Validate each variable in the section
+      # Validate variables in the section
       for var_name, variable in section.variables.items():
+        # Skip non-required variables in disabled sections
+        if not is_enabled and not variable.required:
+          continue
+        
         try:
           # Skip autogenerated variables when empty
           if variable.autogenerated and not variable.value:

+ 31 - 9
cli/core/config.py

@@ -37,19 +37,33 @@ class ConfigManager:
         """Initialize the configuration manager.
         
         Args:
-            config_path: Path to the configuration file. If None, uses default location.
+            config_path: Path to the configuration file. If None, auto-detects:
+                        1. Checks for ./config.yaml (local project config)
+                        2. Falls back to ~/.config/boilerplates/config.yaml (global config)
         """
         if config_path is None:
-            # Default to ~/.config/boilerplates/config.yaml
-            config_dir = Path.home() / ".config" / "boilerplates"
-            config_dir.mkdir(parents=True, exist_ok=True)
-            self.config_path = config_dir / "config.yaml"
+            # Check for local config.yaml in current directory first
+            local_config = Path.cwd() / "config.yaml"
+            if local_config.exists() and local_config.is_file():
+                self.config_path = local_config
+                self.is_local = True
+                logger.info(f"Using local config: {local_config}")
+            else:
+                # Fall back to global config
+                config_dir = Path.home() / ".config" / "boilerplates"
+                config_dir.mkdir(parents=True, exist_ok=True)
+                self.config_path = config_dir / "config.yaml"
+                self.is_local = False
         else:
             self.config_path = Path(config_path)
+            self.is_local = False
         
-        # Create default config if it doesn't exist
+        # Create default config if it doesn't exist (only for global config)
         if not self.config_path.exists():
-            self._create_default_config()
+            if not self.is_local:
+                self._create_default_config()
+            else:
+                raise ConfigError(f"Local config file not found: {self.config_path}")
         else:
             # Migrate existing config if needed
             self._migrate_config_if_needed()
@@ -411,12 +425,20 @@ class ConfigManager:
                     raise ConfigValidationError(f"Library 'enabled' at index {i} must be a boolean")
     
     def get_config_path(self) -> Path:
-        """Get the path to the configuration file.
+        """Get the path to the configuration file being used.
         
         Returns:
-            Path to the configuration file.
+            Path to the configuration file (global or local).
         """
         return self.config_path
+    
+    def is_using_local_config(self) -> bool:
+        """Check if a local configuration file is being used.
+        
+        Returns:
+            True if using local config, False if using global config.
+        """
+        return self.is_local
 
     def get_defaults(self, module_name: str) -> Dict[str, Any]:
         """Get default variable values for a module.

+ 3 - 1
cli/core/display.py

@@ -444,7 +444,9 @@ class DisplayManager:
                 
                 # Add lock icon for sensitive variables
                 sensitive_icon = f" {IconManager.lock()}" if variable.sensitive else ""
-                var_display = f"  {var_name}{sensitive_icon}"
+                # Add required indicator for required variables
+                required_indicator = " [yellow](required)[/yellow]" if variable.required else ""
+                var_display = f"  {var_name}{sensitive_icon}{required_indicator}"
 
                 variables_table.add_row(
                     var_display,

+ 9 - 1
cli/core/module.py

@@ -560,7 +560,15 @@ class Module(ABC):
       logger.info(f"Successfully rendered template '{id}'")
       
       # Determine output directory
-      output_dir = Path(directory) if directory else Path(id)
+      if directory:
+        output_dir = Path(directory)
+        # Check if path looks like an absolute path but is missing the leading slash
+        # This handles cases like "Users/username/path" which should be "/Users/username/path"
+        if not output_dir.is_absolute() and str(output_dir).startswith(("Users/", "home/", "usr/", "opt/", "var/", "tmp/")):
+          output_dir = Path("/") / output_dir
+          logger.debug(f"Normalized relative-looking absolute path to: {output_dir}")
+      else:
+        output_dir = Path(id)
       
       # Check for conflicts and get confirmation (skip in quiet mode)
       if not quiet:

+ 15 - 2
cli/core/prompt.py

@@ -34,6 +34,7 @@ class PromptHandler:
       return {}
 
     collected: Dict[str, Any] = {}
+    prompted_variables: set[str] = set()  # Track which variables we've already prompted for
 
     # Process each section
     for section_key, section in variables.get_sections().items():
@@ -60,6 +61,9 @@ class PromptHandler:
       # Always show section header first
       self.display.display_section_header(section.title, section.description)
 
+      # Track whether this section will be enabled
+      section_will_be_enabled = True
+      
       # Handle section toggle - skip for required sections
       if section.required:
         # Required sections are always processed, no toggle prompt needed
@@ -78,18 +82,27 @@ class PromptHandler:
           
           # Use section's native is_enabled() method
           if not section.is_enabled():
-            continue
+            section_will_be_enabled = False
 
       # Collect variables in this section
       for var_name, variable in section.variables.items():
         # Skip toggle variable (already handled)
         if section.toggle and var_name == section.toggle:
           continue
-          
+        
+        # Skip non-required variables if section is disabled
+        if not section_will_be_enabled and not variable.required:
+          logger.debug(f"Skipping non-required variable '{var_name}' from disabled section '{section_key}'")
+          continue
+        
+        # Prompt for the variable
         current_value = variable.convert(variable.value)
         # Pass section.required so _prompt_variable can enforce required inputs
         new_value = self._prompt_variable(variable, required=section.required)
         
+        # Track that we've prompted for this variable
+        prompted_variables.add(var_name)
+        
         # For autogenerated variables, always update even if None (signals autogeneration)
         if variable.autogenerated and new_value is None:
           collected[var_name] = None

+ 16 - 3
cli/core/variable.py

@@ -48,6 +48,8 @@ class Variable:
     self.extra: Optional[str] = data.get("extra")
     # Flag indicating this variable should be auto-generated when empty
     self.autogenerated: bool = data.get("autogenerated", False)
+    # Flag indicating this variable is required even when section is disabled
+    self.required: bool = data.get("required", False)
     # Original value before config override (used for display)
     self.original_value: Optional[Any] = data.get("original_value")
 
@@ -212,6 +214,8 @@ class Variable:
       result['sensitive'] = True
     if self.autogenerated:
       result['autogenerated'] = True
+    if self.required:
+      result['required'] = True
     if self.options is not None:  # Allow empty list
       result['options'] = self.options
     
@@ -312,13 +316,21 @@ class Variable:
     """Check if this variable requires a value (cannot be empty/None).
     
     A variable is considered required if:
-    - It doesn't have a default value (value is None)
-    - It's not marked as autogenerated (which can be empty and generated later)
-    - It's not a boolean type (booleans default to False if not set)
+    - It has an explicit 'required: true' flag (highest precedence)
+    - OR it doesn't have a default value (value is None)
+      AND it's not marked as autogenerated (which can be empty and generated later)
+      AND it's not a boolean type (booleans default to False if not set)
     
     Returns:
         True if the variable must have a non-empty value, False otherwise
     """
+    # Explicit required flag takes highest precedence
+    if self.required:
+      # But autogenerated variables can still be empty (will be generated later)
+      if self.autogenerated:
+        return False
+      return True
+    
     # Autogenerated variables can be empty (will be generated later)
     if self.autogenerated:
       return False
@@ -359,6 +371,7 @@ class Variable:
       'sensitive': self.sensitive,
       'extra': self.extra,
       'autogenerated': self.autogenerated,
+      'required': self.required,
       'original_value': self.original_value,
     }
     

+ 1 - 1
library/compose/gitlab/config/gitlab.rb.j2

@@ -2,7 +2,7 @@
 external_url '{{ external_url }}'
 
 # GitLab Shell SSH settings
-gitlab_rails['gitlab_shell_ssh_port'] = {{ ssh_port }}
+gitlab_rails['gitlab_shell_ssh_port'] = {{ ports_ssh }}
 
 # Let's Encrypt and built-in TLS settings are currently not supported by the template
 # as we are using Traefik as a reverse proxy

+ 1 - 0
library/compose/gitlab/template.yaml

@@ -62,6 +62,7 @@ spec:
         type: int
         description: SSH port
         default: 2424
+        required: true
       ports_registry:
         type: int
         description: Container Registry port