xcad 4 månader sedan
förälder
incheckning
3c176ed45a
5 ändrade filer med 669 tillägg och 11 borttagningar
  1. 12 2
      cli/__main__.py
  2. 239 1
      cli/core/config.py
  3. 48 8
      cli/core/library.py
  4. 369 0
      cli/core/repo.py
  5. 1 0
      library/compose/traefik/template.yaml

+ 12 - 2
cli/__main__.py

@@ -15,6 +15,7 @@ from typer import Typer, Context, Option
 from rich.console import Console
 import cli.modules
 from cli.core.registry import registry
+from cli.core import repo
 # Using standard Python exceptions instead of custom ones
 
 # NOTE: Placeholder version - will be overwritten by release script (.github/workflows/release.yaml)
@@ -122,9 +123,18 @@ def init_app() -> None:
           failed_imports.append(error_info)
           logger.error(error_info)
     
-    # Register modules with app lazily
+    # Register core repo command
+    try:
+      logger.debug("Registering repo command")
+      repo.register_cli(app)
+    except Exception as e:
+      error_info = f"Repo command registration failed: {str(e)}"
+      failed_registrations.append(error_info)
+      logger.warning(error_info)
+    
+    # Register template-based modules with app
     module_classes = list(registry.iter_module_classes())
-    logger.debug(f"Registering {len(module_classes)} discovered modules")
+    logger.debug(f"Registering {len(module_classes)} template-based modules")
     
     for name, module_cls in module_classes:
       try:

+ 239 - 1
cli/core/config.py

@@ -50,6 +50,9 @@ class ConfigManager:
         # Create default config if it doesn't exist
         if not self.config_path.exists():
             self._create_default_config()
+        else:
+            # Migrate existing config if needed
+            self._migrate_config_if_needed()
     
     def _create_default_config(self) -> None:
         """Create a default configuration file."""
@@ -59,11 +62,47 @@ class ConfigManager:
                 "editor": "vim",
                 "output_dir": None,
                 "library_paths": []
-            }
+            },
+            "libraries": [
+                {
+                    "name": "default",
+                    "url": "https://github.com/christianlempa/boilerplates.git",
+                    "branch": "main",
+                    "directory": "library",
+                    "enabled": True
+                }
+            ]
         }
         self._write_config(default_config)
         logger.info(f"Created default configuration at {self.config_path}")
     
+    def _migrate_config_if_needed(self) -> None:
+        """Migrate existing config to add missing sections like libraries."""
+        try:
+            config = self._read_config()
+            needs_migration = False
+            
+            # Add libraries section if missing
+            if "libraries" not in config:
+                logger.info("Migrating config: adding libraries section")
+                config["libraries"] = [
+                    {
+                        "name": "default",
+                        "url": "https://github.com/christianlempa/boilerplates.git",
+                        "branch": "refactor/boilerplates-v2",
+                        "directory": "library",
+                        "enabled": True
+                    }
+                ]
+                needs_migration = True
+            
+            # Write back if migration was needed
+            if needs_migration:
+                self._write_config(config)
+                logger.info("Config migration completed")
+        except Exception as e:
+            logger.warning(f"Config migration failed: {e}")
+    
     @staticmethod
     def _validate_string_length(value: str, field_name: str, max_length: int = MAX_STRING_LENGTH) -> None:
         """Validate string length to prevent DOS attacks.
@@ -301,6 +340,40 @@ class ConfigManager:
                     if not isinstance(path, str):
                         raise ConfigValidationError(f"Library path must be a string, got {type(path).__name__}")
                     self._validate_path_string(path, f"Library path at index {i}")
+        
+        # Validate libraries structure
+        if "libraries" in config:
+            libraries = config["libraries"]
+            
+            if not isinstance(libraries, list):
+                raise ConfigValidationError("'libraries' must be a list")
+            
+            self._validate_list_length(libraries, "Libraries list")
+            
+            for i, library in enumerate(libraries):
+                if not isinstance(library, dict):
+                    raise ConfigValidationError(f"Library at index {i} must be a dictionary")
+                
+                # Validate required fields
+                required_fields = ["name", "url", "directory"]
+                for field in required_fields:
+                    if field not in library:
+                        raise ConfigValidationError(f"Library at index {i} missing required field '{field}'")
+                    
+                    if not isinstance(library[field], str):
+                        raise ConfigValidationError(f"Library '{field}' at index {i} must be a string")
+                    
+                    self._validate_string_length(library[field], f"Library '{field}' at index {i}", max_length=500)
+                
+                # Validate optional branch field
+                if "branch" in library:
+                    if not isinstance(library["branch"], str):
+                        raise ConfigValidationError(f"Library 'branch' at index {i} must be a string")
+                    self._validate_string_length(library["branch"], f"Library 'branch' at index {i}", max_length=200)
+                
+                # Validate optional enabled field
+                if "enabled" in library and not isinstance(library["enabled"], bool):
+                    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.
@@ -532,3 +605,168 @@ class ConfigManager:
         """
         config = self._read_config()
         return config.get("preferences", {})
