Browse Source

archetype validation testing

xcad 4 months ago
parent
commit
0ffd5acf22

+ 0 - 23
--directory/compose.yaml

@@ -1,23 +0,0 @@
-services:
-  grafana:
-    image: docker.io/grafana/grafana-oss:12.1.1
-    restart: unless-stopped
-    container_name: grafana
-    environment:
-      - TZ=Europe/Berlin
-      - UID=1000
-      - GID=1000
-    networks:
-      bridge:
-    ports:
-      - "3000:3000"
-    volumes:
-      - grafana-data:/var/lib/grafana
-
-volumes:
-  grafana-data:
-    driver: local
-
-networks:
-  bridge:
-    driver: bridge

+ 0 - 18
--directory/config/files/middlewares.yaml

@@ -1,18 +0,0 @@
----
-# Traefik Dynamic Middleware Configuration
-# This file is watched by Traefik and changes are applied automatically
-
-http:
-  middlewares:
-# Production-Ready Security Headers Middleware
-    # Use in service labels: traefik.http.routers.myservice.middlewares=security-headers@file
-    security-headers:
-      headers:
-        frameDeny: true
-        browserXssFilter: true
-        contentTypeNosniff: true
-        sslRedirect: true
-        forceSTSHeader: true
-        stsSeconds: 31536000
-        stsIncludeSubdomains: true
-        stsPreload: true

+ 0 - 27
--directory/config/files/routers.yaml

@@ -1,27 +0,0 @@
----
-# Traefik Dynamic Router Configuration
-# Define routers to route traffic to services
-# Uncomment and customize the examples below
-
-# http:
-#   routers:
-#     # Example 1: Simple host-based routing with HTTPS
-#     my-app:
-#       rule: "Host(`app.example.com`)"
-#       service: my-app-service
-#       entryPoints:
-#         - websecure
-#       tls:
-#         certResolver: cloudflare
-#
-#     # Example 2: Path-based routing with middleware
-#     api:
-#       rule: "Host(`example.com`) && PathPrefix(`/api`)"
-#       service: api-service
-#       priority: 10
-#       entryPoints:
-#         - websecure
-#       tls:
-#         certResolver: cloudflare
-#       middlewares:
-#         - rate-limit@file

+ 0 - 28
--directory/config/files/services.yaml

@@ -1,28 +0,0 @@
----
-# Traefik Dynamic Service Configuration
-# Define backend services that routers connect to
-# Uncomment and customize the examples below
-
-# http:
-#   services:
-#     # Example 1: Single backend server
-#     my-app-service:
-#       loadBalancer:
-#         servers:
-#           - url: "http://192.168.1.100:8080"
-#
-#     # Example 2: Load balanced service with multiple backends
-#     api-service:
-#       loadBalancer:
-#         servers:
-#           - url: "http://192.168.1.10:8080"
-#           - url: "http://192.168.1.11:8080"
-#         sticky:
-#           cookie:
-#             name: api-sticky
-#             httpOnly: true
-#
-# # Server Transport for HTTPS backends with self-signed certificates
-# serversTransports:
-#   insecure:
-#     insecureSkipVerify: true

+ 0 - 24
--directory/config/traefik.yaml

@@ -1,24 +0,0 @@
----
-global:
-  checkNewVersion: false
-  sendAnonymousUsage: false
-
-log:
-  level: INFO
-
-ping:
-  entryPoint: ping
-
-entryPoints:
-  ping:
-    address: :8082
-  web:
-    address: :80
-
-providers:
-  docker:
-    exposedByDefault: false
-    network: proxy
-  file:
-    directory: /etc/traefik/files
-    watch: true

+ 0 - 35
--output-dir/values.yaml

@@ -1,35 +0,0 @@
----
-global:
-  image:
-    repository: "ghcr.io/goauthentik/server"
-    tag: "2025.6.3"
-    pullPolicy: IfNotPresent
-authentik:
-  secret_key: ojUjEz2wv3O44cLvEdJSAbhJCo0NMDeg
-  postgresql:
-    host: postgres.local
-    name: authentik
-    user: authentik
-    password: lyaPwh0qC87rIbzBv8aifnCzXqklblUt
-    port: 5432
-  error_reporting:
-    enabled: false
-  log_level: error
-server:
-  service:
-    type: ClusterIP
-  ingress:
-    enabled: true
-    ingressClassName: traefik
-    annotations:
-      cert-manager.io/cluster-issuer: cloudflare-issuer
-    hosts:
-      - authentik.example.com
-    tls:
-      - secretName: traefik-tls
-        hosts:
-          - authentik.example.com
-postgresql:
-  enabled: false
-redis:
-  enabled: true

+ 225 - 102
AGENTS.md

@@ -100,6 +100,21 @@ Modules can be either single files or packages:
 - Call `registry.register(YourModule)` at module bottom
 - Auto-discovered and registered at CLI startup
 
+**Module Discovery and Registration:**
+
+The system automatically discovers and registers modules at startup:
+
+1. **Discovery**: CLI `__main__.py` imports all Python files in `cli/modules/` directory
+2. **Registration**: Each module file calls `registry.register(ModuleClass)` at module level
+3. **Storage**: Registry stores module classes in a central dictionary by module name
+4. **Command Generation**: CLI framework auto-generates subcommands for each registered module
+5. **Instantiation**: Modules are instantiated on-demand when commands are invoked
+
+**Benefits:**
+- No manual registration needed - just add a file to `cli/modules/`
+- Modules are self-contained - can be added/removed without modifying core code
+- Type-safe - registry validates module interfaces at registration time
+
 **Module Spec:**
 Module-wide variable specification defining defaults for all templates of that kind.
 
@@ -138,30 +153,32 @@ For modules supporting multiple schema versions, use package structure:
 ```
 cli/modules/compose/
   __init__.py          # Module class, loads appropriate spec
-  spec_v1_0.py         # Schema 1.0 specification
-  spec_v1_1.py         # Schema 1.1 specification
+  spec_v1_0.py         # Schema version specification files
+  spec_v1_1.py
+  spec_v1_2.py
+  ...                  # Additional schema versions as needed
 ```
 
 **Existing Modules:**
-- `cli/modules/compose/` - Docker Compose package with schema 1.0 and 1.1 support
-  - `spec_v1_0.py` - Basic compose spec
-  - `spec_v1_1.py` - Extended with network_mode, swarm support
-
-**Compose Module Schema Differences:**
-
-**Schema 1.0:**
-- `network` section: Has `toggle: "network_enabled"` with explicit `network_enabled` boolean variable (default: False)
-- `ports` section: Has `toggle: "ports_enabled"` with explicit `ports_enabled` boolean variable (default: True)
-- Templates check `{% if network_enabled %}` and `{% if ports_enabled %}`
-
-**Schema 1.1:**
-- `network` section: NO toggle - always available, controlled by `network_mode` enum (bridge/host/macvlan)
-- `ports` section: Has `toggle: "ports_enabled"` but the variable is AUTO-CREATED and NOT available in templates
-- Templates check `{% if network_mode == 'bridge' %}` for networks and `{% if network_mode == 'bridge' and not traefik_enabled %}` for ports
-- **Key changes**:
-  - `network_enabled` doesn't exist - use `network_mode` conditionals instead
-  - `ports_enabled` is not usable in templates - use `network_mode` and `traefik_enabled` conditionals instead
-  - Port visibility is controlled by: network mode (must be bridge) + Traefik (ports not needed when using Traefik)
+- `cli/modules/compose/` - Docker Compose package with multi-schema support
+  - Multiple `spec_v*.py` files for schema versioning
+  - Check module directory for current supported schemas
+
+**Compose Module Architecture:**
+
+The compose module uses schema versioning to maintain backward compatibility while introducing new features. Each schema version is defined in a separate spec file (e.g., `spec_v1_0.py`, `spec_v1_1.py`), and templates declare which schema they use.
+
+**Schema Design Principles:**
+- **Backward compatibility**: Newer module versions can load older template schemas
+- **Auto-created toggle variables**: Sections with `toggle` automatically create boolean variables
+- **Conditional visibility**: Variables use `needs` constraints to show/hide based on other variable values
+- **Mode-based organization**: Related settings grouped by operational mode (e.g., network_mode, volume_mode)
+- **Incremental evolution**: New schemas add features without breaking existing templates
+
+**To understand current schema features:**
+- Check `cli/modules/compose/spec_v*.py` files for available schemas
+- Run `python3 tests/print_schema.py compose` to see latest schema structure
+- Review `archetypes/compose/` for reference implementations
 
 **(Work in Progress):** terraform, docker, ansible, kubernetes, packer modules
 
@@ -265,33 +282,47 @@ display.display_template(template, template_id)
 
 Templates are directory-based. Each template is a directory containing all the necessary files and subdirectories for the boilerplate.
 
+### Template Rendering Flow
+
+**How templates are loaded and rendered:**
+
+1. **Discovery**: LibraryManager finds template directories containing `template.yaml`/`template.yml`
+2. **Parsing**: Template class loads and parses the template metadata and spec
+3. **Schema Resolution**: Module's `get_spec()` loads appropriate spec based on template's `schema` field
+4. **Variable Inheritance**: Template inherits ALL variables from module schema
+5. **Variable Merging**: Template spec overrides are merged with module spec (precedence: module < template < user config < CLI)
+6. **Collection Building**: VariableCollection is constructed with merged variables and sections
+7. **Dependency Resolution**: Sections are topologically sorted based on `needs` constraints
+8. **Variable Resolution**: Variables with `needs` constraints are evaluated for visibility
+9. **Jinja2 Rendering**: Template files (`.j2`) are rendered with final variable values
+10. **Sanitization**: Rendered output is cleaned (whitespace, blank lines, trailing newline)
+11. **Validation**: Optional semantic validation (YAML structure, Docker Compose schema, etc.)
+
+**Key Architecture Points:**
+- Templates don't "call" module specs - they declare a schema version and inherit from it
+- Variable visibility is dynamic based on `needs` constraints (evaluated at prompt/render time)
+- Jinja2 templates support `{% include %}` and `{% import %}` for composition
+
+### Template Structure
+
 Requires `template.yaml` or `template.yml` with metadata and variables:
 
 ```yaml
 ---
 kind: compose
-schema: "1.0"  # Optional: Defaults to 1.0 if not specified
+schema: "X.Y"  # Optional: Defaults to "1.0" if not specified (e.g., "1.0", "1.2")
 metadata:
-  name: My Nginx Template
-  description: >
-    A template for a simple Nginx service.
-
-
-    Project: https://...
-
-    Source: https://
-
-    Documentation: https://
-  version: 0.1.0
-  author: Christian Lempa
-  date: '2024-10-01'
+  name: My Service Template
+  description: A template for a service.
+  version: 1.0.0
+  author: Your Name
+  date: '2024-01-01'
 spec:
   general:
     vars:
-      nginx_version:
-        type: string
-        description: The Nginx version to use.
-        default: latest
+      service_name:
+        type: str
+        description: Service name
 ```
 
 ### Template Metadata Versioning