+    
+    def get_libraries(self) -> list[Dict[str, Any]]:
+        """Get all configured libraries.
+        
+        Returns:
+            List of library configurations
+        """
+        config = self._read_config()
+        return config.get("libraries", [])
+    
+    def get_library_by_name(self, name: str) -> Optional[Dict[str, Any]]:
+        """Get a specific library by name.
+        
+        Args:
+            name: Name of the library
+            
+        Returns:
+            Library configuration dictionary or None if not found
+        """
+        libraries = self.get_libraries()
+        for library in libraries:
+            if library.get("name") == name:
+                return library
+        return None
+    
+    def add_library(self, name: str, url: str, directory: str = "library", branch: str = "main", enabled: bool = True) -> None:
+        """Add a new library to the configuration.
+        
+        Args:
+            name: Unique name for the library
+            url: Git repository URL
+            directory: Directory within the repo containing templates
+            branch: Git branch to use
+            enabled: Whether the library is enabled
+            
+        Raises:
+            ConfigValidationError: If library with the same name already exists or validation fails
+        """
+        # Validate inputs
+        if not isinstance(name, str) or not name:
+            raise ConfigValidationError("Library name must be a non-empty string")
+        
+        self._validate_string_length(name, "Library name", max_length=100)
+        
+        if not isinstance(url, str) or not url:
+            raise ConfigValidationError("Library URL must be a non-empty string")
+        
+        self._validate_string_length(url, "Library URL", max_length=500)
+        
+        if not isinstance(directory, str) or not directory:
+            raise ConfigValidationError("Library directory must be a non-empty string")
+        
+        self._validate_string_length(directory, "Library directory", max_length=200)
+        
+        if not isinstance(branch, str) or not branch:
+            raise ConfigValidationError("Library branch must be a non-empty string")
+        
+        self._validate_string_length(branch, "Library branch", max_length=200)
+        
+        # Check if library already exists
+        if self.get_library_by_name(name):
+            raise ConfigValidationError(f"Library '{name}' already exists")
+        
+        config = self._read_config()
+        
+        if "libraries" not in config:
+            config["libraries"] = []
+        
+        config["libraries"].append({
+            "name": name,
+            "url": url,
+            "branch": branch,
+            "directory": directory,
+            "enabled": enabled
+        })
+        
+        self._write_config(config)
+        logger.info(f"Added library '{name}'")
+    
+    def remove_library(self, name: str) -> None:
+        """Remove a library from the configuration.
+        
+        Args:
+            name: Name of the library to remove
+            
+        Raises:
+            ConfigError: If library is not found
+        """
+        config = self._read_config()
+        libraries = config.get("libraries", [])
+        
+        # Find and remove the library
+        new_libraries = [lib for lib in libraries if lib.get("name") != name]
+        
+        if len(new_libraries) == len(libraries):
+            raise ConfigError(f"Library '{name}' not found")
+        
+        config["libraries"] = new_libraries
+        self._write_config(config)
+        logger.info(f"Removed library '{name}'")
+    
+    def update_library(self, name: str, **kwargs: Any) -> None:
+        """Update a library's configuration.
+        
+        Args:
+            name: Name of the library to update
+            **kwargs: Fields to update (url, branch, directory, enabled)
+            
+        Raises:
+            ConfigError: If library is not found
+            ConfigValidationError: If validation fails
+        """
+        config = self._read_config()
+        libraries = config.get("libraries", [])
+        
+        # Find the library
+        library_found = False
+        for library in libraries:
+            if library.get("name") == name:
+                library_found = True
+                
+                # Update allowed fields
+                if "url" in kwargs:
+                    url = kwargs["url"]
+                    if not isinstance(url, str) or not url:
+                        raise ConfigValidationError("Library URL must be a non-empty string")
+                    self._validate_string_length(url, "Library URL", max_length=500)
+                    library["url"] = url
+                
+                if "branch" in kwargs:
+                    branch = kwargs["branch"]
+                    if not isinstance(branch, str) or not branch:
+                        raise ConfigValidationError("Library branch must be a non-empty string")
+                    self._validate_string_length(branch, "Library branch", max_length=200)
+                    library["branch"] = branch
+                
+                if "directory" in kwargs:
+                    directory = kwargs["directory"]
+                    if not isinstance(directory, str) or not directory:
+                        raise ConfigValidationError("Library directory must be a non-empty string")
+                    self._validate_string_length(directory, "Library directory", max_length=200)
+                    library["directory"] = directory
+                
+                if "enabled" in kwargs:
+                    enabled = kwargs["enabled"]
+                    if not isinstance(enabled, bool):
+                        raise ConfigValidationError("Library enabled must be a boolean")
+                    library["enabled"] = enabled
+                
+                break
+        
+        if not library_found:
+            raise ConfigError(f"Library '{name}' not found")
+        
+        config["libraries"] = libraries
+        self._write_config(config)
+        logger.info(f"Updated library '{name}'")
+    
+    def get_libraries_path(self) -> Path:
+        """Get the path to the libraries directory.
+        
+        Returns:
+            Path to the libraries directory (same directory as config file)
+        """
+        return self.config_path.parent / "libraries"

+ 48 - 8
cli/core/library.py

@@ -119,15 +119,55 @@ class Library:
 class LibraryManager:
   """Manages multiple libraries and provides methods to find templates."""
   
-  # FIXME: For now this is static and only has one library
   def __init__(self) -> None:
-
-    # get the root path of the repository
-    repo_root = Path(__file__).parent.parent.parent.resolve()
-
-    self.libraries = [
-      Library(name="default", path=repo_root / "library", priority=0)
-    ]
+    """Initialize LibraryManager with git-based libraries from config."""
+    from .config import ConfigManager
+    
+    self.config = ConfigManager()
+    self.libraries = self._load_libraries_from_config()
+  
+  def _load_libraries_from_config(self) -> list[Library]:
+    """Load libraries from configuration.
+    
+    Returns:
+        List of Library instances
+    """
+    libraries = []
+    libraries_path = self.config.get_libraries_path()
+    
+    # Get library configurations from config
+    library_configs = self.config.get_libraries()
+    
+    for i, lib_config in enumerate(library_configs):
+      # Skip disabled libraries
+      if not lib_config.get("enabled", True):
+        logger.debug(f"Skipping disabled library: {lib_config.get('name')}")
+        continue
+      
+      name = lib_config.get("name")
+      
+      # Build path to library: ~/.config/boilerplates/libraries/{name}/
+      # The 'directory' config is just metadata about the repo structure,
+      # the actual cloned repo is always at libraries/{name}/
+      library_path = libraries_path / name
+      
+      # Check if library path exists
+      if not library_path.exists():
+        logger.warning(
+          f"Library '{name}' not found at {library_path}. "
+          f"Run 'repo update' to sync libraries."
+        )
+        continue
+      
+      # Create Library instance with priority based on order (first = highest priority)
+      priority = len(library_configs) - i
+      libraries.append(Library(name=name, path=library_path, priority=priority))
+      logger.debug(f"Loaded library '{name}' from {library_path} with priority {priority}")
+    
+    if not libraries:
+      logger.warning("No libraries loaded. Run 'repo update' to sync libraries.")
+    
+    return libraries
 
   def find_by_id(self, module_name: str, template_id: str) -> Optional[tuple[Path, str]]:
     """Find a template by its ID across all libraries.

+ 369 - 0
cli/core/repo.py

@@ -0,0 +1,369 @@
+"""Repository management module for syncing library repositories."""
+from __future__ import annotations
+
+import logging
+import subprocess
+from pathlib import Path
+from typing import Optional
+
+from rich.console import Console
+from rich.panel import Panel
+from rich.progress import Progress, SpinnerColumn, TextColumn
+from rich.table import Table
+from typer import Argument, Option, Typer
+
+from ..core.config import ConfigManager
+from ..core.exceptions import ConfigError
+
+logger = logging.getLogger(__name__)
+console = Console()
+console_err = Console(stderr=True)
+
+app = Typer(help="Manage library repositories")
+
+
+def _run_git_command(args: list[str], cwd: Optional[Path] = None) -> tuple[bool, str, str]:
+    """Run a git command and return the result.
+    
+    Args:
+        args: Git command arguments (without 'git' prefix)
+        cwd: Working directory for the command
+        
+    Returns:
+        Tuple of (success, stdout, stderr)
+    """
+    try:
+        result = subprocess.run(
+            ["git"] + args,
+            cwd=cwd,
+            capture_output=True,
+            text=True,
+            timeout=300  # 5 minute timeout
+        )
+        return result.returncode == 0, result.stdout, result.stderr
+    except subprocess.TimeoutExpired:
+        return False, "", "Command timed out after 5 minutes"
+    except FileNotFoundError:
+        return False, "", "Git command not found. Please install git."
+    except Exception as e:
+        return False, "", str(e)
+
+
+def _clone_or_pull_repo(name: str, url: str, target_path: Path, branch: Optional[str] = None, sparse_dir: Optional[str] = None) -> tuple[bool, str]:
+    """Clone or pull a git repository with optional sparse-checkout.
+    
+    Args:
+        name: Library name
+        url: Git repository URL
+        target_path: Target directory for the repository
+        branch: Git branch to clone/pull (optional)
+        sparse_dir: Directory to sparse-checkout (optional, use None or "." for full clone)
+        
+    Returns:
+        Tuple of (success, message)
+    """
+    if target_path.exists() and (target_path / ".git").exists():
+        # Repository exists, pull updates
+        logger.debug(f"Pulling updates for library '{name}' at {target_path}")
+        
+        # If branch is specified, checkout the branch first
+        if branch:
+            success, stdout, stderr = _run_git_command(["checkout", branch], cwd=target_path)
+            if not success:
+                logger.warning(f"Failed to checkout branch '{branch}' for library '{name}': {stderr}")
+        
+        success, stdout, stderr = _run_git_command(["pull", "--ff-only"], cwd=target_path)
+        
+        if success:
+            # Check if anything was updated
+            if "Already up to date" in stdout or "Already up-to-date" in stdout:
+                return True, "Already up to date"
+            else:
+                return True, "Updated successfully"
+        else:
+            error_msg = stderr or stdout
+            logger.error(f"Failed to pull library '{name}': {error_msg}")
+            return False, f"Pull failed: {error_msg}"
+    else:
+        # Repository doesn't exist, clone it
+        logger.debug(f"Cloning library '{name}' from {url} to {target_path}")
+        
+        # Ensure parent directory exists
+        target_path.parent.mkdir(parents=True, exist_ok=True)
+        
+        # Determine if we should use sparse-checkout
+        use_sparse = sparse_dir and sparse_dir != "."
+        
+        if use_sparse:
+            # Use sparse-checkout to clone only specific directory
+            logger.debug(f"Using sparse-checkout for directory: {sparse_dir}")
+            
+            # Initialize empty repo
+            success, stdout, stderr = _run_git_command(["init"], cwd=None)
+            if success:
+                # Create target directory
+                target_path.mkdir(parents=True, exist_ok=True)
+                
+                # Initialize git repo
+                success, stdout, stderr = _run_git_command(["init"], cwd=target_path)
+                if not success:
+                    return False, f"Failed to initialize repo: {stderr or stdout}"
+                
+                # Add remote
+                success, stdout, stderr = _run_git_command(["remote", "add", "origin", url], cwd=target_path)
+                if not success:
+                    return False, f"Failed to add remote: {stderr or stdout}"
+                
+                # Enable sparse-checkout
+                success, stdout, stderr = _run_git_command(["config", "core.sparseCheckout", "true"], cwd=target_path)
+                if not success:
+                    return False, f"Failed to enable sparse-checkout: {stderr or stdout}"
+                
+                # Create sparse-checkout file
+                sparse_checkout_file = target_path / ".git" / "info" / "sparse-checkout"
+                sparse_checkout_file.parent.mkdir(parents=True, exist_ok=True)
+                with open(sparse_checkout_file, "w") as f:
+                    f.write(f"{sparse_dir}/*\n")
+                
+                # Pull specific branch with sparse-checkout
+                pull_args = ["pull", "--depth", "1", "origin"]
+                if branch:
+                    pull_args.append(branch)
+                else:
+                    pull_args.append("main")
+                
+                success, stdout, stderr = _run_git_command(pull_args, cwd=target_path)
+                if not success:
+                    return False, f"Sparse-checkout failed: {stderr or stdout}"
+                
+                # Move contents of sparse directory to root
+                sparse_path = target_path / sparse_dir
+                if sparse_path.exists() and sparse_path.is_dir():
+                    # Move all contents from sparse_dir to target_path root
+                    import shutil
+                    for item in sparse_path.iterdir():
+                        dest = target_path / item.name
+                        if dest.exists():
+                            if dest.is_dir():
+                                shutil.rmtree(dest)
+                            else:
+                                dest.unlink()
+                        shutil.move(str(item), str(target_path))
+                    
+                    # Remove empty sparse directory
+                    sparse_path.rmdir()
+                
+                return True, "Cloned successfully (sparse)"
+            else:
+                return False, f"Failed to initialize: {stderr or stdout}"
+        else:
+            # Regular full clone
+            clone_args = ["clone", "--depth", "1"]
+            if branch:
+                clone_args.extend(["--branch", branch])
+            clone_args.extend([url, str(target_path)])
+            
+            success, stdout, stderr = _run_git_command(clone_args)
+            
+            if success:
+                return True, "Cloned successfully"
+            else:
+                error_msg = stderr or stdout
+                logger.error(f"Failed to clone library '{name}': {error_msg}")
+                return False, f"Clone failed: {error_msg}"
+
+
+@app.command()
+def update(
+    library_name: Optional[str] = Argument(
+        None,
+        help="Name of specific library to update (updates all if not specified)"
+    ),
+    verbose: bool = Option(False, "--verbose", "-v", help="Show detailed output")
+) -> None:
+    """Update library repositories by cloning or pulling from git.
+    
+    This command syncs all configured libraries from their git repositories.
+    If a library doesn't exist locally, it will be cloned. If it exists, it will be pulled.
+    """
+    config = ConfigManager()
+    libraries = config.get_libraries()
+    
+    if not libraries:
+        console.print("[yellow]No libraries configured.[/yellow]")
+        console.print("Libraries are auto-configured on first run with a default library.")
+        return
+    
+    # Filter to specific library if requested
+    if library_name:
+        libraries = [lib for lib in libraries if lib.get("name") == library_name]
+        if not libraries:
+            console_err.print(f"[red]Error:[/red] Library '{library_name}' not found in configuration")
+            return
+    
+    libraries_path = config.get_libraries_path()
+    
+    # Create results table
+    results = []
+    
+    with Progress(
+        SpinnerColumn(),
+        TextColumn("[progress.description]{task.description}"),
+        console=console,
+    ) as progress:
+        for lib in libraries:
+            name = lib.get("name")
+            url = lib.get("url")
+            branch = lib.get("branch")
+            directory = lib.get("directory", "library")
+            enabled = lib.get("enabled", True)
+            
+            if not enabled:
+                if verbose:
+                    console.print(f"[dim]Skipping disabled library: {name}[/dim]")
+                results.append((name, "Skipped (disabled)", False))
+                continue
+            
+            task = progress.add_task(f"Updating {name}...", total=None)
+            
+            # Target path: ~/.config/boilerplates/libraries/{name}/
+            target_path = libraries_path / name
+            
+            # Clone or pull the repository with sparse-checkout if directory is specified
+            success, message = _clone_or_pull_repo(name, url, target_path, branch, directory)
+            
+            results.append((name, message, success))
+            progress.remove_task(task)
+            
+            if verbose:
+                status = "[green]✓[/green]" if success else "[red]✗[/red]"
+                console.print(f"{status} {name}: {message}")
+    
+    # Display summary table
+    if not verbose:
+        table = Table(title="Library Update Summary", show_header=True)
+        table.add_column("Library", style="cyan", no_wrap=True)
+        table.add_column("Status")
+        
+        for name, message, success in results:
+            status_style = "green" if success else "red"
+            status_icon = "✓" if success else "✗"
+            table.add_row(name, f"[{status_style}]{status_icon}[/{status_style}] {message}")
+        
+        console.print(table)
+    
+    # Summary
+    total = len(results)
+    successful = sum(1 for _, _, success in results if success)
+    
+    if successful == total:
+        console.print(f"\n[green]All libraries updated successfully ({successful}/{total})[/green]")
+    elif successful > 0:
+        console.print(f"\n[yellow]Partially successful: {successful}/{total} libraries updated[/yellow]")
+    else:
+        console.print(f"\n[red]Failed to update libraries[/red]")
+
+
+@app.command()
+def list() -> None:
+    """List all configured libraries."""
+    config = ConfigManager()
+    libraries = config.get_libraries()
+    
+    if not libraries:
+        console.print("[yellow]No libraries configured.[/yellow]")
+        return
+    
+    table = Table(title="Configured Libraries", show_header=True)
+    table.add_column("Name", style="cyan", no_wrap=True)
+    table.add_column("URL", style="blue")
+    table.add_column("Branch", style="yellow")
+    table.add_column("Directory", style="magenta")
+    table.add_column("Status", style="green")
+    
+    libraries_path = config.get_libraries_path()
+    
+    for lib in libraries:
+        name = lib.get("name", "")
+        url = lib.get("url", "")
+        branch = lib.get("branch", "main")
+        directory = lib.get("directory", "library")
+        enabled = lib.get("enabled", True)
+        
+        # Check if library exists locally (check base path, not directory subdirectory)
+        library_path = libraries_path / name
+        exists = library_path.exists()
+        
+        status_parts = []
+        if not enabled:
+            status_parts.append("[dim]disabled[/dim]")
+        elif exists:
+            status_parts.append("[green]synced[/green]")
+        else:
+            status_parts.append("[yellow]not synced[/yellow]")
+        
+        status = " ".join(status_parts)
+        
+        table.add_row(name, url, branch, directory, status)
+    
+    console.print(table)
+
+
+@app.command()
+def add(
+    name: str = Argument(..., help="Unique name for the library"),
+    url: str = Argument(..., help="Git repository URL"),
+    branch: str = Option("main", "--branch", "-b", help="Git branch to use"),
+    directory: str = Option("library", "--directory", "-d", help="Directory within repo containing templates (metadata only)"),
+    enabled: bool = Option(True, "--enabled/--disabled", help="Enable or disable the library"),
+    sync: bool = Option(True, "--sync/--no-sync", help="Sync the library after adding")
+) -> None:
+    """Add a new library to the configuration."""
+    config = ConfigManager()
+    
+    try:
+        config.add_library(name, url, directory, branch, enabled)
+        console.print(f"[green]✓[/green] Added library '{name}'")
+        
+        if sync and enabled:
+            console.print(f"\nSyncing library '{name}'...")
+            # Call update for this specific library
+            update(library_name=name, verbose=True)
+    except ConfigError as e:
+        console_err.print(f"[red]Error:[/red] {e}")
+
+
+@app.command()
+def remove(
+    name: str = Argument(..., help="Name of the library to remove"),
+    keep_files: bool = Option(False, "--keep-files", help="Keep the local library files (don't delete)")
+) -> None:
+    """Remove a library from the configuration and delete its local files."""
+    config = ConfigManager()
+    
+    try:
+        # Remove from config
+        config.remove_library(name)
+        console.print(f"[green]✓[/green] Removed library '{name}' from configuration")
+        
+        # Delete local files unless --keep-files is specified
+        if not keep_files:
+            libraries_path = config.get_libraries_path()
+            library_path = libraries_path / name
+            
+            if library_path.exists():
+                import shutil
+                shutil.rmtree(library_path)
+                console.print(f"[green]✓[/green] Deleted local files at {library_path}")
+            else:
+                console.print(f"[dim]No local files found at {library_path}[/dim]")
+    except ConfigError as e:
+        console_err.print(f"[red]Error:[/red] {e}")
+
+
+
+
+# Register the repo command with the CLI
+def register_cli(parent_app: Typer) -> None:
+    """Register the repo command with the parent Typer app."""
+    parent_app.add_typer(app, name="repo")

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

@@ -56,6 +56,7 @@ metadata:
        - Review and limit network exposure
 
     For more information, visit: https://doc.traefik.io/traefik/
+  draft: true
 spec:
   general:
     title: "General"