@@ -314,12 +345,14 @@ The `metadata.version` field in `template.yaml` should reflect the version of th
 
 ### Template Schema Versioning
 
+**Version Format:** Schemas use 2-level versioning in `MAJOR.MINOR` format (e.g., "1.0", "1.2", "2.0").
+
 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
+schema: "X.Y"  # Optional: Defaults to "1.0" if not specified (e.g., "1.0", "1.2")
 metadata:
   name: My Template
   version: 1.0.0
@@ -329,7 +362,7 @@ spec:
 ```
 
 **How It Works:**
-- **Module Schema Version**: Each module defines `schema_version` (e.g., "1.1")
+- **Module Schema Version**: Each module defines `schema_version` (e.g., "1.0", "1.2", "2.0")
 - **Module Spec Loading**: Modules load appropriate spec based on template's schema version
 - **Template Schema Version**: Each template declares `schema` at the top level (defaults to "1.0")
 - **Compatibility Check**: Template schema ≤ Module schema → Compatible
@@ -337,39 +370,39 @@ spec:
 
 **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)
+- Older templates work with newer module versions (backward compatibility)
+- Templates with newer schema versions fail on older modules with `IncompatibleSchemaVersionError`
+- Version comparison uses MAJOR.MINOR format (e.g., "1.0" < "1.2" < "2.0")
 
 **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"`
+- Set template schema when using features from a specific schema version
+- Templates using features from newer schemas must declare the appropriate schema version
 
 **Single-File Module Example:**
 ```python
 class SimpleModule(Module):
   name = "simple"
   description = "Simple module"
-  schema_version = "1.0"
+  schema_version = "X.Y"  # e.g., "1.0", "1.2"
   spec = VariableCollection.from_dict({...})  # Single spec
 ```
 
 **Multi-Schema Module Example:**
 ```python
-# cli/modules/compose/__init__.py
-class ComposeModule(Module):
-  name = "compose"
-  description = "Manage Docker Compose configurations"
-  schema_version = "1.1"  # Highest schema version supported
+# cli/modules/modulename/__init__.py
+class ExampleModule(Module):
+  name = "modulename"
+  description = "Module description"
+  schema_version = "X.Y"  # Highest schema version supported (e.g., "1.2", "2.0")
   
   def get_spec(self, template_schema: str) -> VariableCollection:
     """Load spec based on template schema version."""
-    if template_schema == "1.0":
-      from .spec_v1_0 import get_spec
-    elif template_schema == "1.1":
-      from .spec_v1_1 import get_spec
-    return get_spec()
+    # Dynamically load the appropriate spec version
+    # template_schema will be like "1.0", "1.2", etc.
+    version_file = f"spec_v{template_schema.replace('.', '_')}"
+    spec_module = importlib.import_module(f".{version_file}", package=__package__)
+    return spec_module.get_spec()
 ```
 
 **Version Management:**
@@ -390,33 +423,44 @@ class ComposeModule(Module):
 When using Traefik with Docker Compose, the `traefik.docker.network` label is **CRITICAL** for stacks with multiple networks. When containers are connected to multiple networks, Traefik must know which network to use for routing.
 
 **Implementation:**
-- ALL templates using Traefik MUST follow the patterns in `archetypes/compose/traefik-v1.j2` (standard mode) and `archetypes/compose/swarm-v1.j2` (swarm mode)
-- These archetypes are the authoritative reference for correct Traefik label configuration
+- Review `archetypes/compose/` directory for reference implementations of Traefik integration patterns
 - The `traefik.docker.network={{ traefik_network }}` label must be present in both standard `labels:` and `deploy.labels:` sections
+- Standard mode and Swarm mode require different label configurations - check archetypes for examples
 
 ### Variables
 
-**Precedence** (lowest to highest):
+**How Templates Inherit Variables:**
+
+Templates automatically inherit ALL variables from the module schema version they declare. The template's `schema: "X.Y"` field determines which module spec is loaded, and all variables from that schema are available.
+
+**When to Define Template Variables:**
+
+You only need to define variables in your template's `spec` section when:
+1. **Overriding defaults**: Change default values for module variables (e.g., hardcode `service_name` for your specific app)
+2. **Adding custom variables**: Define template-specific variables not present in the module schema
+3. **Upgrading to newer schema**: To use new features, update `schema: "X.Y"` to a higher version - no template spec changes needed
+
+**Variable Precedence** (lowest to highest):
 1. Module `spec` (defaults for all templates of that kind)
 2. Template `spec` (overrides module defaults)
 3. User `config.yaml` (overrides template and module defaults)
 4. CLI `--var` (highest priority)
 
-**Template Variable Overrides:**
-Template `spec` variables can:
+**Template Variable Override Rules:**
 - **Override module defaults**: Only specify properties that differ from module spec (e.g., change `default` value)
 - **Create new variables**: Define template-specific variables not in module spec
 - **Minimize duplication**: Do NOT re-specify `type`, `description`, or other properties if they remain unchanged from module spec
 
 **Example:**
 ```yaml
-# Module spec defines: service_name (type: str, no default)
-# Template spec overrides:
+# Template declares schema: "1.2" → inherits ALL variables from compose schema 1.2
+# Template spec ONLY needs to override specific defaults:
 spec:
   general:
     vars:
       service_name:
         default: whoami  # Only override the default, type already defined in module
+      # All other schema 1.2 variables (network_mode, volume_mode, etc.) are automatically available
 ```
 
 **Variable Types:**
@@ -439,10 +483,30 @@ spec:
 - **Required Sections**: Mark with `required: true` (general is implicit). Users must provide all values.
 - **Toggle Settings**: Conditional sections via `toggle: "bool_var_name"`. If false, section is skipped.
   - **IMPORTANT**: When a section has `toggle: "var_name"`, that boolean variable is AUTO-CREATED by the system
-  - In schema 1.0: Toggle variables are explicitly defined in the section's `vars`
-  - In schema 1.1: Toggle variables are auto-created and don't need explicit definition (unless customizing defaults)
+  - Toggle variable behavior may vary by schema version - check current schema documentation
   - Example: `ports` section with `toggle: "ports_enabled"` automatically provides `ports_enabled` boolean
-- **Dependencies**: Use `needs: "section_name"` or `needs: ["sec1", "sec2"]`. Dependent sections only shown when dependencies are enabled. Auto-validated (detects circular/missing/self dependencies). Topologically sorted.
+- **Dependencies**: Use `needs: "section_name"` or `needs: ["sec1", "sec2"]`. Dependent sections only shown when dependencies are enabled.
+
+**Dependency Resolution Architecture:**
+
+Sections and variables support `needs` constraints to control visibility based on other variables.
+
+**Section-Level Dependencies:**
+- Format: `needs: "section_name"` or `needs: ["sec1", "sec2"]`
+- Section only appears when all required sections are enabled (their toggle variables are true)
+- Automatically validated: detects circular, missing, and self-dependencies
+- Topologically sorted: ensures dependencies are prompted/processed before dependents
+
+**Variable-Level Dependencies:**
+- Format: `needs: "var_name=value"` or `needs: "var1=val1;var2=val2"` (semicolon-separated)
+- Variable only visible when constraint is satisfied (e.g., `needs: "network_mode=bridge"`)
+- Supports multiple values: `needs: "network_mode=bridge,macvlan"` (comma = OR)
+- Evaluated dynamically at prompt and render time
+
+**Validation:**
+- Circular dependencies: Raises error if A needs B and B needs A
+- Missing dependencies: Raises error if referencing non-existent sections/variables
+- Self-dependencies: Raises error if section depends on itself
 
 **Example Section with Dependencies:**
 
@@ -515,63 +579,122 @@ To skip the prompt use the `--no-interactive` flag, which will use defaults or e
 
 ## Archetypes
 
-The `archetypes` package provides reusable, standardized template building blocks for creating boilerplates. Archetypes are modular Jinja2 snippets that represent specific configuration sections (e.g., networks, volumes, service labels).
+The `archetypes` package provides reusable, standardized template building blocks for creating boilerplates. Archetypes are modular Jinja2 snippets that represent specific configuration sections.
 
 ### Purpose
 
 1. **Template Development**: Provide standardized, tested building blocks for creating new templates
 2. **Testing & Validation**: Enable testing of specific configuration sections in isolation with different variable combinations
 
-### Structure
-
-```
-archetypes/
-  __init__.py              # Package initialization
-  __main__.py              # CLI tool (auto-discovers modules)
-  compose/                 # Module-specific archetypes
-    archetypes.yaml        # Configuration: schema version + variable overrides
-    compose.yaml.j2        # Main composition file (includes all components)
-    service-*.j2           # Service-level components (networks, ports, volumes, labels, etc.)
-    networks-*.j2          # Top-level network definitions
-    volumes-*.j2           # Top-level volume definitions
-    configs-*.j2           # Config definitions
-    secrets-*.j2           # Secret definitions
-```
-
-**Key Files:**
-- `archetypes.yaml`: Configures schema version and variable overrides for testing
-- `compose.yaml.j2`: Main composition file that includes all archetype components to test complete configurations
-- Individual `*.j2` files: Modular components for specific configuration sections
-
 ### Usage
 
 ```bash
-# List available archetypes
+# List available archetypes for a module
 python3 -m archetypes compose list
 
-# Preview complete composition (all components together)
-python3 -m archetypes compose generate compose.yaml
-
-# Preview individual component
-python3 -m archetypes compose generate networks-v1
+# Preview an archetype component
+python3 -m archetypes compose generate <archetype-name>
 
 # Test with variable overrides
-python3 -m archetypes compose generate compose.yaml \
+python3 -m archetypes compose generate <archetype-name> \
   --var traefik_enabled=true \
   --var swarm_enabled=true
+
+# Validate templates against archetypes
+python3 -m archetypes compose validate            # All templates
+python3 -m archetypes compose validate <template> # Single template
+```
+
+### Archetype Validation
+
+The `validate` command compares templates against archetypes to measure coverage and identify which archetype patterns are being used.
+
+**What it does:**
+- Compares each template file against all available archetypes using **structural pattern matching**
+- Abstracts away specific values to focus on:
+  - **Jinja2 control flow**: `{% if %}`, `{% elif %}`, `{% else %}`, `{% for %}` structures
+  - **YAML structure**: Key names, indentation, and nesting patterns
+  - **Variable usage patterns**: Presence of `{{ }}` placeholders (not specific names)
+  - **Wildcard placeholders**: `__ANY__`, `__ANYSTR__`, `__ANYINT__`, `__ANYBOOL__`
+  - **Repeat markers**: `{# @repeat-start #}` / `{# @repeat-end #}`
+  - **Optional markers**: `{# @optional-start #}` / `{# @optional-end #}`
+- This allows detection of archetypes even when specific values differ (e.g., `grafana_data` vs `alloy_data`)
+- Calculates **containment ratio**: what percentage of each archetype structure is found within the template
+- Reports usage status: **exact** (≥95%), **high** (≥70%), **partial** (≥30%), or **none** (<30%)
+- Provides coverage metrics: (exact + high matches) / total archetypes
+
+### Advanced Pattern Matching in Archetypes
+
+Archetypes support special annotations for flexible pattern matching:
+
+**Wildcard Placeholders** (match any value):
+- `__ANY__` - Matches anything
+- `__ANYSTR__` - Matches any string
+- `__ANYINT__` - Matches any integer
+- `__ANYBOOL__` - Matches any boolean
+
+**Repeat Markers** (pattern can appear 1+ times):
+```yaml
+{# @repeat-start #}
+  pattern
+{# @repeat-end #}
 ```
 
+**Optional Markers** (section may or may not exist):
+```yaml
+{# @optional-start #}
+  pattern
+{# @optional-end #}
+```
+
+**Example:**
+```yaml
+volumes:
+  {# @repeat-start #}
+  __ANY__:
+    driver: local
+  {# @repeat-end #}
+```
+Matches any number of volumes with `driver: local`
+
+**Usage:**
+```bash
+# Validate all templates in library - shows summary table
+python3 -m archetypes compose validate
+
+# Validate specific template - shows detailed archetype breakdown
+python3 -m archetypes compose validate whoami
+
+# Validate templates in custom location
+python3 -m archetypes compose validate --library /path/to/templates
+```
+
+**Output:**
+- **Summary mode** (all templates): Table showing exact/high/partial/none counts and coverage % per template
+- **Detail mode** (single template): Table showing each archetype's status, similarity %, and matching file
+
+**Use cases:**
+- **Quality assurance**: Ensure templates follow established patterns
+- **Refactoring**: Identify templates that could benefit from archetype alignment
+- **Documentation**: Track which archetypes are most/least used across templates
+
 ### Template Development Workflow
 
-1. **Start with archetypes**: Copy relevant archetype components to your template directory
-2. **Customize**: Modify components as needed (hardcode image, add custom labels, etc.)
-3. **Test**: Validate using `python3 -m archetypes compose generate`
-4. **Validate**: Use `compose validate` to check Jinja2 syntax and semantic correctness
+1. **Discover**: Use `list` command to see available archetype components for your module
+2. **Review**: Preview archetypes to understand implementation patterns
+3. **Copy**: Copy relevant archetype components to your template directory
+4. **Customize**: Modify as needed (hardcode image, add custom labels, etc.)
+5. **Validate**: Use `compose validate` to check Jinja2 syntax and semantic correctness
 
-### Implementation Details
+### Architecture
+
+**Key Concepts:**
+- Each module can have its own `archetypes/<module>/` directory with reusable components
+- `archetypes.yaml` configures schema version and variable overrides for testing
+- Components are modular Jinja2 files that can be tested in isolation or composition
+- **Testing only**: The `generate` command NEVER writes files - always shows preview output
 
 **How it works:**
 - Loads module spec based on schema version from `archetypes.yaml`
 - Merges variable sources: module spec → archetypes.yaml → CLI --var
 - Renders using Jinja2 with support for `{% include %}` directives
-- **Testing only**: The `generate` command NEVER writes files - always shows preview output

+ 4 - 0
CHANGELOG.md

@@ -19,6 +19,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
 - Separate help panels for "Template Commands" and "Configuration Commands"
 - Compose Schema 1.2: Authentik Traefik middleware integration with `authentik_traefik_middleware` variable
 - Markdown formatting support for template descriptions and next steps (#1471)
+- Output directory flag `--output`/`-o` for `generate` command (#1534) - Replaces positional directory argument
 
 ### Changed
 - Removed Jinja2 `| default()` filter extraction and merging (#1410) - All defaults must now be defined in template/module specs
@@ -28,6 +29,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
 - Simplified dry-run output to show only essential information (files, sizes, status)
 - Traefik template now uses module spec variable `authentik_traefik_middleware` instead of template-specific `traefik_authentik_middleware_name`
 
+### Deprecated
+- Positional directory argument for `generate` command (#1534) - Use `--output`/`-o` flag instead (will be removed in v0.2.0)
+
 ### Fixed
 - CLI --var flag now properly converts boolean and numeric strings to appropriate Python types (#1522)
 - Empty template files are no longer created during generation (#1518)

+ 638 - 3
archetypes/__main__.py

@@ -7,8 +7,10 @@ Usage: python3 -m archetypes <module> <command>
 from __future__ import annotations
 
 import builtins
+import difflib
 import importlib
 import logging
+import re
 import sys
 from collections import OrderedDict
 from pathlib import Path
@@ -46,6 +48,16 @@ display = DisplayManager()
 # Base directory for archetypes
 ARCHETYPES_DIR = Path(__file__).parent
 
+# Similarity thresholds for archetype validation
+SIMILARITY_EXACT = 0.95
+SIMILARITY_HIGH = 0.7
+SIMILARITY_PARTIAL = 0.3
+COVERAGE_GOOD = 0.7
+COVERAGE_FAIR = 0.4
+
+# Display limits
+MAX_DIFF_LINES = 10
+
 
 def setup_logging(log_level: str = "WARNING") -> None:
     """Configure logging for debugging."""
@@ -249,15 +261,19 @@ class ArchetypeTemplate:
 
 
 def find_archetypes(module_name: str) -> list[Path]:
-    """Find all .j2 files in the module's archetype directory."""
+    """Find all .j2 files in the module's archetype directory.
+
+    Excludes files matching the pattern '*-all-v*.j2' as these are
+    typically composite archetypes used for testing/generation only.
+    """
     module_dir = ARCHETYPES_DIR / module_name
 
     if not module_dir.exists():
         console.print(f"[red]Module directory not found: {module_dir}[/red]")
         return []
 
-    # Find all .j2 files
-    j2_files = list(module_dir.glob("*.j2"))
+    # Find all .j2 files, excluding 'all-v*.j2' and '*-all-v*.j2' patterns
+    j2_files = [f for f in module_dir.glob("*.j2") if not re.match(r"(.*-)?all-v.*\.j2$", f.name)]
     return sorted(j2_files)
 
 
@@ -351,6 +367,536 @@ def _display_generated_preview(output_dir: Path, rendered_files: dict[str, str])
         console.print()
 
 
+def _normalize_template_content(content: str) -> list[str]:
+    """Normalize template content for comparison.
+
+    Removes blank lines, comments, and trims whitespace to focus on
+    structural similarity rather than exact formatting.
+    """
+    lines = []
+    for line in content.splitlines():
+        stripped = line.strip()
+        # Skip empty lines and comment-only lines
+        if stripped and not stripped.startswith("#"):
+            lines.append(stripped)
+    return lines
+
+
+def _extract_structural_pattern(content: str, is_archetype: bool = False) -> list[str]:
+    """Extract structural pattern from template content.
+
+    Abstracts away specific values to focus on:
+    - Jinja2 control structures (if/elif/else/endif/for/endfor)
+    - YAML structure (keys, indentation levels)
+    - Variable placeholders (replaced with generic <VAR>)
+    - Literal values (replaced with generic <VALUE>)
+    - Wildcard placeholders (__ANY__, __ANYSTR__, __ANYINT__, __ANYBOOL__)
+    - Pattern markers (@repeat-start/end, @optional-start/end)
+
+    Args:
+        content: The template content to parse
+        is_archetype: If True, preserves special markers and wildcards
+
+    This allows comparing templates based on structure and logic
+    rather than exact string matches.
+    """
+    lines = []
+    for line in content.splitlines():
+        # Skip empty lines
+        stripped = line.strip()
+        if not stripped:
+            continue
+
+        # Check for pattern markers (only in archetypes)
+        if is_archetype and stripped.startswith("{#"):
+            # Preserve pattern markers like @repeat-start, @optional-start, etc.
+            if "@repeat-start" in stripped:
+                lines.append("@REPEAT_START")
+                continue
+            elif "@repeat-end" in stripped:
+                lines.append("@REPEAT_END")
+                continue
+            elif "@optional-start" in stripped:
+                lines.append("@OPTIONAL_START")
+                continue
+            elif "@optional-end" in stripped:
+                lines.append("@OPTIONAL_END")
+                continue
+            elif "@requires" in stripped:
+                # Extract the requirement path (e.g., "services.*.configs")
+                match = re.search(r"@requires\s+([^\s#}]+)", stripped)
+                if match:
+                    lines.append(f"@REQUIRES {match.group(1)}")
+                continue
+            # Skip other comments
+            continue
+
+        # Skip regular comments
+        if stripped.startswith("#"):
+            continue
+
+        # Preserve indentation level (simplified to count of spaces)
+        indent_level = len(line) - len(line.lstrip())
+        indent_marker = "  " * (indent_level // 2)  # Normalize to 2-space indents
+
+        # Keep Jinja2 control structures exactly as-is (no abstraction)
+        if re.match(r"^{%\s*(if|elif|else|endif|for|endfor|block|endblock)\s*.*%}$", stripped):
+            lines.append(indent_marker + stripped)
+            continue
+
+        # Keep Jinja2 variable interpolations as-is (preserve variable names)
+        # We want to match exact variable usage, not abstract it
+        normalized = stripped
+
+        # Handle wildcard placeholders in archetypes
+        if is_archetype:
+            # Preserve wildcard patterns
+            for wildcard in ["__ANY__", "__ANYSTR__", "__ANYINT__", "__ANYBOOL__"]:
+                if wildcard in normalized:
+                    # Keep wildcard as-is for archetype patterns
+                    pass
+
+        # Extract YAML key if present (key: value pattern)
+        yaml_key_match = re.match(r"^-?\s*([a-zA-Z_][a-zA-Z0-9_]*):\s*(.*)$", normalized)
+        if yaml_key_match:
+            key = yaml_key_match.group(1)
+            value = yaml_key_match.group(2)
+
+            # Preserve key and value as-is (wildcards will be handled during matching)
+            if value:
+                normalized_line = f"{key}: {value}"
+            else:
+                normalized_line = f"{key}:"
+
+            lines.append(indent_marker + normalized_line)
+            continue
+
+        # Handle list items (- value)
+        if normalized.startswith("-"):
+            # Preserve list values as-is
+            lines.append(indent_marker + normalized)
+            continue
+
+        # Fallback: just preserve the abstracted line
+        lines.append(indent_marker + normalized)
+
+    return lines
+
+
+def _pattern_matches_value(pattern: str, value: str) -> bool:
+    """Check if a pattern (with wildcards) matches a value.
+
+    Supports wildcards: __ANY__, __ANYSTR__, __ANYINT__, __ANYBOOL__, __ANYPATH__
+
+    Args:
+        pattern: The pattern string (may contain wildcards)
+        value: The value to match against
+
+    Returns:
+        True if pattern matches value
+    """
+    # If no wildcards, must match exactly
+    if "__ANY" not in pattern:
+        return pattern == value
+
+    # Build regex from pattern by replacing wildcards
+    regex_pattern = re.escape(pattern)
+
+    # Replace wildcards with appropriate regex patterns
+    # Order matters: more specific before more general
+    regex_pattern = regex_pattern.replace(r"__ANYPATH__", r"[^:]+")
+    regex_pattern = regex_pattern.replace(r"__ANYINT__", r"\d+")
+    regex_pattern = regex_pattern.replace(r"__ANYBOOL__", r"(?:true|false|yes|no)")
+    regex_pattern = regex_pattern.replace(r"__ANYSTR__", r"[a-zA-Z0-9_-]+")
+    regex_pattern = regex_pattern.replace(r"__ANY__", r".+")
+
+    # Anchor the pattern
+    regex_pattern = f"^{regex_pattern}$"
+
+    return bool(re.match(regex_pattern, value, re.IGNORECASE))
+
+
+def _normalize_pattern_line(line: str) -> str:
+    """Normalize a pattern line by replacing wildcards with generic markers.
+
+    Wildcards in archetypes get normalized to match any corresponding value.
+    """
+    # Replace wildcard patterns with match-any markers
+    for wildcard in ["__ANY__", "__ANYSTR__", "__ANYINT__", "__ANYBOOL__"]:
+        line = line.replace(wildcard, "<WILDCARD>")
+    return line
+
+
+def _extract_repeat_sections(pattern: list[str]) -> list[tuple[int, int, list[str]]]:
+    """Extract repeat sections from a pattern.
+
+    Returns:
+        List of (start_idx, end_idx, section_content) tuples
+    """
+    sections = []
+    i = 0
+    while i < len(pattern):
+        if pattern[i] == "@REPEAT_START":
+            start = i + 1
+            depth = 1
+            j = i + 1
+            while j < len(pattern) and depth > 0:
+                if pattern[j] == "@REPEAT_START":
+                    depth += 1
+                elif pattern[j] == "@REPEAT_END":
+                    depth -= 1
+                j += 1
+            end = j - 1
+            sections.append((i, j, pattern[start:end]))
+            i = j
+        else:
+            i += 1
+    return sections
+
+
+def _extract_optional_sections(pattern: list[str]) -> set[int]:
+    """Extract indices of optional sections.
+
+    Returns:
+        Set of line indices that are within optional sections
+    """
+    optional_indices = set()
+    i = 0
+    while i < len(pattern):
+        if pattern[i] == "@OPTIONAL_START":
+            start = i + 1
+            depth = 1
+            j = i + 1
+            while j < len(pattern) and depth > 0:
+                if pattern[j] == "@OPTIONAL_START":
+                    depth += 1
+                elif pattern[j] == "@OPTIONAL_END":
+                    depth -= 1
+                j += 1
+            end = j - 1
+            # Mark all lines in this range as optional
+            for idx in range(start, end):
+                optional_indices.add(idx)
+            i = j
+        else:
+            i += 1
+    return optional_indices
+
+
+def _check_requirement(requirement: str, template_pattern: list[str]) -> bool:
+    """Check if a requirement path exists in the template.
+
+    Args:
+        requirement: Path like "services.*.configs" or "configs:"
+        template_pattern: The template's structural pattern
+
+    Returns:
+        True if requirement is satisfied
+    """
+    # Parse requirement path
+    parts = requirement.split(".")
+
+    # Simple case: just check if key exists
+    if len(parts) == 1:
+        # Check for exact match or as YAML key
+        search_term = parts[0].rstrip(":")
+        return any(search_term in line for line in template_pattern)
+
+    # Complex case: services.*.configs means "any service has configs"
+    if len(parts) == 3 and parts[1] == "*":
+        # Look for the nested key within the parent section
+        parent = parts[0]  # e.g., "services"
+        child = parts[2]  # e.g., "configs"
+
+        # Check if we can find parent section followed by child key
+        in_parent = False
+        for line in template_pattern:
+            if parent in line and ":" in line:
+                in_parent = True
+            elif in_parent and child in line:
+                return True
+            # Reset if we hit another top-level key
+            elif in_parent and line and not line.startswith((" ", "\t", "-")):
+                in_parent = False
+
+    return False
+
+
+def _calculate_similarity(archetype_content: str, template_content: str) -> tuple[float, str]:
+    """Calculate similarity between archetype and template content.
+
+    Uses structural pattern matching to compare templates based on:
+    - Jinja2 control flow (if/elif/else/for)
+    - YAML structure (keys and nesting)
+    - Variable usage patterns (not specific values)
+    - Wildcard matching (__ANY__, __ANYSTR__, etc.)
+    - Repeat sections (@repeat-start/end)
+    - Optional sections (@optional-start/end)
+
+    This allows detection of archetypes even when specific names differ
+    (e.g., grafana_data vs alloy_data).
+
+    Returns:
+        Tuple of (similarity_ratio, usage_status)
+        - similarity_ratio: 0.0 to 1.0 (percentage of archetype structure found)
+        - usage_status: "exact", "high", "partial", or "none"
+    """
+    archetype_pattern = _extract_structural_pattern(archetype_content, is_archetype=True)
+    template_pattern = _extract_structural_pattern(template_content, is_archetype=False)
+
+    if not archetype_pattern:
+        return 0.0, "none"
+
+    # Check for @requires marker - if requirement not met, return 0%
+    for line in archetype_pattern:
+        if line.startswith("@REQUIRES "):
+            requirement = line.split(" ", 1)[1]
+            if not _check_requirement(requirement, template_pattern):
+                # Required section/key is missing from template
+                return 0.0, "none"
+
+    # Extract repeat and optional sections from RAW pattern
+    repeat_sections = _extract_repeat_sections(archetype_pattern)
+    raw_optional_indices = _extract_optional_sections(archetype_pattern)
+
+    # Remove marker lines from archetype pattern for comparison
+    # AND build a mapping from raw indices to cleaned indices
+    cleaned_archetype = []
+    raw_to_cleaned_map = {}
+    cleaned_idx = 0
+    
+    for raw_idx, line in enumerate(archetype_pattern):
+        if line not in ("@REPEAT_START", "@REPEAT_END", "@OPTIONAL_START", "@OPTIONAL_END") and not line.startswith("@REQUIRES "):
+            cleaned_archetype.append(line)
+            raw_to_cleaned_map[raw_idx] = cleaned_idx
+            cleaned_idx += 1
+    
+    # Map optional indices from raw to cleaned
+    optional_indices = {raw_to_cleaned_map[i] for i in raw_optional_indices if i in raw_to_cleaned_map}
+
+    if not cleaned_archetype:
+        return 0.0, "none"
+
+    # Count matches using wildcard-aware matching
+    matched_lines = 0
+    used_template_indices = set()
+    matched_archetype_indices = set()
+
+    for arch_idx, arch_line in enumerate(cleaned_archetype):
+        # Try to find a matching line in template (that hasn't been matched yet)
+        for i, temp_line in enumerate(template_pattern):
+            if i in used_template_indices:
+                continue
+
+            # Check if lines match (with wildcard support)
+            if arch_line == temp_line:
+                matched_lines += 1
+                used_template_indices.add(i)
+                matched_archetype_indices.add(arch_idx)
+                break
+            elif "__ANY" in arch_line and _pattern_matches_value(arch_line, temp_line):
+                matched_lines += 1
+                used_template_indices.add(i)
+                matched_archetype_indices.add(arch_idx)
+                break
+
+    # Handle optional sections correctly:
+    # - If optional section is NOT in template: exclude from denominator (don't penalize)
+    # - If optional section IS in template: must fully comply (include in denominator)
+    optional_lines_used = len([i for i in optional_indices if i in matched_archetype_indices])
+    optional_lines_total = len(optional_indices)
+    optional_lines_unused = optional_lines_total - optional_lines_used
+
+    # Total = all lines - unused optional lines
+    total_archetype_lines = len(cleaned_archetype) - optional_lines_unused
+
+    # Ratio represents what percentage of the required archetype structure is found
+    ratio = matched_lines / total_archetype_lines if total_archetype_lines > 0 else 0.0
+    
+    # Cap at 1.0 (100%) to prevent math errors
+    ratio = min(ratio, 1.0)
+
+    # Determine usage status based on structural match
+    if ratio >= SIMILARITY_EXACT:
+        return ratio, "exact"
+    if ratio >= SIMILARITY_HIGH:
+        return ratio, "high"
+    if ratio >= SIMILARITY_PARTIAL:
+        return ratio, "partial"
+    return ratio, "none"
+
+
+def _find_template_files(template_dir: Path) -> dict[str, Path]:
+    """Find all template .j2 files in a template directory.
+
+    Returns:
+        Dict mapping template file stem (without .j2) to file path
+    """
+    template_files = {}
+    if template_dir.exists():
+        for j2_file in template_dir.glob("*.j2"):
+            template_files[j2_file.stem] = j2_file
+    return template_files
+
+
+def _validate_template_against_archetypes(
+    template_dir: Path,
+    archetypes: list[Path],
+) -> dict[str, dict[str, Any]]:
+    """Validate a template directory against all archetypes.
+
+    Returns:
+        Dict mapping archetype ID to validation results:
+        {
+            "archetype_id": {
+                "ratio": 0.85,
+                "status": "high",
+                "template_file": Path(...) or None
+            }
+        }
+    """
+    template_files = _find_template_files(template_dir)
+    results = {}
+
+    for archetype_path in archetypes:
+        archetype_id = archetype_path.stem
+
+        # Load archetype content
+        with archetype_path.open() as f:
+            archetype_content = f.read()
+
+        # Check if template has a matching file
+        best_match = None
+        best_ratio = 0.0
+        best_status = "none"
+
+        for _template_stem, template_path in template_files.items():
+            with template_path.open() as f:
+                template_content = f.read()
+
+            ratio, status = _calculate_similarity(archetype_content, template_content)
+
+            if ratio > best_ratio:
+                best_ratio = ratio
+                best_status = status
+                best_match = template_path
+
+        results[archetype_id] = {"ratio": best_ratio, "status": best_status, "template_file": best_match}
+
+    return results
+
+
+def _get_pattern_diff(archetype_pattern: list[str], template_pattern: list[str]) -> tuple[list[str], list[str]]:
+    """Get the differences between archetype and template patterns.
+
+    Supports wildcard matching in archetype patterns.
+
+    Returns:
+        Tuple of (missing_lines, extra_lines)
+        - missing_lines: Lines in archetype but not in template
+        - extra_lines: Lines in template but not in archetype
+    """
+    matched_archetype_indices = set()
+    matched_template_indices = set()
+
+    # For each archetype line, check if it matches any template line
+    for i, arch_line in enumerate(archetype_pattern):
+        # Check for exact match first (fast path)
+        if arch_line in template_pattern:
+            matched_archetype_indices.add(i)
+            # Mark the first matching template line
+            template_idx = template_pattern.index(arch_line)
+            matched_template_indices.add(template_idx)
+            continue
+
+        # Check for wildcard pattern match
+        if "__ANY" in arch_line:
+            for j, template_line in enumerate(template_pattern):
+                if j in matched_template_indices:
+                    continue
+                if _pattern_matches_value(arch_line, template_line):
+                    matched_archetype_indices.add(i)
+                    matched_template_indices.add(j)
+                    break
+
+    missing_lines = [archetype_pattern[i] for i in range(len(archetype_pattern)) if i not in matched_archetype_indices]
+
+    extra_lines = [template_pattern[i] for i in range(len(template_pattern)) if i not in matched_template_indices]
+
+    return missing_lines, extra_lines
+
+
+def _create_validation_table(template_id: str, validation_results: dict[str, dict[str, Any]]) -> Table:
+    """Create a table showing archetype validation results."""
+    table = Table(
+        title=f"Archetype Validation: {template_id}",
+        show_header=True,
+        header_style="bold cyan",
+    )
+    table.add_column("Archetype", style="cyan")
+    table.add_column("Similarity", justify="right")
+    table.add_column("Template File", style="dim")
+
+    # Sort by similarity (highest first)
+    sorted_results = sorted(validation_results.items(), key=lambda x: x[1]["ratio"], reverse=True)
+
+    for archetype_id, result in sorted_results:
+        ratio = result["ratio"]
+        template_file = result["template_file"]
+
+        # Color-code similarity based on thresholds
+        if ratio >= SIMILARITY_EXACT:
+            color = "green"
+        elif ratio >= SIMILARITY_HIGH:
+            color = "green"
+        elif ratio >= SIMILARITY_PARTIAL:
+            color = "yellow"
+        else:
+            color = "red"
+
+        ratio_text = f"[{color}]{ratio:.1%}[/{color}]"
+        file_text = template_file.name if template_file else "--"
+
+        table.add_row(archetype_id, ratio_text, file_text)
+
+    return table
+
+
+def _display_pattern_diff(archetype_id: str, archetype_path: Path, template_path: Path, ratio: float) -> None:
+    """Display the structural differences between archetype and template."""
+    # Load and extract patterns
+    with archetype_path.open() as f:
+        archetype_content = f.read()
+    with template_path.open() as f:
+        template_content = f.read()
+
+    archetype_pattern = _extract_structural_pattern(archetype_content, is_archetype=True)
+    template_pattern = _extract_structural_pattern(template_content, is_archetype=False)
+
+    # Clean up markers from archetype pattern for diff display
+    cleaned_archetype = [
+        line
+        for line in archetype_pattern
+        if line not in ("@REPEAT_START", "@REPEAT_END", "@OPTIONAL_START", "@OPTIONAL_END")
+        and not line.startswith("@REQUIRES ")
+    ]
+
+    missing_lines, extra_lines = _get_pattern_diff(cleaned_archetype, template_pattern)
+
+    if not missing_lines and not extra_lines:
+        console.print(f"\n[green]✓[/green] [bold]{archetype_id}[/bold]: Perfect match!")
+        return
+
+    console.print(f"\n[bold cyan]Differences for {archetype_id}[/bold cyan] ([dim]{ratio:.1%} match[/dim]):")
+
+    if missing_lines:
+        console.print("\n[yellow]  Missing from template:[/yellow]")
+        for line in missing_lines[:MAX_DIFF_LINES]:
+            console.print(f"    [red]-[/red] {line}")
+        if len(missing_lines) > MAX_DIFF_LINES:
+            console.print(f"    [dim]... and {len(missing_lines) - MAX_DIFF_LINES} more lines[/dim]")
+
+
 def create_module_commands(module_name: str) -> Typer:
     """Create a Typer app with commands for a specific module."""
     module_app = Typer(help=f"Manage {module_name} archetypes")
@@ -415,6 +961,95 @@ def create_module_commands(module_name: str) -> Typer:
         _display_generated_preview(output_dir, rendered_files)
         display.success("Preview complete - no files were written")
 
+    @module_app.command()
+    def validate(
+        template_id: str = Argument(..., help="Template ID or path to validate"),
+        library_path: str | None = Option(
+            None, "--library", "-l", help="Path to template library (defaults to library/<module>)"
+        ),
+        show_diff: bool = Option(False, "--diff", "-d", help="Show detailed differences for non-exact matches"),
+    ) -> None:
+        """Validate a template against archetypes to check usage coverage.
+
+        Compares template files with archetype snippets and reports which
+        archetype patterns are used and what differences exist.
+        """
+        archetypes = find_archetypes(module_name)
+
+        if not archetypes:
+            display.error(
+                f"No archetypes found for module '{module_name}'", context=f"directory: {ARCHETYPES_DIR / module_name}"
+            )
+            return
+
+        # Determine library path
+        lib_dir = Path(library_path) if library_path else Path.cwd() / "library" / module_name
+
+        if not lib_dir.exists():
+            display.error(
+                f"Library directory not found: {lib_dir}", context="Use --library to specify a different path"
+            )
+            return
+
+        # Find template to validate
+        template_path = lib_dir / template_id
+        if not template_path.exists():
+            # Try as direct path
+            template_path = Path(template_id)
+            if not template_path.exists():
+                display.error(f"Template not found: {template_id}", context=f"Searched in: {lib_dir}")
+                return
+
+        results = _validate_template_against_archetypes(
+            template_path,
+            archetypes,
+        )
+
+        table = _create_validation_table(template_path.name, results)
+        console.print()
+        console.print(table)
+
+        # Show summary stats
+        counts = {"exact": 0, "high": 0, "partial": 0, "none": 0}
+        for result in results.values():
+            counts[result["status"]] += 1
+
+        total = len(results)
+        coverage = (counts["exact"] + counts["high"]) / total if total > 0 else 0
+
+        console.print()
+        color = "green" if coverage >= COVERAGE_GOOD else "yellow" if coverage >= COVERAGE_FAIR else "red"
+        console.print(
+            f"[bold]Summary:[/bold] {counts['exact']} exact, {counts['high']} high, "
+            f"{counts['partial']} partial, {counts['none']} none | "
+            f"Coverage: [{color}]{coverage:.1%}[/]"
+        )
+
+        # Show diffs if requested
+        if show_diff:
+            console.print("\n" + "=" * 80)
+            console.print("[bold]Detailed Differences:[/bold]")
+            console.print("=" * 80)
+
+            # Show diffs for non-exact matches (sorted by ratio, highest first)
+            sorted_results = sorted(results.items(), key=lambda x: x[1]["ratio"], reverse=True)
+
+            for archetype_id, result in sorted_results:
+                ratio = result["ratio"]
+
+                # Skip perfect matches and archetypes with no template file
+                if ratio >= SIMILARITY_EXACT or not result["template_file"]:
+                    continue
+
+                archetype_path = None
+                for arch_path in archetypes:
+                    if arch_path.stem == archetype_id:
+                        archetype_path = arch_path
+                        break
+
+                if archetype_path:
+                    _display_pattern_diff(archetype_id, archetype_path, result["template_file"], ratio)
+
     return module_app
 
 

+ 0 - 0
archetypes/compose/compose.yaml.j2 → archetypes/compose/all-v1.j2


+ 5 - 2
archetypes/compose/configs-v1.j2

@@ -1,5 +1,8 @@
+{# @requires configs #}
 {% if swarm_enabled %}
 configs:
-  {{ service_name }}_config_1:
-    file: ./config/testapp.yaml
+  {# @repeat-start #}
+  {{ service_name }}__ANYSTR__:
+    file: __ANYPATH__
+  {# @repeat-end #}
 {% endif %}

+ 1 - 0
archetypes/compose/networks-v1.j2

@@ -1,3 +1,4 @@
+{# @requires networks #}
 {% if network_mode != 'host' %}
 networks:
   {{ network_name }}:

+ 5 - 2
archetypes/compose/secrets-v1.j2

@@ -1,5 +1,8 @@
+{# @requires secrets #}
 {% if swarm_enabled %}
 secrets:
-  {{ service_name }}_secret_1:
-    file: ./.env.secret
+  {# @repeat-start #}
+  {{ service_name }}__ANYSTR__:
+    file: ./.env.secret.__ANYSTR__
+  {# @repeat-end #}
 {% endif %}

+ 5 - 2
archetypes/compose/service-configs-v1.j2

@@ -1,5 +1,8 @@
+{# @requires services.*.configs #}
     {% if swarm_enabled %}
     configs:
-      - source: {{ service_name }}_config_1
-        target: /etc/app/config.yaml
+      {# @repeat-start #}
+      - source: {{ service_name }}__ANYSTR__
+        target: __ANYPATH__
+      {# @repeat-end #}
     {% endif %}

+ 11 - 1
archetypes/compose/service-deploy-v1.j2

@@ -1,3 +1,4 @@
+{# @requires services.*.deploy #}
     {% if swarm_enabled or resources_enabled %}
     deploy:
       {% if swarm_enabled %}
@@ -5,6 +6,11 @@
       {% if swarm_placement_mode == 'replicated' %}
       replicas: {{ swarm_replicas }}
       {% endif %}
+      {% if swarm_placement_host %}
+      placement:
+        constraints:
+          - node.hostname == {{ swarm_placement_host }}
+      {% endif %}
       restart_policy:
         condition: on-failure
       {% endif %}
@@ -23,22 +29,26 @@
       labels:
         - traefik.enable=true
         - traefik.docker.network={{ traefik_network }}
-        - traefik.http.services.{{ service_name }}-web.loadBalancer.server.port=80
+        - traefik.http.services.{{ service_name }}-web.loadBalancer.server.port=__ANYINT__
         - traefik.http.routers.{{ service_name }}-http.service={{ service_name }}-web
         - traefik.http.routers.{{ service_name }}-http.rule=Host(`{{ traefik_host }}`)
         - traefik.http.routers.{{ service_name }}-http.entrypoints={{ traefik_entrypoint }}
+        {# @optional-start #}
         {% if authentik_enabled %}
         - traefik.http.routers.{{ service_name }}-http.middlewares={{ authentik_traefik_middleware }}
         {% endif %}
+        {# @optional-end #}
         {% if traefik_tls_enabled %}
         - traefik.http.routers.{{ service_name }}-https.service={{ service_name }}-web
         - traefik.http.routers.{{ service_name }}-https.rule=Host(`{{ traefik_host }}`)
         - traefik.http.routers.{{ service_name }}-https.entrypoints={{ traefik_tls_entrypoint }}
         - traefik.http.routers.{{ service_name }}-https.tls=true
         - traefik.http.routers.{{ service_name }}-https.tls.certresolver={{ traefik_tls_certresolver }}
+        {# @optional-start #}
         {% if authentik_enabled %}
         - traefik.http.routers.{{ service_name }}-https.middlewares={{ authentik_traefik_middleware }}
         {% endif %}
+        {# @optional-end #}
         {% endif %}
       {% endif %}
     {% endif %}

+ 3 - 2
archetypes/compose/service-environment-v1.j2

@@ -1,9 +1,10 @@
+{# @requires services.*.environment #}
     environment:
       - TZ={{ container_timezone }}
-      - UID={{ user_uid }}
-      - GID={{ user_gid }}
+      {# @optional-start #}
       {% if swarm_enabled %}
       - SECRET=/run/secrets/{{ service_name }}_secret_1
       {% else %}
       - SECRET=${SECRET}
       {% endif %}
+      {# @optional-end #}

+ 5 - 0
archetypes/compose/service-labels-v1.j2

@@ -1,3 +1,4 @@
+{# @requires services.*.labels #}
     {% if traefik_enabled and not swarm_enabled %}
     labels:
       - traefik.enable=true
@@ -6,17 +7,21 @@
       - traefik.http.routers.{{ service_name }}-http.service={{ service_name }}-web
       - traefik.http.routers.{{ service_name }}-http.rule=Host(`{{ traefik_host }}`)
       - traefik.http.routers.{{ service_name }}-http.entrypoints={{ traefik_entrypoint }}
+      {# @optional-start #}
       {% if authentik_enabled %}
       - traefik.http.routers.{{ service_name }}-http.middlewares={{ authentik_traefik_middleware }}
       {% endif %}
+      {# @optional-end #}
       {% if traefik_tls_enabled %}
       - traefik.http.routers.{{ service_name }}-https.service={{ service_name }}-web
       - traefik.http.routers.{{ service_name }}-https.rule=Host(`{{ traefik_host }}`)
       - traefik.http.routers.{{ service_name }}-https.entrypoints={{ traefik_tls_entrypoint }}
       - traefik.http.routers.{{ service_name }}-https.tls=true
       - traefik.http.routers.{{ service_name }}-https.tls.certresolver={{ traefik_tls_certresolver }}
+      {# @optional-start #}
       {% if authentik_enabled %}
       - traefik.http.routers.{{ service_name }}-https.middlewares={{ authentik_traefik_middleware }}
       {% endif %}
+      {# @optional-end #}
       {% endif %}
     {% endif %}

+ 1 - 0
archetypes/compose/service-networks-v1.j2

@@ -1,3 +1,4 @@
+{# @requires services #}
     {% if network_mode == 'host' %}
     network_mode: host
     {% else %}

+ 8 - 3
archetypes/compose/service-ports-v1.j2

@@ -1,11 +1,16 @@
+{# @requires services.*.ports #}
     {% if not traefik_enabled and network_mode == 'bridge' %}
     ports:
       {% if swarm_enabled %}
-      - target: 80
-        published: {{ ports_http }}
+      {# @repeat-start #}
+      - target: __ANYINT__
+        published: {{ ports__ANYSTR__ }}
         protocol: tcp
         mode: host
+      {# @repeat-end #}
       {% else %}
-      - "{{ ports_http }}:80"
+      {# @repeat-start #}
+      - __ANY__
+      {# @repeat-end #}
       {% endif %}
     {% endif %}

+ 4 - 1
archetypes/compose/service-secrets-v1.j2

@@ -1,4 +1,7 @@
+{# @requires services.*.secrets #}
 {% if swarm_enabled %}
+    {# @repeat-start #}
     secrets:
-      - {{ service_name }}_secret_1
+      - {{ service_name }}__ANYSTR__
+    {# @repeat-end #}
 {% endif %}

+ 2 - 1
archetypes/compose/service-v1.j2

@@ -1,6 +1,7 @@
+{# @requires services #}
 services:
   {{ service_name }}:
-    image: testapp:latest
+    image: __ANY__
     {% if not swarm_enabled %}
     restart: {{ restart_policy }}
     container_name: {{ container_name }}

+ 18 - 5
archetypes/compose/service-volumes-v2.j2

@@ -1,13 +1,26 @@
+{# @requires services.*.volumes #}
     volumes:
+      {# @optional-start #}
       {% if volume_mode == 'mount' %}
-      - {{ volume_mount_path }}/data:/data:rw
-      {% else %}
-      - testapp_volume:/data
+      {# @repeat-start #}
+      - {{ volume_mount_path }}/__ANYPATH__:__ANYPATH__:rw
+      {# @repeat-end #}
+      {% elif volume_mode in ['local', 'nfs'] %}
+      {# @repeat-start #}
+      - {{ service_name }}-__ANYSTR__:__ANYPATH__
+      {# @repeat-end #}
       {% endif %}
+      {# @optional-end #}
+      {# @optional-start #}
       {% if not swarm_enabled %}
       {% if volume_mode == 'mount' %}
-      - {{ volume_mount_path }}/config/testapp.yaml:/etc/app/config.yaml:ro
+      {# @repeat-start #}
+      - {{ volume_mount_path }}/config/__ANYPATH__:__ANYPATH__:ro
+      {# @repeat-end #}
       {% else %}
-      - ./config/testapp.yaml:/etc/app/config.yaml:ro
+      {# @repeat-start #}
+      - ./config/__ANYPATH__:__ANYPATH__:ro
+      {# @repeat-end #}
       {% endif %}
       {% endif %}
+      {# @optional-end #}

+ 9 - 5
archetypes/compose/volumes-v2.j2

@@ -1,13 +1,17 @@
-{% if volume_mode == 'local' %}
+{# @requires volumes #}
 volumes:
-  testapp_volume:
+{% if volume_mode == 'local' %}
+  {# @repeat-start #}
+  {{ service_name }}-__ANYSTR__:
     driver: local
+  {# @repeat-end #}
 {% elif volume_mode == 'nfs' %}
-volumes:
-  testapp_volume:
+  {# @repeat-start #}
+  {{ service_name }}-__ANYSTR__:
     driver: local
     driver_opts:
       type: nfs
       o: addr={{ volume_nfs_server }},{{ volume_nfs_options }}
-      device: ":{{ volume_nfs_path }}"
+      device: ":{{ volume_nfs_path }}__ANY__"
+  {# @repeat-end #}
 {% endif %}

+ 2 - 2
cli/core/display/__init__.py

@@ -131,9 +131,9 @@ class DisplayManager:
         """Display an error message."""
         return self.status.error(message, context, details)
 
-    def warning(self, message: str, context: str | None = None) -> None:
+    def warning(self, message: str, context: str | None = None, details: str | None = None) -> None:
         """Display a warning message."""
-        return self.status.warning(message, context)
+        return self.status.warning(message, context, details)
 
     def success(self, message: str, context: str | None = None) -> None:
         """Display a success message."""

+ 17 - 2
cli/core/display/display_status.py

@@ -147,14 +147,29 @@ class StatusDisplay:
             # No details, use standard display
             self._display_message("error", message, context)
 
-    def warning(self, message: str, context: str | None = None) -> None:
+    def warning(self, message: str, context: str | None = None, details: str | None = None) -> None:
         """Display a warning message.
 
         Args:
             message: Warning message
             context: Optional context
+            details: Optional additional details (shown in dim style on same line)
         """
-        self._display_message("warning", message, context)
+        if details:
+            # Combine message and details on same line with different formatting
+            settings = self.settings
+            color = settings.COLOR_WARNING
+            icon = IconManager.get_status_icon("warning")
+
+            # Format: Icon Warning: Message (details in dim)
+            formatted = f"[{color}]{icon} Warning: {message}[/{color}] [dim]({details})[/dim]"
+            console_err.print(formatted)
+
+            # Log at debug level to avoid duplicate console output (already printed to stderr)
+            logger.debug(f"Warning displayed: {message} ({details})")
+        else:
+            # No details, use standard display
+            self._display_message("warning", message, context)
 
     def success(self, message: str, context: str | None = None) -> None:
         """Display a success message.

+ 5 - 5
cli/core/display/display_template.py

@@ -3,6 +3,11 @@ from __future__ import annotations
 from pathlib import Path
 from typing import TYPE_CHECKING
 
+from rich import box
+from rich.console import Console
+from rich.panel import Panel
+from rich.text import Text
+
 from .display_icons import IconManager
 from .display_settings import DisplaySettings
 
@@ -70,13 +75,8 @@ class TemplateDisplay:
         library_type = template.metadata.library_type or "git"
         icon = IconManager.UI_LIBRARY_STATIC if library_type == "static" else IconManager.UI_LIBRARY_GIT
         color = "yellow" if library_type == "static" else "blue"
-        library_display = f"[{color}]{icon} {library_name}[/{color}]"
 
         # Create custom H1-style header with Rich markup support
-        from rich import box
-        from rich.console import Console
-        from rich.panel import Panel
-        from rich.text import Text
 
         # Build header content with Rich formatting
         header_content = Text()

+ 1 - 1
cli/core/input/prompt_manager.py

@@ -91,7 +91,7 @@ class PromptHandler:
 
         collected: dict[str, Any] = {}
 
-        for section_key, section in variables.get_sections().items():
+        for _section_key, section in variables.get_sections().items():
             if not section.variables:
                 continue
 

+ 3 - 1
cli/core/library.py

@@ -166,7 +166,9 @@ class LibraryManager:
     def _warn_missing_library(self, name: str, library_path: Path, lib_type: str) -> None:
         """Log warning about missing library."""
         if lib_type == "git":
-            logger.warning(f"Library '{name}' not found at {library_path}. Run 'boilerplates repo update' to sync libraries.")
+            logger.warning(
+                f"Library '{name}' not found at {library_path}. Run 'boilerplates repo update' to sync libraries."
+            )
         else:
             logger.warning(f"Static library '{name}' not found at {library_path}")
 

+ 45 - 18
cli/core/module/base_commands.py

@@ -39,6 +39,7 @@ class GenerationConfig:
 
     id: str
     directory: str | None = None
+    output: str | None = None
     interactive: bool = True
     var: list[str] | None = None
     var_file: str | None = None
@@ -229,7 +230,7 @@ def check_output_directory(
     return existing_files
 
 
-def get_generation_confirmation(ctx: ConfirmationContext) -> bool:
+def get_generation_confirmation(_ctx: ConfirmationContext) -> bool:
     """Display file generation confirmation and get user approval."""
     # No confirmation needed - either non-interactive, dry-run, or already confirmed during directory check
     return True
@@ -309,11 +310,11 @@ def execute_dry_run(
     return len(rendered_files), overwrite_files, size_str
 
 
-def write_generated_files(
+def write_rendered_files(
     output_dir: Path,
     rendered_files: dict[str, str],
-    quiet: bool,
-    display: DisplayManager,
+    _quiet: bool,
+    _display: DisplayManager,
 ) -> None:
     """Write rendered files to the output directory."""
     output_dir.mkdir(parents=True, exist_ok=True)
@@ -371,18 +372,33 @@ def _render_template(template, id: str, display: DisplayManager, interactive: bo
     return rendered_files, variable_values
 
 
-def _determine_output_dir(directory: str | None, id: str) -> Path:
-    """Determine and normalize output directory path."""
-    if directory:
+def _determine_output_dir(directory: str | None, output: str | None, id: str) -> tuple[Path, bool]:
+    """Determine and normalize output directory path.
+    
+    Returns:
+        Tuple of (output_dir, used_deprecated_arg) where used_deprecated_arg indicates
+        if the deprecated positional directory argument was used.
+    """
+    used_deprecated_arg = False
+    
+    # Priority: --output flag > positional directory argument > template ID
+    if output:
+        output_dir = Path(output)
+    elif directory:
         output_dir = Path(directory)
-        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}")
+        used_deprecated_arg = True
+        logger.debug(f"Using deprecated positional directory argument: {directory}")
     else:
         output_dir = Path(id)
-    return output_dir
+    
+    # Normalize paths that look like absolute paths but are relative
+    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}")
+    
+    return output_dir, used_deprecated_arg
 
 
 def _display_template_error(display: DisplayManager, template_id: str, error: TemplateRenderError) -> None:
@@ -409,20 +425,24 @@ def _display_generic_error(display: DisplayManager, template_id: str, error: Exc
     display.text("")
 
     # Truncate long error messages
+    max_error_length = 100
     error_msg = str(error)
-    if len(error_msg) > 100:
-        error_msg = error_msg[:100] + "..."
+    if len(error_msg) > max_error_length:
+        error_msg = f"{error_msg[:max_error_length]}..."
 
     # Display error with details
     display.error(f"Failed to generate boilerplate from template '{template_id}'", details=error_msg)
 
 
-def generate_template(module_instance, config: GenerationConfig) -> None:
+def generate_template(module_instance, config: GenerationConfig) -> None:  # noqa: PLR0912, PLR0915
     """Generate from template."""
     logger.info(f"Starting generation for template '{config.id}' from module '{module_instance.name}'")
 
     display = DisplayManager(quiet=config.quiet) if config.quiet else module_instance.display
     template = _prepare_template(module_instance, config.id, config.var_file, config.var, display)
+    
+    # Determine output directory early to check for deprecated argument usage
+    output_dir, used_deprecated_arg = _determine_output_dir(config.directory, config.output, config.id)
 
     if not config.quiet:
         # Display template header
@@ -432,10 +452,17 @@ def generate_template(module_instance, config: GenerationConfig) -> None:
         # Display variables table
         module_instance.display.variables.render_variables_table(template)
         module_instance.display.text("")
+        
+        # Show deprecation warning BEFORE any user interaction
+        if used_deprecated_arg:
+            module_instance.display.warning(
+                "Using positional argument for output directory is deprecated and will be removed in v0.2.0",
+                details="Use --output/-o flag instead"
+            )
+            module_instance.display.text("")
 
     try:
         rendered_files, variable_values = _render_template(template, config.id, display, config.interactive)
-        output_dir = _determine_output_dir(config.directory, config.id)
 
         # Check for conflicts and get confirmation (skip in quiet mode)
         if not config.quiet:
@@ -462,7 +489,7 @@ def generate_template(module_instance, config: GenerationConfig) -> None:
             if not config.quiet:
                 dry_run_stats = execute_dry_run(config.id, output_dir, rendered_files, config.show_files, display)
         else:
-            write_generated_files(output_dir, rendered_files, config.quiet, display)
+            write_rendered_files(output_dir, rendered_files, config.quiet, display)
 
         # Display next steps (not in quiet mode)
         if template.metadata.next_steps and not config.quiet:

+ 13 - 1
cli/core/module/base_module.py

@@ -167,8 +167,19 @@ class Module(ABC):
     def generate(
         self,
         id: Annotated[str, Argument(help="Template ID")],
-        directory: Annotated[str | None, Argument(help="Output directory (defaults to template ID)")] = None,
+        directory: Annotated[
+            str | None, 
+            Argument(help="[DEPRECATED: use --output] Output directory (defaults to template ID)")
+        ] = None,
         *,
+        output: Annotated[
+            str | None,
+            Option(
+                "--output",
+                "-o",
+                help="Output directory (defaults to template ID)",
+            ),
+        ] = None,
         interactive: Annotated[
             bool,
             Option(
@@ -218,6 +229,7 @@ class Module(ABC):
         config = GenerationConfig(
             id=id,
             directory=directory,
+            output=output,
             interactive=interactive,
             var=var,
             var_file=var_file,

+ 4 - 4
cli/core/repo.py

@@ -12,7 +12,7 @@ from rich.table import Table
 from typer import Argument, Option, Typer
 
 from ..core.config import ConfigManager, LibraryConfig
-from ..core.display import DisplayManager
+from ..core.display import DisplayManager, IconManager
 from ..core.exceptions import ConfigError
 
 logger = logging.getLogger(__name__)
@@ -267,8 +267,6 @@ def _get_library_path_for_static(lib: dict, config: ConfigManager) -> Path:
 
 def _get_library_info(lib: dict, config: ConfigManager, libraries_path: Path) -> tuple[str, str, str, str, str, str]:
     """Extract library information based on type."""
-    from cli.core.display import IconManager
-
     name = lib.get("name", "")
     lib_type = lib.get("type", "git")
     enabled = lib.get("enabled", True)
@@ -339,7 +337,9 @@ def list() -> None:
 
     for lib in libraries:
         name = lib.get("name", "")
-        url_or_path, branch, directory, type_display, type_icon, status = _get_library_info(lib, config, libraries_path)
+        url_or_path, branch, directory, type_display, _type_icon, status = _get_library_info(
+            lib, config, libraries_path
+        )
         table.add_row(name, url_or_path, branch, directory, type_display, status)
 
     display.print_table(table)

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

@@ -6,21 +6,16 @@ metadata:
   description: |
     A **complete DevOps platform** that provides Git repository management, CI/CD pipelines,
     issue tracking, and container registry in a single application.
-    
     ## Important Configuration Notes
-    
     **Performance Presets**:
     - `homelab`: Optimized for low-resource environments (limited workers, reduced PostgreSQL buffers)
     - `default`: Standard server configuration for production use
-    
     **External URL**:
     - Set to your public domain (e.g., `https://gitlab.example.com`) for proper clone URLs
     - Affects SSH clone URLs and web links in emails/notifications
-    
     **Container Registry**:
     - Enable if you need private Docker image hosting
     - Requires separate external URL (e.g., `https://registry.example.com`)
-    
     ## Resources
     - **Project**: https://about.gitlab.com/
     - **Documentation**: https://docs.gitlab.com/
@@ -32,18 +27,15 @@ metadata:
     - traefik
   next_steps: |
     ## Post-Installation Steps
-    
     1. **Start GitLab**:
        ```bash
        docker compose up -d
        ```
-    
     2. **Wait for initialization** (2-5 minutes):
        ```bash
        docker compose logs -f gitlab
        ```
        Wait for message: `gitlab Reconfigured!`
-    
     3. **Access the web interface**:
        {% if traefik_enabled -%}
        - Via Traefik: https://{{ traefik_host }}
@@ -52,18 +44,14 @@ metadata:
        - Open {{ external_url }} in your browser
        {% if network_mode == 'bridge' %}- Or: http://localhost:{{ ports_http }}{% endif %}
        {%- endif %}
-    
     4. **Initial login credentials**:
        - **Username**: `root`
        - **Password**: `{{ root_password }}`
-       
        > **Important**: This password only works on FIRST initialization.
        > Change it immediately after first login via GitLab's web interface!
-    
     5. **Configure SSH** (optional):
        - SSH clone URLs will use port `{{ ports_ssh }}`
        - Update your Git remote if needed
-    
     ## Additional Resources
     - Documentation: https://docs.gitlab.com/
     - GitLab Runner: https://docs.gitlab.com/runner/

+ 51 - 46
library/compose/pihole/compose.yaml.j2

@@ -1,15 +1,18 @@
+---
 services:
   {{ service_name }}:
+    image: docker.io/pihole/pihole:2025.11.0
     {% if not swarm_enabled %}
+    restart: {{ restart_policy }}
     container_name: {{ container_name }}
     {% endif %}
-    image: docker.io/pihole/pihole:2025.11.0
+    hostname: {{ container_hostname }}
     environment:
       - TZ={{ container_timezone }}
       - PIHOLE_UID={{ user_uid }}
       - PIHOLE_GID={{ user_gid }}
       {% if swarm_enabled %}
-      - WEBPASSWORD_FILE={{ webpassword_secret_name }}
+      - WEBPASSWORD_FILE={{ service_name }}_webpassword
       {% else %}
       - FTLCONF_webserver_api_password=${WEBPASSWORD}
       {% endif %}
@@ -30,7 +33,7 @@ services:
       {{ network_name }}:
       {% endif %}
     {% endif %}
-    {% if network_mode not in ['host', 'macvlan'] %}
+    {% if not traefik_enabled and network_mode == 'bridge' %}
     ports:
       {% if not traefik_enabled %}
       {% if swarm_enabled %}
@@ -67,34 +70,43 @@ services:
       {% endif %}
     {% endif %}
     volumes:
-      {% if not swarm_enabled %}
-      - config_dnsmasq:/etc/dnsmasq.d
-      - config_pihole:/etc/pihole
-      {% else %}
       {% if volume_mode == 'mount' %}
       - {{ volume_mount_path }}/dnsmasq:/etc/dnsmasq.d:rw
       - {{ volume_mount_path }}/pihole:/etc/pihole:rw
-      {% elif volume_mode == 'local' %}
-      - config_dnsmasq:/etc/dnsmasq.d
-      - config_pihole:/etc/pihole
-      {% elif volume_mode == 'nfs' %}
-      - config_dnsmasq:/etc/dnsmasq.d
-      - config_pihole:/etc/pihole
-      {% endif %}
+      {% elif volume_mode in ['local', 'nfs'] %}
+      - {{ service_name }}-dnsmasq:/etc/dnsmasq.d
+      - {{ service_name }}-pihole:/etc/pihole
       {% endif %}
     cap_add:
       - NET_ADMIN
       - SYS_TIME
     {% if swarm_enabled %}
     secrets:
-      - {{ webpassword_secret_name }}
+      - {{ service_name }}_webpassword
+    {% endif %}
+    {% if swarm_enabled or resources_enabled %}
     deploy:
+      {% if swarm_enabled %}
       mode: replicated
       replicas: 1
       placement:
         constraints:
           - node.hostname == {{ swarm_placement_host }}
-      {% if traefik_enabled %}
+      restart_policy:
+        condition: on-failure
+      {% endif %}
+      {% if resources_enabled %}
+      resources:
+        limits:
+          cpus: '{{ resources_cpu_limit }}'
+          memory: {{ resources_memory_limit }}
+        {% if swarm_enabled %}
+        reservations:
+          cpus: '{{ resources_cpu_reservation }}'
+          memory: {{ resources_memory_reservation }}
+        {% endif %}
+      {% endif %}
+      {% if swarm_enabled and traefik_enabled %}
       labels:
         - traefik.enable=true
         - traefik.docker.network={{ traefik_network }}
@@ -110,8 +122,8 @@ services:
         - traefik.http.routers.{{ service_name }}-https.tls.certresolver={{ traefik_tls_certresolver }}
         {% endif %}
       {% endif %}
-    {% else %}
-    {% if traefik_enabled %}
+    {% endif %}
+    {% if traefik_enabled and not swarm_enabled %}
     labels:
       - traefik.enable=true
       - traefik.docker.network={{ traefik_network }}
@@ -127,45 +139,42 @@ services:
       - traefik.http.routers.{{ service_name }}-https.tls.certresolver={{ traefik_tls_certresolver }}
       {% endif %}
     {% endif %}
-    restart: {{ restart_policy }}
-    {% endif %}
 
 {% if swarm_enabled %}
-{% if volume_mode in ['local', 'nfs'] %}
+secrets:
+  {{ service_name }}_webpassword:
+    file: ./.env.secret.webpassword
+{% endif %}
+
+{% if volume_mode == 'local' %}
 volumes:
-  config_dnsmasq:
-    {% if volume_mode == 'nfs' %}
+  {{ service_name }}-dnsmasq:
+    driver: local
+  {{ service_name }}-pihole:
+    driver: local
+{% elif volume_mode == 'nfs' %}
+volumes:
+  {{ service_name }}-dnsmasq:
     driver: local
     driver_opts:
       type: nfs
       o: addr={{ volume_nfs_server }},{{ volume_nfs_options }}
       device: ":{{ volume_nfs_path }}/dnsmasq"
-    {% endif %}
-  config_pihole:
-    {% if volume_mode == 'nfs' %}
+  {{ service_name }}-pihole:
     driver: local
     driver_opts:
       type: nfs
       o: addr={{ volume_nfs_server }},{{ volume_nfs_options }}
       device: ":{{ volume_nfs_path }}/pihole"
-    {% endif %}
-{% endif %}
-
-secrets:
-  {{ webpassword_secret_name }}:
-    file: ./.env.secret
-{% else %}
-volumes:
-  config_dnsmasq:
-    driver: local
-  config_pihole:
-    driver: local
 {% endif %}
 
 {% if network_mode != 'host' %}
 networks:
-  {% if network_mode == 'macvlan' %}
   {{ network_name }}:
+    {% if network_external %}
+    external: true
+    {% else %}
+    {% if network_mode == 'macvlan' %}
     driver: macvlan
     driver_opts:
       parent: {{ network_macvlan_parent_interface }}
@@ -173,18 +182,14 @@ networks:
       config:
         - subnet: {{ network_macvlan_subnet }}
           gateway: {{ network_macvlan_gateway }}
-  {% elif network_mode == 'bridge' and network_external %}
-  {{ network_name }}:
-    external: true
-  {% elif network_mode == 'bridge' and not network_external %}
-  {{ network_name }}:
-    {% if swarm_enabled %}
+    name: {{ network_name }}
+    {% elif swarm_enabled %}
     driver: overlay
     attachable: true
     {% else %}
     driver: bridge
     {% endif %}
-  {% endif %}
+    {% endif %}
   {% if traefik_enabled %}
   {{ traefik_network }}:
     external: true

+ 0 - 89
test_clean_error.py

@@ -1,89 +0,0 @@
-#!/usr/bin/env python3
-"""Test cleaner error display."""
-
-from cli.core.display import DisplayManager
-from cli.core.exceptions import RenderErrorContext, TemplateRenderError
-
-
-def test_template_render_error():
-    """Simulate a clean template rendering error."""
-    display = DisplayManager()
-
-    # Show some context before the error
-    print("┏" + "━" * 78 + "┓")
-    print("┃ Nginx (id:nginx │ version:1.25.3 │ schema:1.1 │ library: default)     " + " " * 18 + "┃")
-    print("┗" + "━" * 78 + "┛")
-    print()
-    print("Nginx web server template.")
-    print()
-    print("Template File Structure")
-    print()
-    print(" nginx")
-    print("├──  compose.yaml")
-    print("└──  .env")
-    print()
-    print("Customize any settings? [y/n] (n): n")
-
-    # Create error with context
-    context = RenderErrorContext(file_path="compose.yaml.j2", line_number=25)
-    error = TemplateRenderError("Undefined variable 'missing_var'", context=context)
-
-    # Display using the new clean format
-    display.text("")
-    display.text("─" * 80, style="dim")
-    display.text("")
-    display.error("Failed to generate boilerplate from template 'nginx'")
-    display.text(f"  {error.file_path}:line {error.line_number}", style="dim")
-
-
-def test_generic_error():
-    """Simulate a clean generic error."""
-    display = DisplayManager()
-
-    print("\n\n" + "=" * 80)
-    print("SCENARIO 2: File Permission Error")
-    print("=" * 80 + "\n")
-
-    # Show some context
-    print("Boilerplate generated successfully in '/protected/path'")
-
-    # Display error
-    error_msg = "[Errno 13] Permission denied: '/protected/path/compose.yaml'"
-
-    display.text("")
-    display.text("─" * 80, style="dim")
-    display.text("")
-    display.error("Failed to generate boilerplate from template 'nginx'")
-    display.text(f"  {error_msg}", style="dim")
-
-
-def test_long_error():
-    """Simulate error with long message that gets truncated."""
-    display = DisplayManager()
-
-    print("\n\n" + "=" * 80)
-    print("SCENARIO 3: Long Error Message (truncated)")
-    print("=" * 80 + "\n")
-
-    error_msg = "Template validation failed: services.nginx.ports configuration is invalid. Expected a list of port mappings but got a string. Please check your Docker Compose syntax and ensure ports are defined as a list."
-
-    if len(error_msg) > 100:
-        error_msg = error_msg[:100] + "..."
-
-    display.text("")
-    display.text("─" * 80, style="dim")
-    display.text("")
-    display.error("Failed to generate boilerplate from template 'nginx'")
-    display.text(f"  {error_msg}", style="dim")
-
-
-if __name__ == "__main__":
-    print("\n" + "=" * 80)
-    print("SCENARIO 1: Template Render Error")
-    print("=" * 80 + "\n")
-
-    test_template_render_error()
-    test_generic_error()
-    test_long_error()
-
-    print("\n")

+ 0 - 134
test_error_display.py

@@ -1,134 +0,0 @@
-#!/usr/bin/env python3
-"""Interactive test script to see clean error display in action."""
-
-from cli.core.display import DisplayManager
-from cli.core.exceptions import RenderErrorContext, TemplateRenderError
-
-
-def show_header():
-    """Show template header like in real generation."""
-    print("┏" + "━" * 78 + "┓")
-    print("┃ Nginx (id:nginx │ version:1.25.3 │ schema:1.1 │ library: default)     " + " " * 18 + "┃")
-    print("┗" + "━" * 78 + "┛")
-    print()
-    print("Nginx web server template with reverse proxy support.")
-    print()
-    print("Template File Structure")
-    print()
-    print(" nginx")
-    print("├──  compose.yaml")
-    print("└──  .env")
-    print()
-
-
-def scenario_1_template_error():
-    """Scenario 1: Template rendering error with file location."""
-    display = DisplayManager()
-
-    print("\n" + "=" * 80)
-    print("SCENARIO 1: Template Render Error (with file location)")
-    print("=" * 80 + "\n")
-
-    show_header()
-    print("Customize any settings? [y/n] (n): n")
-
-    # Create error with context
-    context = RenderErrorContext(file_path="compose.yaml.j2", line_number=25)
-    error = TemplateRenderError("Undefined variable 'missing_var'", context=context)
-
-    # Display clean error
-    display.text("")
-    display.text("─" * 80, style="dim")
-    display.text("")
-
-    details = error.file_path
-    if error.line_number:
-        details += f":line {error.line_number}"
-
-    display.error("Failed to generate boilerplate from template 'nginx'", details=details)
-
-
-def scenario_2_permission_error():
-    """Scenario 2: File permission error."""
-    display = DisplayManager()
-
-    print("\n\n" + "=" * 80)
-    print("SCENARIO 2: File Permission Error")
-    print("=" * 80 + "\n")
-
-    show_header()
-    print("Customize any settings? [y/n] (n): n")
-    print()
-    print(" Warning: Directory '/protected/path' is not empty. 2 file(s) will be overwritten.")
-    print()
-    print("Continue? [y/n] (n): y")
-
-    # Display error
-    display.text("")
-    display.text("─" * 80, style="dim")
-    display.text("")
-
-    error_msg = "[Errno 13] Permission denied: '/protected/path/compose.yaml'"
-    display.error("Failed to generate boilerplate from template 'nginx'", details=error_msg)
-
-
-def scenario_3_validation_error():
-    """Scenario 3: Long validation error (truncated)."""
-    display = DisplayManager()
-
-    print("\n\n" + "=" * 80)
-    print("SCENARIO 3: Validation Error (truncated)")
-    print("=" * 80 + "\n")
-
-    show_header()
-    print("Customize any settings? [y/n] (n): n")
-
-    # Display error
-    display.text("")
-    display.text("─" * 80, style="dim")
-    display.text("")
-
-    error_msg = "Template validation failed: services.nginx.ports configuration is invalid. Expected a list of port mappings but got a string. Please check your Docker Compose syntax."
-    if len(error_msg) > 100:
-        error_msg = error_msg[:100] + "..."
-
-    display.error("Failed to generate boilerplate from template 'nginx'", details=error_msg)
-
-
-def scenario_4_success_comparison():
-    """Scenario 4: Show successful generation for comparison."""
-    display = DisplayManager()
-
-    print("\n\n" + "=" * 80)
-    print("SCENARIO 4: Successful Generation (for comparison)")
-    print("=" * 80 + "\n")
-
-    show_header()
-    print("Customize any settings? [y/n] (n): n")
-
-    # Display success
-    display.text("")
-    display.text("─" * 80, style="dim")
-
-    display.success("Boilerplate generated successfully in 'nginx'")
-
-
-if __name__ == "__main__":
-    print("\nThis script demonstrates the clean error display formatting.")
-    print("Pay attention to the separator line, error icon, and details formatting.\n")
-
-    input("Press ENTER to see Scenario 1 (Template Error with file location)...")
-    scenario_1_template_error()
-
-    input("\n\nPress ENTER to see Scenario 2 (Permission Error)...")
-    scenario_2_permission_error()
-
-    input("\n\nPress ENTER to see Scenario 3 (Long Validation Error)...")
-    scenario_3_validation_error()
-
-    input("\n\nPress ENTER to see Scenario 4 (Success for comparison)...")
-    scenario_4_success_comparison()
-
-    print("\n\n" + "=" * 80)
-    print("All scenarios complete!")
-    print("=" * 80 + "\n")