Przeglądaj źródła

release(v0.2.0): prepare template runtime rollout

xcad 1 miesiąc temu
rodzic
commit
6edd149ccb
100 zmienionych plików z 2637 dodań i 6396 usunięć
  1. BIN
      .assets/banner.jpg
  2. 7 6
      .wiki/Core-Concepts-Defaults.md
  3. 70 314
      .wiki/Core-Concepts-Libraries.md
  4. 187 344
      .wiki/Core-Concepts-Templates.md
  5. 238 546
      .wiki/Core-Concepts-Variables.md
  6. 58 227
      .wiki/Getting-Started.md
  7. 20 35
      .wiki/Home.md
  8. 17 17
      .wiki/Installation.md
  9. 0 63
      .wiki/Variables-Ansible.md
  10. 0 214
      .wiki/Variables-Compose.md
  11. 0 125
      .wiki/Variables-Helm.md
  12. 0 84
      .wiki/Variables-Kubernetes.md
  13. 0 35
      .wiki/Variables-Packer.md
  14. 0 36
      .wiki/Variables-Terraform.md
  15. 27 17
      .wiki/Variables.md
  16. 2 14
      .wiki/_Sidebar.md
  17. 57 105
      AGENTS.md
  18. 9 0
      CHANGELOG.md
  19. 17 35
      README.md
  20. 0 1
      WARP.md
  21. 1 1
      cli/__init__.py
  22. 11 0
      cli/__main__.py
  23. 95 10
      cli/core/config/config_manager.py
  24. 1 1
      cli/core/display/display_base.py
  25. 1 1
      cli/core/display/display_icons.py
  26. 5 7
      cli/core/display/display_table.py
  27. 1 5
      cli/core/display/display_template.py
  28. 84 9
      cli/core/display/display_variable.py
  29. 0 35
      cli/core/exceptions.py
  30. 54 0
      cli/core/input/input_manager.py
  31. 68 16
      cli/core/input/prompt_manager.py
  32. 82 26
      cli/core/library.py
  33. 109 129
      cli/core/module/base_commands.py
  34. 23 20
      cli/core/module/base_module.py
  35. 203 0
      cli/core/module/generation_destination.py
  36. 0 270
      cli/core/prompt.py
  37. 30 2
      cli/core/repo.py
  38. 0 17
      cli/core/schema/__init__.py
  39. 0 15
      cli/core/schema/ansible/v1.0.json
  40. 0 229
      cli/core/schema/compose/v1.0.json
  41. 0 312
      cli/core/schema/compose/v1.1.json
  42. 0 528
      cli/core/schema/compose/v1.2.json
  43. 0 202
      cli/core/schema/helm/v1.0.json
  44. 0 247
      cli/core/schema/kubernetes/v1.0.json
  45. 0 220
      cli/core/schema/loader.py
  46. 0 14
      cli/core/schema/packer/v1.0.json
  47. 0 87
      cli/core/schema/terraform/v1.0.json
  48. 10 1
      cli/core/template/__init__.py
  49. 454 700
      cli/core/template/template.py
  50. 487 221
      cli/core/template/variable.py
  51. 11 12
      cli/core/template/variable_collection.py
  52. 3 2
      cli/core/template/variable_section.py
  53. 1 75
      cli/modules/ansible/__init__.py
  54. 3 89
      cli/modules/compose/__init__.py
  55. 1 74
      cli/modules/helm/__init__.py
  56. 1 75
      cli/modules/kubernetes/__init__.py
  57. 1 74
      cli/modules/packer/__init__.py
  58. 63 0
      cli/modules/swarm/__init__.py
  59. 1 75
      cli/modules/terraform/__init__.py
  60. 2 4
      library/ansible/checkmk-install-agent/template.yaml
  61. 2 4
      library/ansible/checkmk-manage-host/template.yaml
  62. 0 1
      library/ansible/docker-certs-enable/template.yaml
  63. 0 1
      library/ansible/docker-certs/template.yaml
  64. 0 1
      library/ansible/docker-install-ubuntu/template.yaml
  65. 0 1
      library/ansible/docker-prune/template.yaml
  66. 0 1
      library/ansible/ubuntu-add-sshkey/template.yaml
  67. 0 1
      library/ansible/ubuntu-apt-update/template.yaml
  68. 0 1
      library/ansible/ubuntu-vm-core/template.yaml
  69. 0 2
      library/compose/adguardhome/template.yaml
  70. 0 2
      library/compose/alloy/template.yaml
  71. 4 12
      library/compose/authentik/template.yaml
  72. 3 4
      library/compose/bind9/template.yaml
  73. 4 10
      library/compose/checkmk/template.yaml
  74. 0 1
      library/compose/dockge/template.yaml
  75. 0 1
      library/compose/gitea/template.yaml
  76. 0 2
      library/compose/gitlab-runner/template.yaml
  77. 8 37
      library/compose/gitlab/template.yaml
  78. 4 10
      library/compose/grafana/template.yaml
  79. 0 2
      library/compose/homeassistant/template.yaml
  80. 0 2
      library/compose/homepage/template.yaml
  81. 0 29
      library/compose/homer/template.yaml
  82. 3 10
      library/compose/influxdb/template.yaml
  83. 8 40
      library/compose/komodo/template.yaml
  84. 8 40
      library/compose/komodo/template.yaml.backup
  85. 0 1
      library/compose/loki/template.yaml
  86. 0 7
      library/compose/mariadb/template.yaml
  87. 18 15
      library/compose/n8n/template.yaml
  88. 8 14
      library/compose/netbox/template.yaml
  89. 5 5
      library/compose/nextcloud/template.yaml
  90. 4 3
      library/compose/nginx/template.yaml
  91. 0 1
      library/compose/openwebui/template.yaml
  92. 2 31
      library/compose/pangolin/template.yaml
  93. 0 1
      library/compose/passbolt/template.yaml
  94. 3 10
      library/compose/pihole/template.yaml
  95. 0 1
      library/compose/portainer/template.yaml
  96. 5 5
      library/compose/postgres/template.yaml
  97. 0 17
      library/compose/prometheus/template.yaml
  98. 17 14
      library/compose/renovate/template.yaml
  99. 10 16
      library/compose/semaphoreui/template.yaml
  100. 6 17
      library/compose/traefik/template.yaml

BIN
.assets/banner.jpg


+ 7 - 6
.wiki/Core-Concepts-Defaults.md

@@ -13,12 +13,13 @@ Save time by setting default values for variables you use frequently. This page
 
 Variables are resolved in this order (lowest to highest priority):
 
-1. Module spec (module-wide defaults)
-2. Template spec (template-specific defaults)
+1. Module runtime defaults
+2. Template defaults from `template.json`
 3. **User config** (your saved defaults) ← This page
-4. CLI arguments (`--var` flags)
+4. `--var-file`
+5. CLI arguments (`--var`)
 
-Your defaults override module and template values but can be overridden by CLI arguments.
+Your saved defaults override module and template defaults, but they can still be overridden at generation time.
 
 ## Managing Defaults
 
@@ -132,9 +133,9 @@ Example content:
 libraries:
   - name: default
     type: git
-    url: https://github.com/christianlempa/boilerplates
+    url: https://github.com/christianlempa/boilerplates-library.git
     branch: main
-    directory: library
+    directory: .
 
 defaults:
   compose:

+ 70 - 314
.wiki/Core-Concepts-Libraries.md

@@ -1,130 +1,35 @@
 # Libraries
 
-Libraries are collections of templates that can be synced from Git repositories or loaded from local directories. This page explains how to manage template libraries.
-
-## What is a Library?
-
-A **library** is a collection of templates organized by module type (compose, terraform, ansible, etc.). Libraries can be:
-- **Git-based** - Synced from remote repositories
-- **Static** - Local directories on your filesystem
+Libraries are sources of templates. A library can be a Git repository or a local directory, and Boilerplates searches them in priority order.
 
 ## Default Library
 
-By default, Boilerplates uses the official template library:
+By default, Boilerplates points to the official template library:
 
-```
+```text
 Name: default
-URL: https://github.com/christianlempa/boilerplates
+URL: https://github.com/christianlempa/boilerplates-library.git
 Branch: main
-Directory: library
-```
-
-This provides production-ready templates for various services and infrastructure.
-
-## Library Location
-
-Libraries are stored locally at:
-```
-~/.config/boilerplates/libraries/
-└── default/
-    └── library/
-        ├── compose/
-        ├── terraform/
-        └── ansible/
-```
-
-## Managing Libraries
-
-### List Libraries
-
-View all configured libraries:
-
-```bash
-boilerplates repo list
-```
-
-Output:
-```
-Libraries:
-  default (git)
-    URL: https://github.com/christianlempa/boilerplates
-    Branch: main
-    Directory: library
-    Status: Synced
-```
-
-### Update Libraries
-
-Sync all Git-based libraries:
-
-```bash
-boilerplates repo update
-```
-
-This:
-- Pulls latest changes from Git repositories
-- Uses sparse-checkout (only downloads template directories)
-- Updates metadata cache
-
-### Add Custom Library
-
-Add your own template library:
-
-```bash
-boilerplates repo add my-templates https://github.com/user/templates \
-  --directory library \
-  --branch main
-```
-
-Parameters:
-- **name** - Unique library identifier
-- **url** - Git repository URL
-- **--directory** - Path to templates within repository (default: `.`)
-- **--branch** - Git branch to use (default: `main`)
-
-### Remove Library
-
-Remove a library from configuration:
-
-```bash
-boilerplates repo remove my-templates
-```
-
-This removes the configuration but keeps downloaded files. To fully clean up:
-
-```bash
-rm -rf ~/.config/boilerplates/libraries/my-templates
+Directory: .
 ```
 
-## Library Types
+## Supported Library Types
 
-### Git Libraries
+- `git`
+- `static`
 
-Synced from remote Git repositories:
+### Git Library Example
 
 ```yaml
 libraries:
   - name: default
     type: git
-    url: https://github.com/christianlempa/boilerplates
+    url: https://github.com/christianlempa/boilerplates-library.git
     branch: main
-    directory: library
+    directory: .
 ```
 
-**Benefits:**
-- Always up-to-date
-- Version controlled
-- Easy to share
-- Automatic updates
-
-**Use cases:**
-- Official templates
-- Team-shared templates
-- Public template collections
-
-### Static Libraries
-
-Local directories on your filesystem:
+### Static Library Example
 
 ```yaml
 libraries:
@@ -133,260 +38,111 @@ libraries:
     path: ~/my-templates
 ```
 
-**Benefits:**
-- No network required
-- Full control
-- Fast access
-- Development/testing
+## Local Storage
 
-**Use cases:**
-- Local development
-- Private templates
-- Custom modifications
-- Testing new templates
-
-## Library Priority
-
-When multiple libraries contain the same template, **priority** determines which is used:
-
-```yaml
-libraries:
-  - name: local      # Priority 1 (highest)
-    type: static
-    path: ~/my-templates
-  - name: default    # Priority 2
-    type: git
-    url: https://github.com/christianlempa/boilerplates
-```
+Git libraries are stored under:
 
-### Simple IDs
-
-Use the template name without qualification:
-
-```bash
-boilerplates compose generate nginx
+```text
+~/.config/boilerplates/libraries/
 ```
 
-The CLI uses the first matching template (from `local` in the example above).
+The configured `directory` is applied inside that checkout. For the official library the directory is `.`.
 
-### Qualified IDs
+## Discovery Rules
 
-Target a specific library:
+Boilerplates discovers templates by module directory, for example:
 
-```bash
-boilerplates compose generate nginx.local    # Uses local library
-boilerplates compose generate nginx.default  # Uses default library
+```text
+compose/
+terraform/
+ansible/
 ```
 
-## Configuration File
+A directory is treated as a template only when it contains `template.json`.
 
-Library configuration is stored in:
-```
-~/.config/boilerplates/config.yaml
-```
+Legacy `template.yaml` and `template.yml` directories are ignored during discovery.
 
-Example:
-```yaml
-libraries:
-  - name: default
-    type: git
-    url: https://github.com/christianlempa/boilerplates
-    branch: main
-    directory: library
-  - name: local
-    type: static
-    path: /Users/me/my-templates
-```
-
-### Manual Editing
+## Common Commands
 
-You can manually edit `config.yaml`:
+List configured libraries:
 
 ```bash
-# Edit configuration
-nano ~/.config/boilerplates/config.yaml
-
-# Verify changes
 boilerplates repo list
 ```
 
-## Advanced Usage
-
-### Multiple Git Branches
-
-Use different branches for stable vs. development templates:
-
-```yaml
-libraries:
-  - name: stable
-    type: git
-    url: https://github.com/user/templates
-    branch: main
-    directory: library
-  - name: dev
-    type: git
-    url: https://github.com/user/templates
-    branch: development
-    directory: library
-```
-
-### Sparse Checkout
-
-Git libraries use sparse-checkout to minimize disk usage:
-
-```
-# Only downloads:
-library/compose/
-library/terraform/
-library/ansible/
-
-# Ignores:
-.github/
-docs/
-tests/
-README.md
-```
-
-This keeps library downloads fast and disk usage low.
-
-### Private Repositories
+Sync Git libraries:
 
-For private Git repositories, ensure SSH or HTTPS authentication is configured:
-
-**SSH:**
 ```bash
-boilerplates repo add private git@github.com:user/private-templates.git \
-  --directory library \
-  --branch main
+boilerplates repo update
 ```
 
-Requires SSH key configured with GitHub/GitLab.
+Add a custom Git library:
 
-**HTTPS with credentials:**
 ```bash
-# Configure Git credential helper
-git config --global credential.helper store
-
-# Add library (will prompt for credentials on first sync)
-boilerplates repo add private https://github.com/user/private-templates.git \
-  --directory library \
+boilerplates repo add my-templates https://github.com/user/templates \
+  --directory . \
   --branch main
 ```
 
-## Template Discovery
-
-After adding libraries, templates are discovered automatically:
+Remove a library:
 
 ```bash
-# Sync libraries
-boilerplates repo update
-
-# List templates from all libraries
-boilerplates compose list
-
-# Show template details (uses priority order)
-boilerplates compose show nginx
-
-# Show from specific library
-boilerplates compose show nginx.local
+boilerplates repo remove my-templates
 ```
 
-## Troubleshooting
-
-### Library Not Syncing
+## Priority and Qualified IDs
 
-If `repo update` fails:
-
-```bash
-# Check network connectivity
-ping github.com
+Libraries are checked in config order. The first matching template wins when you use a simple ID.
 
-# Verify Git access
-git ls-remote https://github.com/christianlempa/boilerplates
+Example:
 
-# Remove and re-add library
-boilerplates repo remove default
-boilerplates repo add default https://github.com/christianlempa/boilerplates \
-  --directory library \
-  --branch main
+```yaml
+libraries:
+  - name: local
+    type: static
+    path: ~/my-templates
+  - name: default
+    type: git
+    url: https://github.com/christianlempa/boilerplates-library.git
+    branch: main
+    directory: .
 ```
 
-### Templates Not Found
-
-If templates don't appear:
+Simple ID:
 
 ```bash
-# Verify library is configured
-boilerplates repo list
-
-# Update libraries
-boilerplates repo update
-
-# Check library directory structure
-ls -la ~/.config/boilerplates/libraries/default/library/compose/
+boilerplates compose generate nginx
 ```
 
-### Duplicate Template Names
-
-If two libraries have the same template:
+Qualified ID:
 
 ```bash
-# Check which library provides it
-boilerplates compose show nginx
-
-# Use qualified ID to target specific library
 boilerplates compose generate nginx.local
+boilerplates compose generate nginx.default
 ```
 
-## Best Practices
-
-### Library Organization
-
-Structure your libraries consistently:
-
-```
-my-templates/
-├── library/
-│   ├── compose/
-│   │   ├── app1/
-│   │   └── app2/
-│   ├── terraform/
-│   └── ansible/
-└── README.md
-```
-
-### Version Control
-
-For Git libraries:
-- Use semantic versioning tags
-- Maintain a CHANGELOG
-- Test templates before merging
-- Use branches for development
-
-### Naming
-
-- Use descriptive library names
-- Avoid special characters
-- Keep names short but meaningful
+## Draft Templates
 
-**Good:** `production`, `dev`, `team-infra`  
-**Bad:** `my-lib-123`, `temp`, `new`
+Templates with `metadata.draft: true` are excluded from normal listings and lookup.
 
-### Documentation
+## Config File
 
-Each library should have:
-- README.md with overview
-- Template documentation
-- Usage examples
-- Contribution guidelines
+Library configuration lives in:
 
-## Next Steps
-
-- [Default Variables](Core-Concepts-Defaults) - Managing variable defaults
-- [Templates](Core-Concepts-Templates) - Understanding template structure
-- [Developer Guide](Developers-Templates) - Creating templates for libraries
+```text
+~/.config/boilerplates/config.yaml
+```
 
-## See Also
+Example:
 
-- [Getting Started](Getting-Started) - Your first template
-- [Installation](Installation) - Installing the CLI
+```yaml
+libraries:
+  - name: default
+    type: git
+    url: https://github.com/christianlempa/boilerplates-library.git
+    branch: main
+    directory: .
+  - name: local
+    type: static
+    path: /Users/me/my-templates
+```

+ 187 - 344
.wiki/Core-Concepts-Templates.md

@@ -1,386 +1,229 @@
 # Templates
 
-Templates are the core building blocks of the Boilerplates CLI. This page explains what templates are, how they work, and how to use them effectively.
-
-## What is a Template?
-
-A template is a **directory-based configuration package** that contains:
-- **Metadata** - Name, description, version, author information
-- **Variable specifications** - Configurable parameters
-- **Template files** - Jinja2 templates that generate your configuration
-- **Static files** - Files copied as-is (optional)
-
-When you generate a template, the CLI:
-1. Prompts you for variable values (or uses defaults/CLI overrides)
-2. Renders template files using Jinja2
-3. Writes the generated files to your specified directory
+Templates are the core unit of Boilerplates. A template is a directory with a `template.json` manifest and a `files/` directory containing the files to render.
 
 ## Template Structure
 
-Every template is a directory containing at minimum a `template.yaml` file:
-
-```
-template-name/
-├── template.yaml          # Template definition and metadata
-├── docker-compose.yml.j2  # Jinja2 template files
-├── .env.j2               # Environment configuration
-└── README.md             # Static file (copied as-is)
-```
-
-### The template.yaml File
-
-This file defines everything about your template:
-
-```yaml
----
-kind: compose              # Module type (compose, terraform, ansible, etc.)
-schema: "X.Y"             # Schema version (affects available features)
-metadata:
-  name: My Service
-  description: Service description with Markdown support
-  version: 1.0.0          # Application/service version
-  author: Your Name
-  date: '2025-01-12'
-  tags:
-    - docker
-    - service
-spec:
-  # Variable specifications (see Variables page)
-```
-
-## Template Discovery
-
-Templates are organized in **libraries**. A library is a collection of templates for a specific module type.
-
-### Default Library Structure
-
-```
-~/.config/boilerplates/libraries/
-└── default/
-    └── library/
-        ├── compose/
-        │   ├── nginx/
-        │   ├── traefik/
-        │   └── whoami/
-        ├── terraform/
-        └── ansible/
-```
-
-### Finding Templates
-
-```bash
-# List all templates for a module
-boilerplates compose list
-
-# Search templates by name
-boilerplates compose search proxy
-
-# Show details about a template
-boilerplates compose show nginx
-```
-
-## Template Metadata
-
-### Required Fields
-
-```yaml
-metadata:
-  name: Template Name        # Display name
-  description: Description   # What the template does
-  version: 1.0.0            # Application version
-  author: Your Name         # Template author
-  date: '2025-01-12'       # Last update date
-```
-
-### Optional Fields
-
-```yaml
-metadata:
-  tags:                     # Searchable tags
-    - docker
-    - web-server
-  draft: false             # Hide from listings if true
-  next_steps: |            # Post-generation instructions
-    ## What's Next
-    1. Review the generated files
-    2. Customize as needed
-    3. Deploy!
-```
-
-### Description Markdown
-
-The `description` field supports Markdown:
-
-```yaml
-metadata:
-  description: |
-    A **powerful reverse proxy** and load balancer.
-    
-    ## Features
-    - Automatic HTTPS
-    - Load balancing
-    - Let's Encrypt integration
-    
-    ## Resources
-    - **Project**: https://traefik.io
-    - **Documentation**: https://doc.traefik.io
-```
-
-This renders nicely when you run `boilerplates compose show <template>`.
-
-## Template Files
-
-### Jinja2 Templates
-
-Files ending in `.j2` are processed by Jinja2:
+Every supported template looks like this:
+
+```text
+my-template/
+├── template.json
+└── files/
+    ├── compose.yaml
+    ├── .env
+    └── config/
+        └── app.yaml
+```
+
+Only `template.json` is a supported manifest format. Legacy `template.yaml` and `template.yml` manifests are not supported.
+
+## Manifest Shape
+
+Top-level fields:
+- `slug`
+- `kind`
+- `metadata`
+- `variables`
+
+Example:
+
+```json
+{
+  "slug": "my-template",
+  "kind": "compose",
+  "metadata": {
+    "name": "My Template",
+    "description": "Short human description",
+    "tags": ["infra", "dev"],
+    "icon": {
+      "provider": "mdi",
+      "id": "docker",
+      "color": "blue"
+    },
+    "draft": false,
+    "version": {
+      "name": "v1.1",
+      "source_dep_name": "ghcr.io/example/my-image",
+      "source_dep_version": "1.1.0",
+      "source_dep_digest": "sha256:abc123def456",
+      "upstream_ref": "release-2026-04-22",
+      "notes": "Tracks upstream container release used by this template snapshot"
+    }
+  },
+  "variables": [
+    {
+      "name": "general",
+      "title": "General",
+      "items": [
+        {
+          "name": "service_name",
+          "type": "str",
+          "title": "Service name",
+          "default": "my-service"
+        }
+      ]
+    }
+  ]
+}
+```
+
+## Metadata
+
+Common metadata fields:
+- `name`
+- `description`
+- `tags`
+- `icon`
+- `draft`
+- `author`
+- `date`
+- `guide`
+- `version`
+
+### Version Metadata
+
+`metadata.version` is optional. If present, it must be an object.
+
+Supported version fields:
+- `name`
+- `source_dep_name`
+- `source_dep_version`
+- `source_dep_digest`
+- `upstream_ref`
+- `notes`
+
+Important rules:
+- the whole `version` object may be omitted
+- any individual `version` field may be omitted
+- the CLI uses `metadata.version.name` as the visible version label in list/show output
+
+This means these are all valid:
+
+```json
+{
+  "metadata": {
+    "name": "My Template"
+  }
+}
+```
+
+```json
+{
+  "metadata": {
+    "name": "My Template",
+    "version": {
+      "name": "v1.1"
+    }
+  }
+}
+```
+
+```json
+{
+  "metadata": {
+    "name": "My Template",
+    "version": {
+      "source_dep_name": "ghcr.io/example/my-image",
+      "source_dep_version": "1.1.0"
+    }
+  }
+}
+```
+
+## Files and Rendering
+
+All files inside `files/` are part of the template output.
+
+Rendering rules:
+- Boilerplates renders every file under `files/`
+- files without template expressions pass through unchanged
+- output paths currently match the relative file paths under `files/`
+- template discovery ignores anything without `template.json`
+
+## Delimiters
+
+Templates use custom delimiters, not default Jinja syntax:
+
+- variables: `<< value >>`
+- blocks: `<% if condition %>`
+- comments: `<# comment #>`
+
+Example:
 
-**docker-compose.yml.j2:**
 ```yaml
 services:
-  {{ service_name }}:
-    image: nginx:{{ nginx_version }}
+  << service_name >>:
+    image: nginx:1.27.0
+<% if ports_enabled %>
     ports:
-      - "{{ nginx_port }}:80"
-    {% if enable_ssl %}
-    volumes:
-      - ./ssl:/etc/nginx/ssl
-    {% endif %}
+      - "<< http_port >>:80"
+<% endif %>
 ```
 
-After rendering with variables:
-- `service_name=web`
-- `nginx_version=1.25`
-- `nginx_port=8080`
-- `enable_ssl=true`
+Legacy `{{ }}`, `{% %}`, and `{# #}` delimiters are rejected.
 
-**Generated docker-compose.yml:**
-```yaml
-services:
-  web:
-    image: nginx:1.25
-    ports:
-      - "8080:80"
-    volumes:
-      - ./ssl:/etc/nginx/ssl
-```
-
-### Static Files
-
-Files without `.j2` extension are copied as-is:
-- `README.md` - Copied unchanged
-- `scripts/setup.sh` - Copied unchanged
-
-### File Includes
+## Includes and Imports
 
-Templates can include other template files:
+Includes and imports are resolved relative to the template's `files/` directory.
 
-**main.j2:**
-```jinja2
-{% include 'common/header.j2' %}
+Example:
 
-services:
-  {{ service_name }}:
-    image: nginx:latest
+```jinja
+<% include 'partials/header.yaml' %>
 ```
 
-**common/header.j2:**
-```yaml
-version: '3.8'
-name: {{ project_name }}
-```
+## Template Discovery
 
-## Schema Versioning
+Templates are discovered from configured libraries. A directory is considered a template only when it contains `template.json`.
 
-Templates declare a schema version that determines available features:
+Useful commands:
 
-```yaml
-schema: "X.Y"  # Use schema version X.Y (e.g., "1.0", "1.2")
+```bash
+boilerplates compose list
+boilerplates compose search nginx
+boilerplates compose show nginx
 ```
 
-**Why Schema Versions?**
-- Modules evolve with new features over time
-- Older templates continue working (backward compatibility)
-- Templates opt-into new features by upgrading schema version
-
-**Checking Current Schema:**
-
-To find the latest schema version and available features for each module, refer to the module-specific variable documentation:
-- [Compose Variables](Variables-Compose) - Shows current schema version at bottom
-- [Terraform Variables](Variables-Terraform)
-- [Ansible Variables](Variables-Ansible)
-- [Kubernetes Variables](Variables-Kubernetes)
-- [Helm Variables](Variables-Helm)
-- [Packer Variables](Variables-Packer)
-
-Each Variables page documents the current schema and which features are available.
+Draft templates:
+- set `metadata.draft` to `true` to hide a template from normal discovery
 
-## Template Lifecycle
-
-### 1. Discovery
-
-```bash
-boilerplates repo update    # Sync libraries
-boilerplates compose list   # Discover templates
-```
+## Generation
 
-### 2. Preview
+Typical generation flow:
 
 ```bash
 boilerplates compose show nginx
+boilerplates compose generate nginx --output ./my-nginx
 ```
 
-Shows:
-- Metadata
-- Variable specifications
-- File structure
+Useful flags:
+- `--output` for local output
+- `--remote` and `--remote-path` for SSH upload targets
+- `--var-file` for YAML overrides
+- `--var` for direct CLI overrides
+- `--no-interactive` for non-interactive generation
+- `--dry-run` to preview generation without writing files
 
-### 3. Generation
+## Validation
 
-```bash
-# Interactive mode
-boilerplates compose generate nginx
-
-# Non-interactive mode
-boilerplates compose generate nginx ./my-nginx \
-  --var service_name=my-nginx \
-  --no-interactive
-```
-
-### 4. Validation (Optional)
+Validate one template:
 
 ```bash
-# Validate template structure
 boilerplates compose validate nginx
-
-# Validate all templates
-boilerplates compose validate
 ```
 
-## Template Identification
-
-Templates are identified by their directory name:
-
-```
-library/compose/nginx/   → template ID: nginx
-library/compose/traefik/ → template ID: traefik
-```
-
-### Qualified IDs
-
-When using multiple libraries, templates can have qualified IDs:
+Validate all templates in a module:
 
 ```bash
-# Simple ID (uses first matching template from priority order)
-boilerplates compose generate nginx
-
-# Qualified ID (targets specific library)
-boilerplates compose generate nginx.local
-boilerplates compose generate nginx.default
-```
-
-## Template Inheritance
-
-Templates inherit variables from module specifications. You only need to override what's different.
-
-**Module spec defines:**
-- `service_name` (default: empty)
-- `container_port` (default: 8080)
-- `restart_policy` (default: unless-stopped)
-
-**Template overrides:**
-```yaml
-spec:
-  general:
-    vars:
-      service_name:
-        default: nginx  # Override default
-      # container_port inherits 8080
-      # restart_policy inherits unless-stopped
+boilerplates compose validate
 ```
 
-This keeps templates concise—you only specify what's unique.
-
 ## Best Practices
 
-### Naming Conventions
-
-- **Template directories**: lowercase, hyphen-separated (`my-service`, `nginx-proxy`)
-- **Service names**: match template name by default
-- **File names**: descriptive and clear (`docker-compose.yml.j2`, not `dc.j2`)
-
-### Version Management
-
-**Application Versions:**
-- Hardcode in template files: `image: nginx:1.25.3`
-- Update `metadata.version` to match application
-- Don't create version variables unless necessary
-
-**Template Updates:**
-- Increment `metadata.version` when updating
-- Update `metadata.date` to current date
-- Document changes in commit messages
-
-### Documentation
-
-- Use Markdown in `description`
-- Provide `next_steps` for post-generation instructions
-- Include links to official documentation
-- Add usage examples
-
-### Testing
-
-Before publishing:
-```bash
-# Validate template
-boilerplates compose validate my-template
-
-# Test generation (dry run)
-boilerplates compose generate my-template --dry-run
-
-# Test with real generation
-boilerplates compose generate my-template /tmp/test
-
-# Verify generated files
-cd /tmp/test && docker compose config
-```
-
-## Advanced Features
-
-### Conditional File Generation
-
-Use Jinja2 conditionals to skip entire sections:
-
-```jinja2
-{% if traefik_enabled %}
-    labels:
-      - "traefik.enable=true"
-      - "traefik.http.routers.{{ service_name }}.rule=Host(`{{ traefik_host }}`)"
-{% endif %}
-```
-
-### Dynamic File Names
-
-Template file names can use variables (though this is rare):
-
-```
-config-{{ environment }}.yml.j2  # Generates: config-prod.yml
-```
-
-### Template Validation
-
-Templates are validated on load:
-- Jinja2 syntax errors detected
-- Undefined variables reported
-- Schema compatibility checked
-
-## Next Steps
-
-- [Variables](Core-Concepts-Variables) - Learn about variable types and configuration
-- [Configuration](Core-Concepts-Libraries) - Manage template libraries
-- [Variable Reference](Variables-Compose) - Complete variable documentation for modules
-- [Developer Guide](Developers-Templates) - Create your own templates
-
-## See Also
-
-- [Getting Started](Getting-Started) - Generate your first template
-- [Installation](Installation) - Install the CLI
+- keep manifests in `template.json`
+- keep all generated content under `files/`
+- use the custom delimiter set consistently
+- hardcode tested upstream application versions in rendered files unless a variable is truly required
+- use `metadata.version.name` for the user-facing label
+- use the remaining `metadata.version` fields to track upstream dependency context when useful

+ 238 - 546
.wiki/Core-Concepts-Variables.md

@@ -1,558 +1,250 @@
 # Variables
 
-Variables are the configurable parameters that customize your templates. This page explains variable types, sections, dependencies, and how to work with them effectively.
-
-## What are Variables?
-
-Variables are **parameters** that control template generation. They can be:
-- Simple values (strings, numbers, booleans)
-- Selections from options (enums)
-- Validated inputs (emails, URLs, hostnames)
-
-**Example:**
-```yaml
-service_name: my-app        # String
-container_port: 8080        # Integer
-traefik_enabled: true       # Boolean
-restart_policy: unless-stopped  # Enum (selection from options)
-```
+Variables define the configurable inputs available to a template. In the current runtime they live in `template.json` under `variables`, grouped into sections with `items`.
+
+## Variable Layout
+
+Example:
+
+```json
+{
+  "variables": [
+    {
+      "name": "general",
+      "title": "General",
+      "description": "Base settings",
+      "items": [
+        {
+          "name": "service_name",
+          "type": "str",
+          "title": "Service name",
+          "default": "my-service"
+        },
+        {
+          "name": "environment",
+          "type": "enum",
+          "title": "Environment",
+          "default": "prod",
+          "config": {
+            "options": ["prod", "stage", "dev"]
+          }
+        }
+      ]
+    }
+  ]
+}
+```
+
+Each group requires:
+- `name`
+- `title`
+- `items`
+
+Each item requires:
+- `name`
+
+Common item fields:
+- `type`
+- `title`
+- `description`
+- `default`
+- `value`
+- `required`
+- `needs`
+- `extra`
+- `config`
+
+`title` is used as the prompt label. If `description` is omitted, the runtime falls back to `title`.
 
 ## Variable Types
 
-### String (`str`)
-
-Text values with optional constraints:
-
-```yaml
-service_name:
-  type: str
-  description: Service name
-  default: my-service
-```
-
-**Usage:**
-- Service names
-- Hostnames
-- Paths
-- General text
-
-### Integer (`int`)
-
-Whole numbers:
-
-```yaml
-container_port:
-  type: int
-  description: Container port
-  default: 8080
-```
-
-**Usage:**
-- Port numbers
-- UIDs/GIDs
-- Counts and limits
-
-### Float (`float`)
-
-Decimal numbers:
-
-```yaml
-cpu_limit:
-  type: float
-  description: CPU limit in cores
-  default: 1.5
-```
-
-**Usage:**
-- Resource limits
-- Ratios and percentages
-
-### Boolean (`bool`)
-
-True/false values:
-
-```yaml
-traefik_enabled:
-  type: bool
-  description: Enable Traefik integration
-  default: false
-```
-
-**Usage:**
-- Feature toggles
-- Conditional configuration
-- Enable/disable options
-
-### Enum (`enum`)
-
-Selection from predefined options:
-
-```yaml
-restart_policy:
-  type: enum
-  description: Container restart policy
-  options: [unless-stopped, always, on-failure, no]
-  default: unless-stopped
-```
-
-**Usage:**
-- Network modes (bridge, host, macvlan)
-- Log levels (debug, info, warn, error)
-- Policies and strategies
-
-### Email (`email`)
-
-Email addresses with validation:
-
-```yaml
-admin_email:
-  type: email
-  description: Administrator email
-  default: admin@example.com
-```
-
-**Validation:**
-- Must match email format (user@domain.com)
-- Rejects invalid email addresses
-
-### URL (`url`)
-
-Full URLs with scheme validation:
-
-```yaml
-api_endpoint:
-  type: url
-  description: API endpoint URL
-  default: https://api.example.com
-```
-
-**Validation:**
-- Must include scheme (http://, https://)
-- Must have valid host
-
-### Hostname (`hostname`)
-
-Domain names or hostnames:
-
-```yaml
-traefik_host:
-  type: hostname
-  description: Service hostname
-  default: app.example.com
-```
-
-**Validation:**
-- Valid DNS hostname format
-- Accepts subdomains and domains
-
-## Variable Properties
-
-Every variable can have these properties:
-
-```yaml
-variable_name:
-  type: str                    # Variable type (required)
-  description: Description     # Help text
-  default: value               # Default value
-  prompt: Custom prompt text   # Override description in prompts
-  extra: Additional help       # Extended help text
-  sensitive: false             # Mask input (for passwords)
-  autogenerated: false         # Auto-generate if empty
-  needs: condition             # Dependency constraint
-```
-
-### Sensitive Variables
-
-Mask input for passwords and secrets:
-
-```yaml
-admin_password:
-  type: str
-  description: Administrator password
-  sensitive: true
-```
-
-**Behavior:**
-- Input is masked in prompts (`********`)
-- Displayed as `***` in output
-- Suitable for passwords, API keys, tokens
-
-### Autogenerated Variables
-
-Automatically generate values if not provided:
-
-```yaml
-secret_key:
-  type: str
-  description: Secret key
-  autogenerated: true
-```
-
-**Behavior:**
-- Shows `*auto` placeholder in prompts
-- Generates value during rendering if empty
-- Press Enter to accept auto-generation
-
-### Custom Prompts
-
-Override the description text in interactive prompts:
-
-```yaml
-service_name:
-  description: Service name (used in docker-compose)
-  prompt: Enter service name
-```
-
-## Variable Sections
-
-Variables are organized into **sections** that group related configuration:
-
-```yaml
-spec:
-  general:        # Required by default
-    title: General Settings
-    vars:
-      service_name: {...}
-      container_port: {...}
-  
-  networking:     # Optional section
-    title: Network Configuration
-    toggle: networking_enabled
-    vars:
-      network_name: {...}
-      network_mode: {...}
-```
-
-### Required Sections
-
-Sections marked as `required: true` must be configured:
-
-```yaml
-general:
-  title: General
-  required: true
-  vars:
-    service_name: {...}
-```
-
-**Behavior:**
-- Users must provide values
-- No way to skip
-- `general` section is implicitly required
-
-### Optional Sections
-
-Sections with a toggle variable:
-
-```yaml
-traefik:
-  title: Traefik
-  toggle: traefik_enabled
-  vars:
-    traefik_host: {...}
-    traefik_network: {...}
-```
-
-**Behavior:**
-- User chooses whether to enable
-- If disabled, section variables are skipped
-- Toggle variable is auto-created as boolean
-
-### Section Dependencies
-
-Sections can depend on other sections:
-
-```yaml
-traefik_tls:
-  title: Traefik TLS/SSL
-  needs: traefik
-  vars:
-    traefik_tls_enabled: {...}
-```
-
-**Behavior:**
-- Only shown if dependency section is enabled
-- Supports multiple dependencies: `needs: [traefik, networking]`
-- Automatically sorted in dependency order
-
-## Variable Dependencies
-
-Variables can depend on other variables using `needs` constraints:
-
-### Simple Constraint
-
-Variable only visible when condition is met:
-
-```yaml
-network_name:
-  type: str
-  description: Network name
-  needs: network_mode=bridge
-```
-
-**Behavior:**
-- Only shown when `network_mode` equals `bridge`
-- Hidden for other network modes
-
-### Multiple Values (OR)
-
-Variable visible for multiple values:
-
-```yaml
-network_name:
-  type: str
-  description: Network name
-  needs: network_mode=bridge,macvlan
-```
-
-**Behavior:**
-- Shown when `network_mode` is `bridge` OR `macvlan`
-- Hidden when `network_mode` is `host`
-
-### Multiple Constraints (AND)
-
-Variable requires multiple conditions:
-
-```yaml
-traefik_tls_certresolver:
-  type: str
-  description: Certificate resolver
-  needs: traefik_enabled=true;network_mode=bridge
-```
-
-**Behavior:**
-- Requires ALL conditions to be true
-- Semicolon (`;`) separates conditions
-- Comma (`,`) within a condition is OR
-
-## Variable Precedence
-
-Variables are resolved in priority order (lowest to highest):
-
-1. **Module spec** - Default values for all templates
-2. **Template spec** - Template-specific overrides
-3. **User config** - Saved defaults in `~/.config/boilerplates/config.yaml`
-4. **CLI arguments** - `--var` flags
-
-**Example:**
-
-```bash
-# Module default: restart_policy=unless-stopped
-# Template override: restart_policy=always
-# User config: restart_policy=on-failure
-# CLI override: --var restart_policy=no
-
-# Result: restart_policy=no (CLI wins)
-```
-
-### Setting Default Values
-
-Save frequently used values:
-
-```bash
-# Set a default
-boilerplates compose defaults set container_timezone="America/New_York"
-
-# View all defaults
-boilerplates compose defaults list
-
-# Remove a default
-boilerplates compose defaults rm container_timezone
-
-# Clear all defaults
-boilerplates compose defaults clear
-```
-
-## Interactive Prompts
-
-When generating templates interactively, the CLI prompts for each variable:
-
-### Text Input
-
-```
-Service name: |my-app|
-```
-
-- Type your value or press Enter for default
-- Default shown in brackets
-
-### Boolean Input
-
-```
-Enable Traefik? (y/n) [n]:
-```
-
-- `y` or `yes` for true
-- `n` or `no` for false
-- Press Enter for default
-
-### Enum Selection
-
-```
-Restart policy:
-  1) unless-stopped (default)
-  2) always
-  3) on-failure
-  4) no
-Select [1]:
-```
-
-- Enter number to select
-- Press Enter for default
-
-### Sensitive Input
-
-```
-Admin password: ********
-```
+Supported types:
+- `str`
+- `int`
+- `float`
+- `bool`
+- `enum`
+- `email`
+- `url`
+- `hostname`
+- `secret`
+
+### Secret Variables
+
+Use `type: "secret"` for values that should be masked in prompts and output.
+
+Example:
+
+```json
+{
+  "name": "api_token",
+  "type": "secret",
+  "title": "API token"
+}
+```
+
+## Config Object
+
+Extra behavior is defined under `config`.
+
+Supported config fields:
+- `placeholder`
+- `textarea`
+- `unit`
+- `options`
+- `slider`
+- `min`
+- `max`
+- `step`
+- `autogenerated`
+
+### Enum Options
+
+```json
+{
+  "name": "environment",
+  "type": "enum",
+  "title": "Environment",
+  "config": {
+    "options": ["prod", "stage", "dev"]
+  }
+}
+```
+
+### Textarea Input
+
+```json
+{
+  "name": "notes",
+  "type": "str",
+  "title": "Notes",
+  "config": {
+    "textarea": true,
+    "placeholder": "Optional notes"
+  }
+}
+```
+
+### Integer Slider
+
+```json
+{
+  "name": "replicas",
+  "type": "int",
+  "title": "Replicas",
+  "default": 3,
+  "config": {
+    "slider": true,
+    "min": 1,
+    "max": 9,
+    "step": 2,
+    "unit": "nodes"
+  }
+}
+```
+
+### Autogenerated Secret Values
+
+`config.autogenerated` is supported only for `secret` variables.
+
+Character-based example:
+
+```json
+{
+  "name": "database_password",
+  "type": "secret",
+  "title": "Database password",
+  "config": {
+    "autogenerated": {
+      "kind": "characters",
+      "length": 40,
+      "characters": ["A", "B", "1", "2"]
+    }
+  }
+}
+```
+
+Base64 example:
+
+```json
+{
+  "name": "api_token",
+  "type": "secret",
+  "title": "API token",
+  "config": {
+    "autogenerated": {
+      "kind": "base64",
+      "bytes": 12
+    }
+  }
+}
+```
+
+## Default Values and Overrides
+
+Value precedence is:
+
+1. module runtime defaults
+2. template defaults from `template.json`
+3. saved user defaults in `config.yaml`
+4. `--var-file`
+5. `--var`
+
+Useful notes:
+- `default` defines the standard value
+- `value` lets a template pin a value more directly in the manifest
+- autogenerated secrets cannot also define a default
+
+## Dependencies
+
+Both groups and items support `needs`.
+
+Examples:
+- `"needs": "traefik_enabled=true"`
+- `"needs": "network_mode=bridge,macvlan"`
+- `"needs": "network_mode!=host"`
+- `"needs": "traefik_enabled=true;network_mode=bridge"`
+
+Semantics:
+- semicolon means AND
+- comma means OR within the same comparison
+- `!=` means the current value must not match
+
+## Toggles
+
+Groups may define `toggle`, but the toggle variable must exist as a boolean item in that same group.
+
+Example:
+
+```json
+{
+  "name": "traefik",
+  "title": "Traefik",
+  "toggle": "traefik_enabled",
+  "items": [
+    {
+      "name": "traefik_enabled",
+      "type": "bool",
+      "title": "Enable Traefik",
+      "default": false
+    },
+    {
+      "name": "traefik_host",
+      "type": "hostname",
+      "title": "Hostname"
+    }
+  ]
+}
+```
 
-- Input is masked
-- Not echoed to terminal
+If a referenced toggle variable is not present, the runtime drops the toggle behavior.
 
-## Non-Interactive Mode
+## Inspecting Variables
 
-Skip prompts entirely using `--no-interactive`:
+The fastest way to see the actual variables for a template is still:
 
 ```bash
-boilerplates compose generate nginx ./output \
-  --var service_name=my-nginx \
-  --var container_port=8080 \
-  --no-interactive
+boilerplates compose show nginx
 ```
 
-**Behavior:**
-- Uses defaults for all variables
-- No user interaction required
-- Suitable for automation and CI/CD
-
-## Variable Validation
-
-### At Prompt Time
-
-Variables are validated during prompts:
-- Type checking (int must be number)
-- Format validation (email, URL, hostname)
-- Option validation (enum must be in options list)
-
-**Example:**
-```
-Container port: abc
-Error: Must be a valid integer
-Container port:
-```
-
-### At Template Load
-
-Templates are validated when loaded:
-- Check for undefined variables used in templates
-- Verify variable dependencies are valid
-- Ensure no circular dependencies
-
-## Advanced Features
-
-### Template Variable Inheritance
-
-Templates inherit ALL variables from their module schema. You only override what's different:
-
-**Module defines:**
-```yaml
-general:
-  vars:
-    service_name:
-      type: str
-    container_port:
-      type: int
-      default: 8080
-```
-
-**Template overrides:**
-```yaml
-spec:
-  general:
-    vars:
-      service_name:
-        default: nginx  # Only override default
-      # container_port inherits 8080
-```
-
-### Dynamic Visibility
-
-Variables with `needs` constraints are dynamically shown/hidden based on other values:
-
-```bash
-# If user selects network_mode=host
-# → Network name prompt is skipped (needs: network_mode=bridge)
-
-# If user selects network_mode=bridge
-# → Network name prompt is shown
-```
-
-### Dependency Resolution
-
-The CLI automatically:
-- Topologically sorts sections based on dependencies
-- Validates no circular dependencies exist
-- Reports errors for missing dependencies
-
-## Best Practices
-
-### Naming Conventions
-
-- Use **snake_case** for variable names
-- Group related variables with common prefixes:
-  - `traefik_*` for Traefik variables
-  - `network_*` for networking variables
-  - `ports_*` for port configuration
-
-### Provide Good Defaults
-
-- Choose sensible defaults for common use cases
-- Use empty defaults when user input is required
-- Document why a default was chosen
-
-### Use Descriptions
-
-- Clear, concise descriptions
-- Explain what the variable controls
-- Include examples if helpful
-
-**Good:**
-```yaml
-traefik_host:
-  description: Service subdomain or full hostname (e.g., 'app' or 'app.example.com')
-```
-
-**Bad:**
-```yaml
-traefik_host:
-  description: Host
-```
-
-### Group Related Variables
-
-Use sections to organize configuration:
-
-```yaml
-spec:
-  general:        # Core configuration
-  network:        # Network settings
-  traefik:        # Reverse proxy
-  traefik_tls:    # TLS/SSL configuration
-```
-
-### Use Dependencies Wisely
-
-- Keep dependency chains short
-- Avoid overly complex conditions
-- Test dependency behavior thoroughly
-
-## Next Steps
-
-- [Configuration](Core-Concepts-Defaults) - Managing default values
-- [Variable Reference](Variables-Compose) - Complete variable list for each module
-- [Templates](Core-Concepts-Templates) - How variables are used in templates
-
-## See Also
-
-- [Getting Started](Getting-Started) - Your first template generation
-- [Developer Guide](Developers-Templates) - Creating templates with variables
+That reflects the template as the runtime sees it, including defaults, dependencies, and optional sections.

+ 58 - 227
.wiki/Getting-Started.md

@@ -1,305 +1,136 @@
 # Getting Started
 
-Welcome to Boilerplates! This guide will help you get up and running in just a few minutes.
-
-## Overview
-
-Boilerplates provides two main components:
-
-### Template Library
-
-A collection of ready-to-use templates for common infrastructure components:
-- **Docker Compose**: Containerized applications (Nginx, Traefik, Grafana, etc.)
-- **Terraform**: Cloud infrastructure (AWS, Azure, GCP)
-- **Ansible**: Configuration management and automation
-- **Kubernetes**: Container orchestration deployments
-- **Packer**: Machine image builders
-
-Templates include:
-- Pre-configured defaults for common use cases
-- Documentation and usage examples
-- Variable specifications for customization
-- Best practices baked in
-
-### Management CLI
-
-A Python-based command-line tool to work with templates:
-- Browse and search the template library
-- Interactive configuration with validation
-- Generate customized templates
-- Manage multiple template libraries (official + custom)
-- Sync updates from repositories
+This guide walks through the current Boilerplates workflow: install the CLI, sync a library, inspect a template, and generate files from a `template.json` manifest.
 
 ## Prerequisites
 
-Before you begin, ensure you have:
-
-- Python 3.10 or higher installed
-- Git installed (for syncing template libraries)
-- Basic command-line knowledge
-- Internet connection (for downloading templates)
+- Python 3.9 or newer
+- Git
+- network access for Git-based libraries
 
-## Installation
+## Install the CLI
 
-The quickest way to install the management CLI is using the automated installer:
+Use the installer script:
 
 ```bash
 curl -fsSL https://raw.githubusercontent.com/christianlempa/boilerplates/main/scripts/install.sh | bash
 ```
 
-This installs the `boilerplates` command and configures access to the official template library.
-
-For detailed installation instructions including platform-specific guidance, see the [Installation](Installation) page.
-
-## Your First Template
-
-Once installed, let's generate your first template!
+For platform-specific instructions, see [Installation](Installation).
 
-### 1. Sync the Template Library
-
-Download the latest templates from the library:
+## Sync the Default Library
 
 ```bash
 boilerplates repo update
 ```
 
-This syncs the official template library to `~/.config/boilerplates/libraries/default/`.
+This syncs the official default library configuration, which points to `christianlempa/boilerplates-library`.
 
-### 2. Browse the Template Library
+## Browse Templates
 
-Explore available Docker Compose templates:
+List available templates for a module:
 
 ```bash
 boilerplates compose list
 ```
 
-You'll see a table showing available templates from the library with their descriptions.
+Search by ID:
+
+```bash
+boilerplates compose search nginx
+```
 
-### 3. Inspect a Template
+## Inspect a Template
 
-Before generating, preview a template's structure and variables:
+Before generating, inspect the template:
 
 ```bash
 boilerplates compose show nginx
 ```
 
 This shows:
-- Template metadata (name, version, author, description)
-- Available configuration variables and defaults
-- Template file structure
-- Variable dependencies and sections
-
-### 4. Generate Files from Template
+- template metadata
+- the visible version label from `metadata.version.name` when present
+- file structure under `files/`
+- the actual variable groups and items exposed by the template
 
-Now, let's use the CLI to generate customized files from a template! You have two options:
+## Generate Files
 
-**Interactive Mode** (Recommended for beginners):
+Interactive mode:
 
 ```bash
-boilerplates compose generate nginx
+boilerplates compose generate nginx --output ./my-nginx
 ```
 
-The tool prompts you for each variable. You can:
-- Press Enter to accept defaults from the template
-- Type custom values
-- Navigate with arrow keys for selections
-- Skip optional sections
-
-**Non-Interactive Mode** (For automation):
+Non-interactive mode:
 
 ```bash
-boilerplates compose generate nginx my-nginx \
+boilerplates compose generate nginx \
+  --output ./my-nginx \
   --var service_name=my-nginx \
-  --var container_port=8080 \
   --no-interactive
 ```
 
-This uses template defaults and provided variables without prompts.
-
-### 5. Review Generated Files
-
-After generation, you'll find:
-
-```
-my-nginx/
-├── docker-compose.yml
-└── .env
-```
-
-Review the files and adjust as needed for your environment.
-
-## Basic Commands
-
-Here are the essential commands you'll use regularly:
-
-### Library Management
-
-Manage template library repositories:
+Preview only:
 
 ```bash
-# Sync official template library
-boilerplates repo update
-
-# List all configured libraries
-boilerplates repo list
-
-# Add a custom template library
-boilerplates repo add my-templates https://github.com/user/templates \
-  --directory library \
-  --branch main
+boilerplates compose generate nginx --dry-run --show-files
 ```
 
-### Working with Templates
+## Override Variables
 
-Discover and use templates from the library:
+Direct overrides:
 
 ```bash
-# Browse available templates
-boilerplates compose list
-
-# Search the library
-boilerplates compose search nginx
-
-# Inspect template structure
-boilerplates compose show nginx
-
-# Generate files from template
-boilerplates compose generate nginx ./output
-
-# Validate template syntax
-boilerplates compose validate
-```
-
-### Working with Defaults
-
-Save frequently used values to avoid repetitive typing:
-
-```bash
-# Set a default value
-boilerplates compose defaults set container_timezone="America/New_York"
-
-# View all defaults
-boilerplates compose defaults list
-
-# Remove a default
-boilerplates compose defaults rm container_timezone
-
-# Clear all defaults
-boilerplates compose defaults clear
-```
-
-## Common Workflows
-
-### Workflow 1: Quick Generation with Defaults
-
-Use template defaults without customization:
-
-```bash
-boilerplates compose generate portainer --no-interactive
-```
-
-### Workflow 2: Interactive Customization
-
-Customize template variables interactively:
-
-```bash
-boilerplates compose show traefik         # Review template structure
-boilerplates compose generate traefik     # Customize via prompts
-```
-
-### Workflow 3: Automation
-
-For scripts and CI/CD pipelines:
-
-```bash
-boilerplates compose generate authentik ./auth \
-  --var service_name=authentik \
+boilerplates compose generate traefik \
+  --output ./proxy \
+  --var service_name=traefik \
   --var traefik_enabled=true \
-  --var traefik_host=auth.example.com \
-  --no-interactive \
-  --dry-run  # Preview first
+  --var traefik_host=proxy.example.com
 ```
 
-## Advanced Features
-
-### Dry Run
-
-Preview generated files without writing them:
+Variable file:
 
 ```bash
-boilerplates compose generate nginx --dry-run
+boilerplates compose generate traefik \
+  --output ./proxy \
+  --var-file ./vars.yaml \
+  --no-interactive
 ```
 
-### Debug Mode
-
-Enable detailed logging for troubleshooting:
+## Save Reusable Defaults
 
 ```bash
-boilerplates --log-level DEBUG compose generate nginx
+boilerplates compose defaults set container_timezone="Europe/Berlin"
+boilerplates compose defaults set restart_policy="unless-stopped"
 ```
 
-### Variable Override
-
-Override specific variables without interactive prompts:
+List defaults:
 
 ```bash
-boilerplates compose generate grafana \
-  --var service_name=monitoring-grafana \
-  --var grafana_port=3000
-```
-
-## Next Steps
-
-Now that you know the basics, explore more:
-
-- [Templates](Core-Concepts-Templates) - Learn how templates work
-- [Variables](Core-Concepts-Variables) - Understand variable types and dependencies
-- [Configuration](Core-Concepts-Libraries) - Customize your setup
-- [Variable Reference](Variables-Compose) - Complete variable documentation
-
-## Troubleshooting
-
-### CLI Command Not Found
-
-If the `boilerplates` command is not found after installation:
-
-```bash
-# Ensure pipx binaries are in PATH
-export PATH="$HOME/.local/bin:$PATH"
-
-# Add to your shell profile (.bashrc, .zshrc, etc.)
-echo 'export PATH="$HOME/.local/bin:$PATH"' >> ~/.bashrc
+boilerplates compose defaults list
 ```
 
-### Templates Not Available
+## Validate Templates
 
-If templates aren't showing up after installation:
+Validate one template:
 
 ```bash
-# Sync the template library
-boilerplates repo update
-
-# Verify library is configured
-boilerplates repo list
+boilerplates compose validate nginx
 ```
 
-### Permission Issues
-
-If you encounter permission errors:
+Validate all templates in a module:
 
 ```bash
-# Ensure output directory is writable
-chmod +w ./output-directory
-
-# Or generate to a different location
-boilerplates compose generate nginx ~/my-projects/nginx
+boilerplates compose validate
 ```
 
-## Getting Help
+## What Changed in the Current Format
 
-- **Documentation:** Browse the [Wiki](Home) for comprehensive guides
-- **Discord:** Join the [community](https://christianlempa.de/discord) for real-time help
-- **GitHub Issues:** Report bugs or request features
-- **YouTube:** Watch [video tutorials](https://www.youtube.com/@christianlempa)
+The current runtime uses:
+- `template.json` instead of `template.yaml`
+- `files/` instead of top-level `.j2` render files
+- custom delimiters instead of default Jinja delimiters
+- structured optional `metadata.version` objects
 
-Happy templating!
+If you are reading older examples that use `template.yaml`, `.j2`, or positional output arguments for `generate`, treat them as outdated.

+ 20 - 35
.wiki/Home.md

@@ -1,45 +1,30 @@
-# Boilerplates Documentation
+# Boilerplates Wiki
 
-Instant access to battle-tested templates for Docker, Terraform, Ansible, Kubernetes, and more.
+Boilerplates is a CLI for discovering template libraries, inspecting template metadata and variables, and generating ready-to-use infrastructure files from `template.json` manifests.
 
-Each template includes sensible defaults, best practices, and common configuration patterns—so you can focus on customizing for your environment.
+The current runtime supports:
+- `template.json` manifests only
+- renderable files under `files/`
+- custom delimiters: `<< >>`, `<% %>`, and `<# #>`
+- structured optional `metadata.version` objects
 
-## Boilerplates CLI Tool
+## Start Here
 
-The Boilerplates CLI is a Python-based tool that streamlines infrastructure template management. It provides an interactive interface for browsing, customizing, and generating configuration files from a curated template library. The tool handles variable validation, dependency resolution, and multi-source template management—giving you a consistent workflow whether you're deploying a single container or orchestrating complex infrastructure.
+- [Getting Started](Getting-Started) - install the CLI, sync a library, inspect a template, and generate files
+- [Installation](Installation) - platform-specific installation options
 
-## User Documentation
+## Core Concepts
 
-**Getting Started**
+- [Templates](Core-Concepts-Templates) - manifest shape, file layout, rendering rules, and version metadata
+- [Variables](Core-Concepts-Variables) - variable groups, item fields, dependencies, toggles, and supported config
+- [Libraries](Core-Concepts-Libraries) - official and custom libraries, discovery, priority, and config
+- [Defaults](Core-Concepts-Defaults) - saved default values and precedence
 
-- [Getting Started](Getting-Started) - Quick introduction and first steps
-- [Installation](Installation) - Install the Boilerplates CLI tool on Linux, MacOS, or NixOS
+## Variable Discovery
 
-**Core Concepts**
+- [Variables](Variables) - how to inspect the variables a template actually exposes
 
-- [Templates](Core-Concepts-Templates) - Understanding templates and how they work
-- [Variables](Core-Concepts-Variables) - Variable types, sections, and dependencies
-- [Libraries](Core-Concepts-Libraries) - Managing template libraries
-- [Defaults](Core-Concepts-Defaults) - Setting and managing default values
+## Additional Docs
 
-**Variable Reference**
-
-- [Ansible Variables](Variables-Ansible)
-- [Compose Variables](Variables-Compose)
-- [Helm Variables](Variables-Helm)
-- [Kubernetes Variables](Variables-Kubernetes)
-- [Packer Variables](Variables-Packer)
-- [Terraform Variables](Variables-Terraform)
-
-## Developer Documentation
-
-**Architecture & Development**
-
-- [Architecture Overview](Developers-Architecture) - System design and core components
-- [Module Development](Developers-Modules) - Creating new modules
-- [Template Development](Developers-Templates) - Building templates
-- [Contributing Guide](Developers-Contributing) - Detailed contribution workflow
-
-**Contributing**
-
-Before contributing, please read our [Contributing Guidelines](https://github.com/ChristianLempa/boilerplates/blob/main/CONTRIBUTING.md)
+- [Contributing Guide](https://github.com/ChristianLempa/boilerplates/blob/main/CONTRIBUTING.md)
+- [Repository README](https://github.com/ChristianLempa/boilerplates/blob/main/README.md)

+ 17 - 17
.wiki/Installation.md

@@ -6,7 +6,7 @@ This guide covers installing the Boilerplates CLI on various platforms.
 
 Before installing, ensure you have:
 
-- **Python 3.10 or higher** - Check with `python3 --version`
+- **Python 3.9 or higher** - Check with `python3 --version`
 - **Git** - Required for syncing template libraries
 - **Internet connection** - For downloading dependencies and templates
 
@@ -16,7 +16,7 @@ Before installing, ensure you have:
 python3 --version
 ```
 
-If you see version 3.10 or higher, you're ready to proceed. If not, see the platform-specific instructions below for installing Python.
+If you see version 3.9 or higher, you're ready to proceed. If not, see the platform-specific instructions below for installing Python.
 
 ## Quick Install (Recommended)
 
@@ -29,7 +29,7 @@ curl -fsSL https://raw.githubusercontent.com/christianlempa/boilerplates/main/sc
 ### Install Specific Version
 
 ```bash
-curl -fsSL https://raw.githubusercontent.com/christianlempa/boilerplates/main/scripts/install.sh | bash -s -- --version v0.1.0
+curl -fsSL https://raw.githubusercontent.com/christianlempa/boilerplates/main/scripts/install.sh | bash -s -- --version v0.2.0
 ```
 
 The installer will:
@@ -61,7 +61,7 @@ python3 -m pipx ensurepath
 3. **Install Boilerplates:**
 
 ```bash
-pipx install boilerplates-cli
+pipx install boilerplates
 ```
 
 4. **Verify installation:**
@@ -88,7 +88,7 @@ python3 -m pipx ensurepath
 3. **Install Boilerplates:**
 
 ```bash
-pipx install boilerplates-cli
+pipx install boilerplates
 ```
 
 #### Arch Linux
@@ -109,7 +109,7 @@ python3 -m pipx ensurepath
 3. **Install Boilerplates:**
 
 ```bash
-pipx install boilerplates-cli
+pipx install boilerplates
 ```
 
 ### MacOS
@@ -132,12 +132,12 @@ pipx ensurepath
 3. **Install Boilerplates:**
 
 ```bash
-pipx install boilerplates-cli
+pipx install boilerplates
 ```
 
 #### Using Python from python.org
 
-1. Download and install Python 3.10+ from [python.org](https://www.python.org/downloads/macos/)
+1. Download and install Python 3.9+ from [python.org](https://www.python.org/downloads/macos/)
 
 2. **Install pipx:**
 
@@ -149,7 +149,7 @@ python3 -m pipx ensurepath
 3. **Install Boilerplates:**
 
 ```bash
-pipx install boilerplates-cli
+pipx install boilerplates
 ```
 
 ### NixOS
@@ -207,7 +207,7 @@ wsl --install
 
 #### Native Windows (Not Recommended)
 
-1. Install Python 3.10+ from [python.org](https://www.python.org/downloads/windows/)
+1. Install Python 3.9+ from [python.org](https://www.python.org/downloads/windows/)
 
 2. Install pipx:
 
@@ -219,7 +219,7 @@ python -m pipx ensurepath
 3. Install Boilerplates:
 
 ```powershell
-pipx install boilerplates-cli
+pipx install boilerplates
 ```
 
 ## Manual Installation
@@ -229,7 +229,7 @@ For development or custom installations:
 ### Using pip (Not Recommended for End Users)
 
 ```bash
-pip install --user boilerplates-cli
+pip install --user boilerplates
 ```
 
 Note: This installs globally and may conflict with system packages. Use pipx instead.
@@ -272,7 +272,7 @@ boilerplates --version
 
 Expected output:
 ```
-Boilerplates CLI v0.1.0
+boilerplates version 0.2.0
 ```
 
 ### Initialize Template Library
@@ -317,7 +317,7 @@ echo '_BOILERPLATES_COMPLETE=fish_source boilerplates | source' >> ~/.config/fis
 ### Update to Latest Version
 
 ```bash
-pipx upgrade boilerplates-cli
+pipx upgrade boilerplates
 ```
 
 ### Update Template Library
@@ -331,7 +331,7 @@ boilerplates repo update
 ### Remove the CLI
 
 ```bash
-pipx uninstall boilerplates-cli
+pipx uninstall boilerplates
 ```
 
 ### Remove Configuration and Templates
@@ -358,7 +358,7 @@ source ~/.bashrc  # or ~/.zshrc
 
 ### Python Version Too Old
 
-If you have Python < 3.10, install a newer version:
+If you have Python < 3.9, install a newer version:
 
 **Ubuntu/Debian:**
 ```bash
@@ -388,7 +388,7 @@ python3 -m pip install --user pipx
 # Or use virtual environments
 python3 -m venv ~/venvs/boilerplates
 source ~/venvs/boilerplates/bin/activate
-pip install boilerplates-cli
+pip install boilerplates
 ```
 
 ### SSL Certificate Errors

+ 0 - 63
.wiki/Variables-Ansible.md

@@ -1,63 +0,0 @@
-# Ansible Variables
-
-**Module:** `ansible`  
-**Schema Version:** `1.0`  
-**Description:** Manage Ansible playbooks
-
----
-
-This page documents all available variables for the ansible module. Variables are organized into sections that can be enabled/disabled based on your configuration needs.
-
-## Table of Contents
-
-- [General](#general)
-- [Options](#options)
-- [Secrets](#secrets)
-
----
-
-## General
-
-**Required:** Yes
-
-| Variable | Type | Default | Description |
-|----------|------|---------|-------------|
-| `playbook_name` | `str` | _none_ | Ansible playbook name |
-| `target_hosts` | `str` | `{{ my_hosts | d([]) }}` | Target hosts pattern (e.g., 'all', 'webservers', or '{{ my_hosts | d([]) }}') |
-| `become` | `bool` | ✗ | Run tasks with privilege escalation (sudo) |
-
----
-
-## Options
-
-**Toggle Variable:** `options_enabled`
-
-| Variable | Type | Default | Description |
-|----------|------|---------|-------------|
-| `options_enabled` | `bool` | ✗ | Enable additional playbook options |
-| `gather_facts` | `bool` | ✓ | Gather facts about target hosts |
-
----
-
-## Secrets
-
-**Toggle Variable:** `secrets_enabled`
-
-| Variable | Type | Default | Description |
-|----------|------|---------|-------------|
-| `secrets_enabled` | `bool` | ✗ | Use external secrets file |
-| `secrets_file` | `str` | `secrets.yaml` | Path to secrets file |
-
----
-
-## Notes
-
-- **Required sections** must be configured
-- **Toggle variables** enable/disable entire sections
-- **Dependencies** (`needs`) control when sections/variables are available
-- **Sensitive variables** are masked during prompts
-- **Auto-generated variables** are populated automatically if not provided
-
----
-
-_Last updated: Schema version 1.0_

+ 0 - 214
.wiki/Variables-Compose.md

@@ -1,214 +0,0 @@
-# Compose Variables
-
-**Module:** `compose`  
-**Schema Version:** `1.2`  
-**Description:** Manage Docker Compose configurations
-
----
-
-This page documents all available variables for the compose module. Variables are organized into sections that can be enabled/disabled based on your configuration needs.
-
-## Table of Contents
-
-- [General](#general)
-- [Network](#network)
-- [Ports](#ports)
-- [Traefik](#traefik)
-- [Traefik TLS/SSL](#traefik-tlsssl)
-- [Volume Storage](#volume-storage)
-- [Resource Limits](#resource-limits)
-- [Docker Swarm](#docker-swarm)
-- [Database](#database)
-- [Email Server](#email-server)
-- [Authentik SSO](#authentik-sso)
-
----
-
-## General
-
-| Variable | Type | Default | Description |
-|----------|------|---------|-------------|
-| `service_name` | `str` | _none_ | Service name |
-| `container_name` | `str` | _none_ | Container name |
-| `container_hostname` | `str` | _none_ | Container internal hostname |
-| `container_timezone` | `str` | `UTC` | Container timezone (e.g., Europe/Berlin) |
-| `user_uid` | `int` | `1000` | User UID for container process |
-| `user_gid` | `int` | `1000` | User GID for container process |
-| `container_loglevel` | `enum` | `info` | Container log level<br>**Options:** `debug`, `info`, `warn`, `error` |
-| `restart_policy` | `enum` | `unless-stopped` | Container restart policy<br>**Options:** `unless-stopped`, `always`, `on-failure`, `no` |
-
----
-
-## Network
-
-| Variable | Type | Default | Description |
-|----------|------|---------|-------------|
-| `network_mode` | `enum` | `bridge` | Docker network mode<br>**Options:** `bridge`, `host`, `macvlan` |
-| `network_name` | `str` | `bridge` | Docker network name<br>**Needs:** `network_mode=bridge,macvlan` |
-| `network_external` | `bool` | ✗ | Use existing Docker network (external)<br>**Needs:** `network_mode=bridge,macvlan` |
-| `network_macvlan_ipv4_address` | `str` | `192.168.1.253` | Static IP address for container<br>**Needs:** `network_mode=macvlan` |
-| `network_macvlan_parent_interface` | `str` | `eth0` | Host network interface name<br>**Needs:** `network_mode=macvlan` |
-| `network_macvlan_subnet` | `str` | `192.168.1.0/24` | Network subnet in CIDR notation<br>**Needs:** `network_mode=macvlan` |
-| `network_macvlan_gateway` | `str` | `192.168.1.1` | Network gateway IP address<br>**Needs:** `network_mode=macvlan` |
-
----
-
-## Ports
-
-**Toggle Variable:** `ports_enabled`  
-**Depends On:** `network_mode=bridge`
-
-Expose service ports to the host.
-
-| Variable | Type | Default | Description |
-|----------|------|---------|-------------|
-| `ports_http` | `int` | `8080` | HTTP port on host |
-| `ports_https` | `int` | `8443` | HTTPS port on host |
-| `ports_ssh` | `int` | `22` | SSH port on host |
-| `ports_dns` | `int` | `53` | DNS port on host |
-| `ports_dhcp` | `int` | `67` | DHCP port on host |
-| `ports_smtp` | `int` | `25` | SMTP port on host |
-
----
-
-## Traefik
-
-**Toggle Variable:** `traefik_enabled`  
-**Depends On:** `network_mode=bridge`
-
-Traefik routes external traffic to your service.
-
-| Variable | Type | Default | Description |
-|----------|------|---------|-------------|
-| `traefik_enabled` | `bool` | ✗ | Enable Traefik reverse proxy integration |
-| `traefik_network` | `str` | `traefik` | Traefik network name |
-| `traefik_host` | `str` | _none_ | Service subdomain or full hostname (e.g., 'app' or 'app.example.com') |
-| `traefik_domain` | `str` | `home.arpa` | Base domain (e.g., example.com) |
-| `traefik_entrypoint` | `str` | `web` | HTTP entrypoint (non-TLS) |
-
----
-
-## Traefik TLS/SSL
-
-**Toggle Variable:** `traefik_tls_enabled`  
-**Depends On:** `traefik_enabled=true;network_mode=bridge`
-
-Enable HTTPS/TLS for Traefik with certificate management.
-
-| Variable | Type | Default | Description |
-|----------|------|---------|-------------|
-| `traefik_tls_enabled` | `bool` | ✓ | Enable HTTPS/TLS |
-| `traefik_tls_entrypoint` | `str` | `websecure` | TLS entrypoint |
-| `traefik_tls_certresolver` | `str` | `cloudflare` | Traefik certificate resolver name |
-
----
-
-## Volume Storage
-
-Configure persistent storage for your service.
-
-| Variable | Type | Default | Description |
-|----------|------|---------|-------------|
-| `volume_mode` | `enum` | `local` | Volume storage backend<br>**Options:** `local`, `mount`, `nfs` • local: Docker-managed volumes | mount: Bind mount from host | nfs: Network filesystem |
-| `volume_mount_path` | `str` | `/mnt/storage` | Host path for bind mounts<br>**Needs:** `volume_mode=mount` |
-| `volume_nfs_server` | `str` | `192.168.1.1` | NFS server address<br>**Needs:** `volume_mode=nfs` |
-| `volume_nfs_path` | `str` | `/export` | NFS export path<br>**Needs:** `volume_mode=nfs` |
-| `volume_nfs_options` | `str` | `rw,nolock,soft` | NFS mount options (comma-separated)<br>**Needs:** `volume_mode=nfs` |
-
----
-
-## Resource Limits
-
-**Toggle Variable:** `resources_enabled`
-
-Set CPU and memory limits for the service.
-
-| Variable | Type | Default | Description |
-|----------|------|---------|-------------|
-| `resources_enabled` | `bool` | ✗ | Enable resource limits |
-| `resources_cpu_limit` | `str` | `1.0` | Maximum CPU cores (e.g., 0.5, 1.0, 2.0) |
-| `resources_cpu_reservation` | `str` | `0.25` | Reserved CPU cores<br>**Needs:** `swarm_enabled=true` |
-| `resources_memory_limit` | `str` | `1G` | Maximum memory (e.g., 512M, 1G, 2G) |
-| `resources_memory_reservation` | `str` | `512M` | Reserved memory<br>**Needs:** `swarm_enabled=true` |
-
----
-
-## Docker Swarm
-
-**Toggle Variable:** `swarm_enabled`  
-**Depends On:** `network_mode=bridge`
-
-Deploy service in Docker Swarm mode.
-
-| Variable | Type | Default | Description |
-|----------|------|---------|-------------|
-| `swarm_enabled` | `bool` | ✗ | Enable Docker Swarm mode |
-| `swarm_placement_mode` | `enum` | `replicated` | Swarm placement mode<br>**Options:** `replicated`, `global` |
-| `swarm_replicas` | `int` | `1` | Number of replicas<br>**Needs:** `swarm_placement_mode=replicated` |
-| `swarm_placement_host` | `str` | _none_ | Target hostname for placement constraint<br>**Needs:** `swarm_placement_mode=replicated` • Constrains service to run on specific node by hostname |
-
----
-
-## Database
-
-**Toggle Variable:** `database_enabled`
-
-| Variable | Type | Default | Description |
-|----------|------|---------|-------------|
-| `database_type` | `enum` | `default` | Database type<br>**Options:** `default`, `sqlite`, `postgres`, `mysql` |
-| `database_external` | `bool` | ✗ | Use an external database server?<br>skips creation of internal database container |
-| `database_host` | `str` | `database` | Database host |
-| `database_port` | `int` | _none_ | Database port |
-| `database_name` | `str` | _none_ | Database name |
-| `database_user` | `str` | _none_ | Database user |
-| `database_password` | `str` | _none_ | Database password<br>**Sensitive** • **Auto-generated** |
-
----
-
-## Email Server
-
-**Toggle Variable:** `email_enabled`
-
-Configure email server for notifications and user management.
-
-| Variable | Type | Default | Description |
-|----------|------|---------|-------------|
-| `email_enabled` | `bool` | ✗ | Enable email server configuration |
-| `email_host` | `str` | _none_ | SMTP server hostname |
-| `email_port` | `int` | `587` | SMTP server port |
-| `email_username` | `str` | _none_ | SMTP username |
-| `email_password` | `str` | _none_ | SMTP password<br>**Sensitive** |
-| `email_from` | `str` | _none_ | From email address |
-| `email_use_tls` | `bool` | ✓ | Use TLS encryption |
-| `email_use_ssl` | `bool` | ✗ | Use SSL encryption |
-
----
-
-## Authentik SSO
-
-**Toggle Variable:** `authentik_enabled`
-
-Integrate with Authentik for Single Sign-On authentication.
-
-| Variable | Type | Default | Description |
-|----------|------|---------|-------------|
-| `authentik_enabled` | `bool` | ✗ | Enable Authentik SSO integration |
-| `authentik_url` | `str` | _none_ | Authentik base URL (e.g., https://auth.example.com) |
-| `authentik_slug` | `str` | _none_ | Authentik application slug |
-| `authentik_client_id` | `str` | _none_ | OAuth client ID from Authentik provider |
-| `authentik_client_secret` | `str` | _none_ | OAuth client secret from Authentik provider<br>**Sensitive** |
-| `authentik_traefik_middleware` | `str` | `authentik-middleware@file` | Traefik middleware name for Authentik authentication<br>**Needs:** `traefik_enabled=true` |
-
----
-
-## Notes
-
-- **Required sections** must be configured
-- **Toggle variables** enable/disable entire sections
-- **Dependencies** (`needs`) control when sections/variables are available
-- **Sensitive variables** are masked during prompts
-- **Auto-generated variables** are populated automatically if not provided
-
----
-
-_Last updated: Schema version 1.2_

+ 0 - 125
.wiki/Variables-Helm.md

@@ -1,125 +0,0 @@
-# Helm Variables
-
-**Module:** `helm`  
-**Schema Version:** `1.0`  
-**Description:** Manage Helm charts
-
----
-
-This page documents all available variables for the helm module. Variables are organized into sections that can be enabled/disabled based on your configuration needs.
-
-## Table of Contents
-
-- [General](#general)
-- [Networking](#networking)
-- [Traefik Ingress](#traefik-ingress)
-- [Traefik TLS/SSL](#traefik-tlsssl)
-- [Volumes](#volumes)
-- [Database](#database)
-- [Email Server](#email-server)
-
----
-
-## General
-
-| Variable | Type | Default | Description |
-|----------|------|---------|-------------|
-| `release_name` | `str` | _none_ | Helm release name |
-| `namespace` | `str` | `default` | Kubernetes namespace for the Helm release |
-
----
-
-## Networking
-
-| Variable | Type | Default | Description |
-|----------|------|---------|-------------|
-| `network_mode` | `enum` | `ClusterIP` | Kubernetes service type<br>**Options:** `ClusterIP`, `NodePort`, `LoadBalancer` |
-
----
-
-## Traefik Ingress
-
-**Toggle Variable:** `traefik_enabled`  
-**Depends On:** `network_mode=ClusterIP`
-
-Traefik routes external traffic to your service.
-
-| Variable | Type | Default | Description |
-|----------|------|---------|-------------|
-| `traefik_enabled` | `bool` | ✗ | Enable Traefik Ingress/IngressRoute |
-| `traefik_host` | `hostname` | _none_ | Hostname for Traefik ingress |
-
----
-
-## Traefik TLS/SSL
-
-**Toggle Variable:** `traefik_tls_enabled`  
-**Depends On:** `traefik_enabled=true;network_mode=ClusterIP`
-
-Enable HTTPS/TLS for Traefik with certificate management.
-
-| Variable | Type | Default | Description |
-|----------|------|---------|-------------|
-| `traefik_tls_enabled` | `bool` | ✓ | Enable HTTPS/TLS |
-| `traefik_tls_secret` | `str` | `traefik-tls` | TLS secret name |
-| `traefik_tls_certmanager` | `bool` | ✗ | Use cert-manager for automatic certificate provisioning |
-| `certmanager_issuer` | `str` | `cloudflare` | Cert-manager ClusterIssuer or Issuer name<br>**Needs:** `traefik_tls_certmanager=true` |
-| `certmanager_issuer_kind` | `enum` | `ClusterIssuer` | Issuer kind<br>**Options:** `ClusterIssuer`, `Issuer` • **Needs:** `traefik_tls_certmanager=true` |
-
----
-
-## Volumes
-
-| Variable | Type | Default | Description |
-|----------|------|---------|-------------|
-| `volumes_mode` | `enum` | `default` | Persistent volume mode<br>**Options:** `default`, `existing-pvc` |
-| `volumes_pvc_name` | `str` | _none_ | Name of existing PVC<br>**Needs:** `volumes_mode=existing-pvc` |
-
----
-
-## Database
-
-**Toggle Variable:** `database_enabled`
-
-| Variable | Type | Default | Description |
-|----------|------|---------|-------------|
-| `database_enabled` | `bool` | ✗ | Enable database configuration |
-| `database_type` | `enum` | `postgres` | Database type<br>**Options:** `postgres`, `mysql`, `mariadb` |
-| `database_host` | `hostname` | _none_ | Database host |
-| `database_port` | `int` | _none_ | Database port |
-| `database_name` | `str` | _none_ | Database name |
-| `database_user` | `str` | _none_ | Database user |
-| `database_password` | `str` | _none_ | Database password<br>**Sensitive** • **Auto-generated** |
-
----
-
-## Email Server
-
-**Toggle Variable:** `email_enabled`
-
-Configure email server for notifications and user management.
-
-| Variable | Type | Default | Description |
-|----------|------|---------|-------------|
-| `email_enabled` | `bool` | ✗ | Enable email server configuration |
-| `email_host` | `hostname` | _none_ | SMTP server hostname |
-| `email_port` | `int` | `587` | SMTP server port |
-| `email_username` | `str` | _none_ | SMTP username |
-| `email_password` | `str` | _none_ | SMTP password<br>**Sensitive** |
-| `email_from` | `email` | _none_ | From email address |
-| `email_use_tls` | `bool` | ✓ | Use TLS encryption |
-| `email_use_ssl` | `bool` | ✗ | Use SSL encryption |
-
----
-
-## Notes
-
-- **Required sections** must be configured
-- **Toggle variables** enable/disable entire sections
-- **Dependencies** (`needs`) control when sections/variables are available
-- **Sensitive variables** are masked during prompts
-- **Auto-generated variables** are populated automatically if not provided
-
----
-
-_Last updated: Schema version 1.0_

+ 0 - 84
.wiki/Variables-Kubernetes.md

@@ -1,84 +0,0 @@
-# Kubernetes Variables
-
-**Module:** `kubernetes`  
-**Schema Version:** `1.0`  
-**Description:** Manage Kubernetes configurations
-
----
-
-This page documents all available variables for the kubernetes module. Variables are organized into sections that can be enabled/disabled based on your configuration needs.
-
-## Table of Contents
-
-- [General](#general)
-- [Traefik](#traefik)
-- [Traefik TLS/SSL](#traefik-tlsssl)
-- [Cert-Manager](#cert-manager)
-
----
-
-## General
-
-| Variable | Type | Default | Description |
-|----------|------|---------|-------------|
-| `resource_name` | `str` | _none_ | Resource name (metadata.name) |
-| `namespace` | `str` | `default` | Kubernetes namespace |
-
----
-
-## Traefik
-
-**Toggle Variable:** `traefik_enabled`
-
-Traefik IngressRoute configuration for HTTP/HTTPS routing
-
-| Variable | Type | Default | Description |
-|----------|------|---------|-------------|
-| `traefik_enabled` | `bool` | ✗ | Enable Traefik IngressRoute |
-| `traefik_entrypoint` | `str` | `web` | Traefik entrypoint (non-TLS) |
-| `traefik_host` | `hostname` | _none_ | Domain name for the service (e.g., app.example.com) |
-| `traefik_service_name` | `str` | _none_ | Backend Kubernetes service name |
-| `traefik_service_port` | `int` | `80` | Backend service port |
-
----
-
-## Traefik TLS/SSL
-
-**Toggle Variable:** `traefik_tls_enabled`  
-**Depends On:** `traefik`
-
-Enable HTTPS/TLS for Traefik with certificate management
-
-| Variable | Type | Default | Description |
-|----------|------|---------|-------------|
-| `traefik_tls_enabled` | `bool` | ✓ | Enable HTTPS/TLS |
-| `traefik_tls_entrypoint` | `str` | `websecure` | TLS entrypoint |
-| `traefik_tls_certresolver` | `str` | `cloudflare` | Traefik certificate resolver name |
-
----
-
-## Cert-Manager
-
-**Toggle Variable:** `certmanager_enabled`
-
-Cert-manager certificate management configuration
-
-| Variable | Type | Default | Description |
-|----------|------|---------|-------------|
-| `certmanager_enabled` | `bool` | ✗ | Enable cert-manager certificate |
-| `certmanager_issuer` | `str` | `cloudflare` | ClusterIssuer or Issuer name |
-| `certmanager_issuer_kind` | `enum` | `ClusterIssuer` | Issuer kind<br>**Options:** `ClusterIssuer`, `Issuer` |
-
----
-
-## Notes
-
-- **Required sections** must be configured
-- **Toggle variables** enable/disable entire sections
-- **Dependencies** (`needs`) control when sections/variables are available
-- **Sensitive variables** are masked during prompts
-- **Auto-generated variables** are populated automatically if not provided
-
----
-
-_Last updated: Schema version 1.0_

+ 0 - 35
.wiki/Variables-Packer.md

@@ -1,35 +0,0 @@
-# Packer Variables
-
-**Module:** `packer`  
-**Schema Version:** `1.0`  
-**Description:** Manage Packer templates
-
----
-
-This page documents all available variables for the packer module. Variables are organized into sections that can be enabled/disabled based on your configuration needs.
-
-## Table of Contents
-
-- [General](#general)
-
----
-
-## General
-
-| Variable | Type | Default | Description |
-|----------|------|---------|-------------|
-| `image_name` | `str` | _none_ | Image name |
-
----
-
-## Notes
-
-- **Required sections** must be configured
-- **Toggle variables** enable/disable entire sections
-- **Dependencies** (`needs`) control when sections/variables are available
-- **Sensitive variables** are masked during prompts
-- **Auto-generated variables** are populated automatically if not provided
-
----
-
-_Last updated: Schema version 1.0_

+ 0 - 36
.wiki/Variables-Terraform.md

@@ -1,36 +0,0 @@
-# Terraform Variables
-
-**Module:** `terraform`  
-**Schema Version:** `1.0`  
-**Description:** Manage Terraform configurations
-
----
-
-This page documents all available variables for the terraform module. Variables are organized into sections that can be enabled/disabled based on your configuration needs.
-
-## Table of Contents
-
-- [General](#general)
-
----
-
-## General
-
-| Variable | Type | Default | Description |
-|----------|------|---------|-------------|
-| `resource_name` | `str` | _none_ | Resource name prefix |
-| `backend_mode` | `enum` | `local` | Terraform backend mode<br>**Options:** `local`, `http` |
-
----
-
-## Notes
-
-- **Required sections** must be configured
-- **Toggle variables** enable/disable entire sections
-- **Dependencies** (`needs`) control when sections/variables are available
-- **Sensitive variables** are masked during prompts
-- **Auto-generated variables** are populated automatically if not provided
-
----
-
-_Last updated: Schema version 1.0_

+ 27 - 17
.wiki/Variables.md

@@ -1,25 +1,35 @@
-# Variables Documentation
+# Variables Reference
 
-This section contains auto-generated documentation for all available variables in each module.
+Boilerplates no longer publishes static schema-version variable reference pages.
 
-## Available Modules
+The current runtime is template-driven:
+- modules provide baseline variable behavior
+- each template can override defaults and add template-specific variables
+- the exact variable set depends on the template you are inspecting
 
-- [Ansible](Variables-Ansible)
-- [Compose](Variables-Compose)
-- [Helm](Variables-Helm)
-- [Kubernetes](Variables-Kubernetes)
-- [Packer](Variables-Packer)
-- [Terraform](Variables-Terraform)
+## How to Inspect Variables
 
----
+Use the CLI against the template itself:
 
-Each module page includes:
+```bash
+boilerplates compose show nginx
+boilerplates terraform show cloudflare-dns-record
+boilerplates ansible show ubuntu-vm-core
+```
 
-- Schema version information
-- Complete list of sections and variables
-- Variable types, defaults, and descriptions
-- Section dependencies and toggle configurations
+This shows:
+- template metadata
+- rendered version label from `metadata.version.name` when present
+- the template file tree
+- the actual variable groups and items exposed by that template
 
----
+## Recommended Workflow
 
-_This documentation is auto-generated from module schemas._
+1. List templates for a module.
+2. Show the template you want to use.
+3. Review defaults, dependencies, and optional sections.
+4. Generate with `--output`, `--var-file`, and `--var` as needed.
+
+## Why the Old Pages Were Removed
+
+The older wiki pages were generated from schema-version snapshots and no longer matched the current `template.json` runtime. They were removed to avoid documenting variables that may not exist, may have changed shape, or may be template-specific.

+ 2 - 14
.wiki/_Sidebar.md

@@ -12,17 +12,5 @@
 - [Libraries](Core-Concepts-Libraries)
 - [Defaults](Core-Concepts-Defaults)
 
-### Variables Reference
-- [All Modules](Variables)
-- [Ansible](Variables-Ansible)
-- [Compose](Variables-Compose)
-- [Helm](Variables-Helm)
-- [Kubernetes](Variables-Kubernetes)
-- [Packer](Variables-Packer)
-- [Terraform](Variables-Terraform)
-
-### Developer Docs
-- [Architecture](Developers-Architecture)
-- [Modules](Developers-Modules)
-- [Templates](Developers-Templates)
-- [Contributing](Developers-Contributing)
+### Reference
+- [Variables](Variables)

+ 57 - 105
AGENTS.md

@@ -50,7 +50,6 @@ The project is stored in a public GitHub Repository, use issues, and branches fo
 
 - `cli/` - Python CLI application source code
   - `cli/core/` - Core Components of the CLI application
-  - `cli/core/schema/` - JSON schema definitions for all modules
   - `cli/modules/` - Modules implementing technology-specific functions
   - `cli/__main__.py` - CLI entry point, auto-discovers modules and registers commands
 - `library/` - Template collections organized by module
@@ -86,10 +85,6 @@ The project is stored in a public GitHub Repository, use issues, and branches fo
 - `cli/core/prompt.py` - Interactive CLI prompts using rich library
 - `cli/core/registry.py` - Central registry for module classes (auto-discovers modules)
 - `cli/core/repo.py` - Repository management for syncing git-based template libraries
-- `cli/core/schema/` - Schema management package (**JSON-based schema system**)
-  - `loader.py` - SchemaLoader class for loading and validating JSON schemas
-  - `<module>/` - Module-specific schema directories (e.g., `compose/`, `terraform/`)
-  - `<module>/v*.json` - Version-specific JSON schema files (e.g., `v1.0.json`, `v1.2.json`)
 - `cli/core/section.py` - VariableSection class (stores section metadata and variables)
   - **Key Attributes**: `key`, `title`, `toggle`, `needs`, `variables` (dict of Variable objects)
 - `cli/core/template.py` - Template Class for parsing, managing and rendering templates
@@ -104,12 +99,9 @@ The project is stored in a public GitHub Repository, use issues, and branches fo
 **Module Structure:**
 Modules can be either single files or packages:
 - **Single file**: `cli/modules/modulename.py` (for simple modules)
-- **Package**: `cli/modules/modulename/` with `__init__.py` (for multi-schema modules)
 
 **Creating Modules:**
 - Subclass `Module` from `cli/core/module.py`
-- Define `name`, `description`, and `schema_version` class attributes
-- For multi-schema modules: organize specs in separate files (e.g., `spec_v1_0.py`, `spec_v1_1.py`)
 - Call `registry.register(YourModule)` at module bottom
 - Auto-discovered and registered at CLI startup
 
@@ -128,19 +120,10 @@ The system automatically discovers and registers modules at startup:
 - Modules are self-contained - can be added/removed without modifying core code
 - Type-safe - registry validates module interfaces at registration time
 
-**Module Schema System:**
 
-**JSON Schema Architecture** (Refactored from Python specs):
 
-All module schemas are now defined as **JSON files** in `cli/core/schema/<module>/v*.json`. This provides:
-- **Version control**: Easy schema comparison and diffs in git
-- **Language-agnostic**: Schemas can be consumed by tools outside Python
-- **Validation**: Built-in JSON schema validation
-- **Documentation**: Self-documenting schema structure
 
-**Schema File Location:**
 ```
-cli/core/schema/
   compose/
     v1.0.json
     v1.1.json
@@ -152,9 +135,7 @@ cli/core/schema/
   ...other modules...
 ```
 
-**JSON Schema Structure:**
 
-Schemas are arrays of section objects, where each section contains:
 
 ```json
 [
@@ -183,46 +164,27 @@ Schemas are arrays of section objects, where each section contains:
 ]
 ```
 
-**Schema Loading in Modules:**
 
-Modules load JSON schemas on-demand using the SchemaLoader:
 
 ```python
-from cli.core.schema import load_schema, has_schema, list_versions
 
 class MyModule(Module):
     name = "mymodule"
-    schema_version = "1.2"  # Latest version supported
     
-    def get_spec(self, template_schema: str) -> OrderedDict:
-        """Load JSON schema and convert to dict format."""
-        json_spec = load_schema(self.name, template_schema)
         # Convert JSON array to OrderedDict format
         return self._convert_json_to_dict(json_spec)
 ```
 
-**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
 
-**Working with Schemas:**
-- **View available versions**: Check `cli/core/schema/<module>/` directory or use `list_versions(module)`
-- **Add new schema version**: Create new JSON file following naming convention (e.g., `v1.3.json`)
-- **Update module**: Increment `schema_version` in module class when adding new schema
-- **Validate schemas**: SchemaLoader automatically validates JSON structure on load
 
-**Migration from Python Specs:**
 
 Older Python-based `spec_v*.py` files have been migrated to JSON. The module `__init__.py` now:
-1. Loads JSON schemas using SchemaLoader
 2. Converts JSON array format to OrderedDict for backward compatibility
-3. Provides lazy loading via `_SchemaDict` class
 
 **Existing Modules:**
-- `cli/modules/compose/` - Docker Compose (JSON schemas: v1.0, v1.1, v1.2)
 - Other modules (ansible, terraform, kubernetes, helm, packer) - Work in Progress
 
 **(Work in Progress):** terraform, docker, ansible, kubernetes, packer modules
@@ -331,55 +293,65 @@ Templates are directory-based. Each template is a directory containing all the n
 
 **How templates are loaded and rendered:**
 
-1. **Discovery**: LibraryManager finds template directories containing `template.yaml`/`template.yml`
+1. **Discovery**: LibraryManager finds template directories containing `template.json`
 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.)
+11. **Validation**: Optional semantic validation (YAML structure, Docker Compose config, 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:
+Requires `template.json` with metadata and variables:
 
-```yaml
----
-kind: compose
-schema: "X.Y"  # Optional: Defaults to "1.0" if not specified (e.g., "1.0", "1.2")
-metadata:
-  name: My Service Template
-  description: A template for a service.
-  version: 1.0.0
-  author: Your Name
-  date: '2024-01-01'
-spec:
-  general:
-    vars:
-      service_name:
-        type: str
-        description: Service name
+```json
+{
+  "slug": "my-service",
+  "kind": "compose",
+  "metadata": {
+    "name": "My Service Template",
+    "description": "A template for a service.",
+    "author": "Your Name",
+    "date": "2024-01-01",
+    "version": {
+      "name": "v1.0.0"
+    }
+  },
+  "variables": [
+    {
+      "name": "general",
+      "title": "General",
+      "items": [
+        {
+          "name": "service_name",
+          "type": "str",
+          "title": "Service name"
+        }
+      ]
+    }
+  ]
+}
 ```
 
 ### Template Metadata Versioning
 
 **Template Version Field:**
-The `metadata.version` field in `template.yaml` should reflect the version of the underlying application or resource:
-- **Compose templates**: Match the Docker image version (e.g., `nginx:1.25.3` → `version: 1.25.3`)
-- **Terraform templates**: Match the provider version (e.g., AWS provider 5.23.0 → `version: 5.23.0`)
-- **Other templates**: Match the primary application/tool version being deployed
-- Use `latest` or increment template-specific version (e.g., `0.1.0`, `0.2.0`) only when no specific application version applies
+The `metadata.version` field in `template.json` is a structured object and may be partial:
+- `name`
+- `source_dep_name`
+- `source_dep_version`
+- `source_dep_digest`
+- `upstream_ref`
+- `notes`
 
-**Rationale:** This helps users identify which version of the application/provider the template is designed for and ensures template versions track upstream changes.
+**Rationale:** This separates the user-facing template version label from the tracked upstream dependency metadata used by the template snapshot.
 
 **Application Version Variables:**
 - **IMPORTANT**: Application/image versions should be **hardcoded** in template files (e.g., `image: nginx:1.25.3`)
@@ -388,64 +360,52 @@ The `metadata.version` field in `template.yaml` should reflect the version of th
 - This prevents version mismatches and ensures templates are tested with specific, known versions
 - Exception: Only create version variables if there's a strong technical reason (e.g., multi-component version pinning)
 
-### 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: "X.Y"  # Optional: Defaults to "1.0" if not specified (e.g., "1.0", "1.2")
-metadata:
-  name: My Template
-  version: 1.0.0
-  # ... other metadata fields
-spec:
-  # ... variable specifications
+```json
+{
+  "slug": "my-template",
+  "kind": "compose",
+  "metadata": {
+    "name": "My Template",
+    "version": {
+      "name": "v1.0.0"
+    }
+  },
+  "variables": [
+    {
+      "name": "general",
+      "title": "General",
+      "items": []
+    }
+  ]
+}
 ```
 
 **How It Works:**
-- **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
-- **Incompatibility**: Template schema > Module schema → `IncompatibleSchemaVersionError`
 
 **Behavior:**
-- Templates without `schema` field default to "1.0" (backward compatible)
 - 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 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 = "X.Y"  # e.g., "1.0", "1.2"
   spec = VariableCollection.from_dict({...})  # Single spec
 ```
 
-**Multi-Schema Module Example:**
 ```python
 # 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."""
     # 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()
 ```
@@ -477,14 +437,11 @@ When using Traefik with Docker Compose, the `traefik.docker.network` label is **
 
 **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)
@@ -499,14 +456,12 @@ You only need to define variables in your template's `spec` section when:
 
 **Example:**
 ```yaml
-# 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:**
@@ -528,7 +483,6 @@ spec:
 **Section Features:**
 - **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
-  - 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.
 
@@ -593,7 +547,7 @@ spec:
 - Validator registry system in `cli/core/validators.py`
 - Extensible: `ContentValidator` abstract base class
 - Built-in validators: `DockerComposeValidator`, `YAMLValidator`
-- Validates rendered output (YAML structure, Docker Compose schema, etc.)
+- Validates rendered output (YAML structure, Docker Compose config, etc.)
 - Triggered via `compose validate` command with `--semantic` flag (enabled by default)
 
 ## Prompt
@@ -735,11 +689,9 @@ python3 -m archetypes compose validate --library /path/to/templates
 
 **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

+ 9 - 0
CHANGELOG.md

@@ -7,6 +7,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
 
 ## [Unreleased]
 
+### Added
+- `template.json` runtime support with required `files/` directories, custom `<< >>` / `<% %>` / `<# #>` delimiters, and legacy-format rejection for `0.2.0` (#1768)
+- Remote generation destinations via `generate --remote` and `--remote-path`, including SSH host discovery and SCP upload flow (#1765)
+- Initial dedicated `swarm` module and validation flow as groundwork for the `compose` / `swarm` split in `0.2.0` (#1766)
+
+### Changed
+- Default library migration now rewrites the built-in repository from `christianlempa/boilerplates` to `christianlempa/boilerplates-library`, with startup notices and legacy `/library` path fallback (#1762)
+- Variable definitions now use the `secret` type and nested `config` metadata for options, placeholders, sliders, and secret autogeneration across the runtime, schemas, and migrated template specs (#1767)
+
 ## [0.1.2] - 2025-12-11
 
 ### Fixed

+ 17 - 35
README.md

@@ -1,27 +1,24 @@
-# Christian's `Boilerplates`
+![Welcome](./.assets/banner.jpg)
 
-[![Welcome](https://cnd-prod-1.s3.us-west-004.backblazeb2.com/new-banner4-scaled-for-github.jpg)](https://youtu.be/apgp9egIKK8)
+Create reusable templates and turn them into configurable workloads for homelabs and self-hosted infrastructure. *Free and Open-Source.*
 
-**Hey, there!**
+## How it works
 
-**I'm Christian, and I'm passionate about creating educational tech content for IT Pros and Homelab nerds.**
+Create reusable templates for infrastructure expertise like Docker, Kubernetes, Terraform, Ansible, Python, and more. Use the built-in *Jinja2-like* templating syntax with `<< >>` variables, `<% %>` blocks, and `<# #>` comments to keep configuration modular and conditional. Sync with Git in both directions or manage everything locally. Render templates, configure variables through a guided wizard, and wire up secrets. Copy them to remote servers and environments or any local directory.
 
-## What are Boilerplates?
-
-**Boilerplates** is a curated collection of production-ready templates for your homelab and infrastructure projects. Stop copying configurations from random GitHub repos or starting from scratch every time you spin up a new service!
+*Don't want to start from scratch?*
+> Explore 100+ template presets for homelabs and self-hosted infrastructure: https://github.com/ChristianLempa/boilerplates-library
 
 ## Boilerplates CLI
 
-The Boilerplates CLI tool gives you instant access to battle-tested templates for Docker, Terraform, Ansible, Kubernetes, and more.
-
-Each template includes sensible defaults, best practices, and common configuration patterns—so you can focus on customizing for your environment.
+The Boilerplates CLI is the main interface for working with template libraries locally. It lets you discover available templates, inspect their metadata and variables, validate them, and generate ready-to-use files.
 
-**Key Features:**
-- 🚀 **Quick Setup** - Generate complete project structures in seconds
-- 🔧 **Fully Customizable** - Interactive prompts or non-interactive mode with variable overrides
-- 💾 **Smart Defaults** - Save your preferred values and reuse across projects
+It combines template-defined variables and defaults, guided interactive prompts, CLI variable overrides, and git-backed template libraries into one workflow. In practice, that means you can keep reusable boilerplates in a repository and turn them into concrete, environment-specific configurations with a single command.
 
-> **Note:** Technologies evolve rapidly. While I actively maintain these templates, always review generated configurations before deploying to production.
+> **WARNING**
+> Boilerplates `0.2.0` introduced the new template format. Legacy `template.yaml` / `template.yml` manifests and `.j2` template files are no longer supported.
+>
+> New templates must use `template.json`, keep renderable content under `files/`, and use the custom *Jinja2*-like delimiters `<< >>`, `<% %>`, and `<# #>` instead of default *Jinja2* syntax.
 
 ### Installation
 
@@ -82,10 +79,10 @@ boilerplates compose show nginx
 boilerplates compose generate authentik
 
 # Generate with custom output directory
-boilerplates compose generate nginx my-nginx-server
+boilerplates compose generate nginx --output my-nginx-server
 
 # Non-interactive mode with variable overrides
-boilerplates compose generate traefik my-proxy \
+boilerplates compose generate traefik --output my-proxy \
   --var service_name=traefik \
   --var traefik_enabled=true \
   --var traefik_host=proxy.example.com \
@@ -123,25 +120,10 @@ boilerplates repo add my-templates https://github.com/user/templates \
 boilerplates repo remove my-templates
 ```
 
-## Documentation
-
-For comprehensive documentation, advanced usage, and template development guides, check out the **[Wiki](../../wiki)** _(coming soon)_.
-
-If you're looking for detailed tutorials on specific tools and technologies, visit my [YouTube Channel](https://www.youtube.com/@christianlempa).
-
 ## Contribution
 
-If you’d like to contribute to this project, reach out to me on social media or [Discord](https://christianlempa.de/discord), or create a pull request for the necessary changes.
-
-## Other Resources
-
-- [Dotfiles](https://github.com/christianlempa/dotfiles) - My personal configuration files on macOS
-- [Cheat-Sheets](https://github.com/christianlempa/cheat-sheets) - Command Reference for various tools and technologies
-
-## Support me
-
-Creating high-quality videos and valuable resources that are accessible to everyone, free of charge, is a huge challenge. With your contribution, I can dedicate more time and effort into the creation process, which ultimately enhances the quality of the content. So, all your support, by becoming a member, truly makes a significant impact on what I do. And you’ll also get some cool benefits and perks in return, as a recognition of your support.
+Contributions are welcome. Feel free to open an issue or submit a pull request!
 
-Remember, ***supporting me is entirely optional.*** Your choice to become a member or not won't change your access to my videos and resources. You are also welcome to reach out to me on Discord, if you have any questions or feedback.
+## License
 
-[https://www.patreon.com/christianlempa](https://www.patreon.com/christianlempa)
+This repository is licensed under the [MIT License](./LICENSE).

+ 0 - 1
WARP.md

@@ -1 +0,0 @@
-AGENTS.md

+ 1 - 1
cli/__init__.py

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

+ 11 - 0
cli/__main__.py

@@ -20,7 +20,9 @@ from typer.core import TyperGroup
 import cli.modules
 from cli import __version__
 from cli.core import repo
+from cli.core.config import ConfigManager
 from cli.core.display import DisplayManager
+from cli.core.exceptions import ConfigError
 from cli.core.registry import registry
 
 
@@ -109,6 +111,15 @@ def main(
     ctx.ensure_object(dict)
     ctx.obj["log_level"] = log_level
 
+    # Trigger config migration early and surface any user-visible notices once.
+    try:
+        ConfigManager()
+    except ConfigError as e:
+        display.error("Failed to load configuration", details=str(e))
+        sys.exit(1)
+    for notice in ConfigManager.consume_migration_notices():
+        display.warning(notice.message)
+
     # If no subcommand is provided, show help and friendly intro
     if ctx.invoked_subcommand is None:
         console.print(ctx.get_help())

+ 95 - 10
cli/core/config/config_manager.py

@@ -5,7 +5,7 @@ import shutil
 import tempfile
 from dataclasses import dataclass
 from pathlib import Path
-from typing import Any
+from typing import Any, ClassVar
 
 import yaml
 
@@ -13,6 +13,21 @@ from ..exceptions import ConfigError, ConfigValidationError, YAMLParseError
 
 logger = logging.getLogger(__name__)
 
+DEFAULT_LIBRARY_NAME = "default"
+DEFAULT_LIBRARY_DIRECTORY = "."
+DEFAULT_LIBRARY_BRANCH = "main"
+DEFAULT_LIBRARY_URL = "https://github.com/christianlempa/boilerplates-library.git"
+LEGACY_DEFAULT_LIBRARY_DIRECTORY = "library"
+LEGACY_DEFAULT_LIBRARY_URL = "https://github.com/christianlempa/boilerplates.git"
+
+
+@dataclass
+class MigrationNotice:
+    """Represents a user-visible config migration notice."""
+
+    kind: str
+    message: str
+
 
 @dataclass
 class LibraryConfig:
@@ -30,6 +45,8 @@ class LibraryConfig:
 class ConfigManager:
     """Manages configuration for the CLI application."""
 
+    _pending_migration_notices: ClassVar[list[MigrationNotice]] = []
+
     def __init__(self, config_path: str | Path | None = None) -> None:
         """Initialize the configuration manager.
 
@@ -72,11 +89,11 @@ class ConfigManager:
             "preferences": {"editor": "vim", "output_dir": None, "library_paths": []},
             "libraries": [
                 {
-                    "name": "default",
+                    "name": DEFAULT_LIBRARY_NAME,
                     "type": "git",
-                    "url": "https://github.com/christianlempa/boilerplates.git",
-                    "branch": "main",
-                    "directory": "library",
+                    "url": DEFAULT_LIBRARY_URL,
+                    "branch": DEFAULT_LIBRARY_BRANCH,
+                    "directory": DEFAULT_LIBRARY_DIRECTORY,
                     "enabled": True,
                 }
             ],
@@ -95,11 +112,11 @@ class ConfigManager:
                 logger.info("Migrating config: adding libraries section")
                 config["libraries"] = [
                     {
-                        "name": "default",
+                        "name": DEFAULT_LIBRARY_NAME,
                         "type": "git",
-                        "url": "https://github.com/christianlempa/boilerplates.git",
-                        "branch": "refactor/boilerplates-v2",
-                        "directory": "library",
+                        "url": DEFAULT_LIBRARY_URL,
+                        "branch": DEFAULT_LIBRARY_BRANCH,
+                        "directory": DEFAULT_LIBRARY_DIRECTORY,
                         "enabled": True,
                     }
                 ]
@@ -116,12 +133,80 @@ class ConfigManager:
                         library["type"] = "git"
                         needs_migration = True
 
+            default_library_migrated = self._migrate_default_library(config)
+            needs_migration = needs_migration or default_library_migrated
+
             # Write back if migration was needed
             if needs_migration:
                 self._write_config(config)
                 logger.info("Config migration completed successfully")
+        except (ConfigError, ConfigValidationError, YAMLParseError):
+            raise
         except Exception as e:
-            logger.warning(f"Config migration failed: {e}")
+            logger.error(f"Config migration failed: {e}")
+            raise ConfigError(f"Failed to migrate configuration '{self.config_path}': {e}") from e
+
+    def _migrate_default_library(self, config: dict[str, Any]) -> bool:
+        """Rewrite the built-in default git library entry to the 0.2.0 library repo."""
+        libraries = config.get("libraries", [])
+        migrated = False
+
+        for library in libraries:
+            if library.get("name") != DEFAULT_LIBRARY_NAME:
+                continue
+
+            if library.get("type", "git") != "git":
+                continue
+
+            if library.get("url") != LEGACY_DEFAULT_LIBRARY_URL:
+                continue
+
+            if library.get("directory", LEGACY_DEFAULT_LIBRARY_DIRECTORY) != LEGACY_DEFAULT_LIBRARY_DIRECTORY:
+                continue
+
+            target_state = {
+                "url": DEFAULT_LIBRARY_URL,
+                "directory": DEFAULT_LIBRARY_DIRECTORY,
+            }
+
+            changed = any(library.get(key) != value for key, value in target_state.items())
+            if not changed:
+                break
+
+            previous_location = library.get("url") or library.get("path") or "<unknown>"
+            library.update(target_state)
+            migrated = True
+            logger.info(
+                "Migrated default library from %s to %s (%s)",
+                previous_location,
+                DEFAULT_LIBRARY_URL,
+                DEFAULT_LIBRARY_DIRECTORY,
+            )
+            self._add_migration_notice(
+                kind="default_library_repo",
+                message=(
+                    "Your default template library was migrated to "
+                    "christianlempa/boilerplates-library. "
+                    "Run 'boilerplates repo update' to sync the new templates."
+                ),
+            )
+            break
+
+        return migrated
+
+    @classmethod
+    def _add_migration_notice(cls, kind: str, message: str) -> None:
+        """Queue a migration notice for later display in the CLI layer."""
+        if any(notice.kind == kind and notice.message == message for notice in cls._pending_migration_notices):
+            return
+        cls._pending_migration_notices.append(MigrationNotice(kind=kind, message=message))
+
+    @classmethod
+    def consume_migration_notices(cls) -> list[MigrationNotice]:
+        """Return and clear pending migration notices."""
+        notices = cls._pending_migration_notices.copy()
+        cls._pending_migration_notices.clear()
+        return notices
 
     def _read_config(self) -> dict[str, Any]:
         """Read configuration from file.

+ 1 - 1
cli/core/display/display_base.py

@@ -300,7 +300,7 @@ class BaseDisplay:
         return icon_map.get(icon_type, "")
 
     def get_lock_icon(self) -> str:
-        """Get the lock icon for sensitive variables.
+        """Get the lock icon for secret variables.
 
         Returns:
             Lock icon unicode character

+ 1 - 1
cli/core/display/display_icons.py

@@ -163,7 +163,7 @@ class IconManager:
 
     @classmethod
     def lock(cls) -> str:
-        """Get the lock icon (for sensitive variables)."""
+        """Get the lock icon (for secret variables)."""
         return cls.UI_LOCK
 
     @classmethod

+ 5 - 7
cli/core/display/display_table.py

@@ -92,7 +92,6 @@ class TableDisplay:
         table.add_column("Name")
         table.add_column("Tags")
         table.add_column("Version", no_wrap=True)
-        table.add_column("Schema", no_wrap=True)
         table.add_column("Library", no_wrap=True)
 
         settings = self.settings
@@ -101,8 +100,7 @@ class TableDisplay:
             name = template.metadata.name or settings.TEXT_UNNAMED_TEMPLATE
             tags_list = template.metadata.tags or []
             tags = ", ".join(tags_list) if tags_list else "-"
-            version = str(template.metadata.version) if template.metadata.version else ""
-            schema = template.schema_version if hasattr(template, "schema_version") else "1.0"
+            version = template.metadata.version.name if template.metadata.version else ""
 
             # Format library with icon and color
             library_name = template.metadata.library or ""
@@ -111,7 +109,7 @@ class TableDisplay:
             color = "yellow" if library_type == "static" else "blue"
             library_display = f"[{color}]{icon} {library_name}[/{color}]"
 
-            table.add_row(template.id, name, tags, version, schema, library_display)
+            table.add_row(template.id, name, tags, version, library_display)
 
         self.base._print_table(table)
 
@@ -216,14 +214,14 @@ class TableDisplay:
         var_type = var_data.get("type", "string")
         var_default = var_data.get("default", "")
         var_desc = var_data.get("description", "")
-        var_sensitive = var_data.get("sensitive", False)
+        var_is_secret = var_data.get("type") == "secret"
 
         label = f"[green]{var_name}[/green] [dim]({var_type})[/dim]"
 
         if var_default is not None and var_default != "":
             settings = self.settings
-            display_val = settings.SENSITIVE_MASK if var_sensitive else str(var_default)
-            if not var_sensitive:
+            display_val = settings.SENSITIVE_MASK if var_is_secret else str(var_default)
+            if not var_is_secret:
                 display_val = self.base.truncate(display_val, settings.VALUE_MAX_LENGTH_DEFAULT)
             label += f" = [{settings.COLOR_WARNING}]{display_val}[/{settings.COLOR_WARNING}]"
 

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

@@ -66,8 +66,7 @@ class TemplateDisplay:
         settings = self.settings
 
         template_name = template.metadata.name or settings.TEXT_UNNAMED_TEMPLATE
-        version = str(template.metadata.version) if template.metadata.version else settings.TEXT_VERSION_NOT_SPECIFIED
-        schema = template.schema_version if hasattr(template, "schema_version") else "1.0"
+        version = template.metadata.version.name if template.metadata.version else settings.TEXT_VERSION_NOT_SPECIFIED
         description = template.metadata.description or settings.TEXT_NO_DESCRIPTION
 
         # Get library information and format with icon/color
@@ -88,9 +87,6 @@ class TemplateDisplay:
         header_content.append("version:", style="white")
         header_content.append(version, style="cyan")
         header_content.append(" │ ", style="dim")
-        header_content.append("schema:", style="white")
-        header_content.append(schema, style="magenta")
-        header_content.append(" │ ", style="dim")
         header_content.append("library:", style="white")
         header_content.append(icon + " ", style=color)
         header_content.append(library_name, style=color)

+ 84 - 9
cli/core/display/display_variable.py

@@ -69,7 +69,7 @@ class VariableDisplay:
                 max_length=settings.VALUE_MAX_LENGTH_SHORT,
             )
             curr = variable.get_display_value(
-                mask_sensitive=True,
+                mask_secret=True,
                 max_length=settings.VALUE_MAX_LENGTH_SHORT,
                 show_none=False,
             )
@@ -82,7 +82,7 @@ class VariableDisplay:
         # Default formatting
         settings = self.settings
         value = variable.get_display_value(
-            mask_sensitive=True,
+            mask_secret=True,
             max_length=settings.VALUE_MAX_LENGTH_DEFAULT,
             show_none=True,
         )
@@ -103,7 +103,7 @@ class VariableDisplay:
         """
         settings = self.settings
 
-        if variable.sensitive:
+        if variable.is_secret():
             return settings.SENSITIVE_MASK
         if value is None or value == "":
             return f"[{settings.COLOR_MUTED}]({settings.TEXT_EMPTY_VALUE})[/{settings.COLOR_MUTED}]"
@@ -150,6 +150,78 @@ class VariableDisplay:
             return f"[bold {style}]{section.title}{disabled_text}[/bold {style}]"
         return f"[bold]{section.title}{disabled_text}[/bold]"
 
+    def _render_variable_options(self, variable) -> str:
+        """Render compact variable options/configuration summary."""
+        parts: list[str] = []
+        config = variable.config
+
+        if variable.type == "enum" and variable.options:
+            parts.append(", ".join(variable.options))
+
+        if config:
+            parts.extend(self._render_textarea_option(variable, config))
+            parts.extend(self._render_integer_options(variable, config))
+            parts.extend(self._render_secret_options(variable))
+
+        return self.base.truncate(" | ".join(parts), self.settings.VALUE_MAX_LENGTH_DEFAULT) if parts else ""
+
+    @staticmethod
+    def _render_textarea_option(variable, config) -> list[str]:
+        if variable.type in {"str", "secret"} and config.textarea:
+            return ["textarea"]
+        return []
+
+    @staticmethod
+    def _render_integer_options(variable, config) -> list[str]:
+        if variable.type != "int":
+            return []
+
+        parts: list[str] = []
+        if config.slider and config.min is not None and config.max is not None:
+            slider_part = f"slider {config.min}..{config.max}"
+            step = config.step if config.step is not None else 1
+            if step != 1:
+                slider_part += f" step {step}"
+            parts.append(slider_part)
+        else:
+            bounds = []
+            if config.min is not None:
+                bounds.append(f"min={config.min}")
+            if config.max is not None:
+                bounds.append(f"max={config.max}")
+            if config.step is not None:
+                bounds.append(f"step={config.step}")
+            if bounds:
+                parts.append(" ".join(bounds))
+
+        if config.unit:
+            parts.append(f"unit={config.unit}")
+
+        return parts
+
+    @staticmethod
+    def _render_secret_options(variable) -> list[str]:
+        if not (variable.is_secret() and variable.autogenerated):
+            return []
+
+        if variable.autogenerated_base64:
+            bytes_value = (
+                variable.autogenerated_config.bytes_or_default()
+                if variable.autogenerated_config
+                else variable.autogenerated_length
+            )
+            return [f"autogen base64 bytes={bytes_value}"]
+
+        length = (
+            variable.autogenerated_config.length_or_default()
+            if variable.autogenerated_config
+            else variable.autogenerated_length
+        )
+        parts = [f"autogen chars len={length}"]
+        if variable.autogenerated_config and variable.autogenerated_config.characters:
+            parts.append(f"charset={len(variable.autogenerated_config.characters)}")
+        return parts
+
     def _render_variable_row(self, var_name: str, variable, is_dimmed: bool, var_satisfied: bool) -> tuple:
         """Build variable row data for table display.
 
@@ -160,7 +232,7 @@ class VariableDisplay:
             var_satisfied: Whether variable dependencies are satisfied
 
         Returns:
-            Tuple of (var_display, type, default_val, description, row_style)
+            Tuple of (var_display, type, default_val, options, description, row_style)
         """
         settings = self.settings
 
@@ -171,15 +243,16 @@ class VariableDisplay:
         default_val = self.render_variable_value(variable, is_dimmed=is_dimmed, var_satisfied=var_satisfied)
 
         # Build variable display name
-        sensitive_icon = f" {IconManager.lock()}" if variable.sensitive else ""
+        secret_icon = f" {IconManager.lock()}" if variable.is_secret() else ""
         # Only show required indicator if variable is enabled (not dimmed and dependencies satisfied)
         required_indicator = settings.LABEL_REQUIRED if variable.required and not is_dimmed and var_satisfied else ""
-        var_display = f"{settings.VAR_NAME_INDENT}{var_name}{sensitive_icon}{required_indicator}"
+        var_display = f"{settings.VAR_NAME_INDENT}{var_name}{secret_icon}{required_indicator}"
 
         return (
             var_display,
             variable.type or "str",
             default_val,
+            self._render_variable_options(variable),
             variable.description or "",
             row_style,
         )
@@ -204,6 +277,7 @@ class VariableDisplay:
         variables_table.add_column("Variable", style=settings.STYLE_VAR_COL_NAME, no_wrap=True)
         variables_table.add_column("Type", style=settings.STYLE_VAR_COL_TYPE)
         variables_table.add_column("Default", style=settings.STYLE_VAR_COL_DEFAULT)
+        variables_table.add_column("Options", style=settings.STYLE_VAR_COL_DEFAULT)
         variables_table.add_column("Description", style=settings.STYLE_VAR_COL_DESC)
 
         first_section = True
@@ -212,7 +286,7 @@ class VariableDisplay:
                 continue
 
             if not first_section:
-                variables_table.add_row("", "", "", "", style=settings.STYLE_DISABLED)
+                variables_table.add_row("", "", "", "", "", style=settings.STYLE_DISABLED)
             first_section = False
 
             # Check if section is enabled AND dependencies are satisfied
@@ -222,7 +296,7 @@ class VariableDisplay:
 
             # Render section header
             header_text = self._render_section_header(section, is_dimmed)
-            variables_table.add_row(header_text, "", "", "")
+            variables_table.add_row(header_text, "", "", "", "")
 
             # Render variables
             for var_name, variable in section.variables.items():
@@ -234,9 +308,10 @@ class VariableDisplay:
                     var_display,
                     var_type,
                     default_val,
+                    options,
                     description,
                     row_style,
                 ) = self._render_variable_row(var_name, variable, is_dimmed, var_satisfied)
-                variables_table.add_row(var_display, var_type, default_val, description, style=row_style)
+                variables_table.add_row(var_display, var_type, default_val, options, description, style=row_style)
 
         self.base._print_table(variables_table)

+ 0 - 35
cli/core/exceptions.py

@@ -79,30 +79,6 @@ class TemplateValidationError(TemplateError):
     pass
 
 
-class IncompatibleSchemaVersionError(TemplateError):
-    """Raised when a template uses a schema version not supported by the module."""
-
-    def __init__(
-        self,
-        template_id: str,
-        template_schema: str,
-        module_schema: str,
-        module_name: str,
-    ):
-        self.template_id = template_id
-        self.template_schema = template_schema
-        self.module_schema = module_schema
-        self.module_name = module_name
-        msg = (
-            f"Template '{template_id}' uses schema version {template_schema}, "
-            f"but module '{module_name}' only supports up to version {module_schema}.\n\n"
-            f"This template requires features not available in your current CLI version.\n"
-            f"Please upgrade the boilerplates CLI.\n\n"
-            f"Run: pip install --upgrade boilerplates"
-        )
-        super().__init__(msg)
-
-
 @dataclass
 class RenderErrorContext:
     """Context information for template rendering errors."""
@@ -198,17 +174,6 @@ class ModuleLoadError(ModuleError):
     pass
 
 
-class SchemaError(BoilerplatesError):
-    """Raised when schema operations fail."""
-
-    def __init__(self, message: str, details: str | None = None):
-        self.details = details
-        msg = message
-        if details:
-            msg += f" ({details})"
-        super().__init__(msg)
-
-
 class FileOperationError(BoilerplatesError):
     """Raised when file operations fail."""
 

+ 54 - 0
cli/core/input/input_manager.py

@@ -61,6 +61,7 @@ class InputManager:
             result = Prompt.ask(
                 f"[{self.settings.PROMPT_STYLE}]{prompt}[/{self.settings.PROMPT_STYLE}]",
                 default=default or "",
+                show_default=default is not None,
                 console=console,
             )
 
@@ -84,6 +85,7 @@ class InputManager:
         return Prompt.ask(
             f"[{self.settings.PROMPT_STYLE}]{prompt}[/{self.settings.PROMPT_STYLE}]",
             default=default or "",
+            show_default=default is not None,
             password=True,
             console=console,
         )
@@ -188,6 +190,58 @@ class InputManager:
                 f"[{self.settings.PROMPT_ERROR_STYLE}]{self.settings.MSG_INVALID_CHOICE}[/{self.settings.PROMPT_ERROR_STYLE}]"
             )
 
+    def numbered_choice(self, prompt: str, choices: list[str], default: str | None = None) -> str:
+        """Prompt user to select one option from a numbered list.
+
+        Users can answer with either the displayed number or the literal choice
+        value. This keeps common binary selections concise without changing the
+        existing free-text choice behavior used elsewhere in the CLI.
+
+        Args:
+            prompt: Prompt message to display
+            choices: List of valid options
+            default: Default choice if user presses Enter
+
+        Returns:
+            Selected choice
+        """
+        if not choices:
+            raise ValueError("Choices list cannot be empty")
+
+        if default is not None and default not in choices:
+            raise ValueError("Default choice must be one of the available choices")
+
+        numbered_choices = {str(index): choice for index, choice in enumerate(choices, start=1)}
+        default_index = None
+        if default is not None:
+            default_index = choices.index(default) + 1
+
+        while True:
+            console.print(f"[{self.settings.PROMPT_STYLE}]{prompt}[/{self.settings.PROMPT_STYLE}]")
+            for index, choice in enumerate(choices, start=1):
+                console.print(f"  {index}. {choice}")
+
+            result = Prompt.ask(
+                f"[{self.settings.PROMPT_STYLE}]Selection[/{self.settings.PROMPT_STYLE}]",
+                default=str(default_index) if default_index is not None else "",
+                show_default=default_index is not None,
+                console=console,
+            ).strip()
+
+            if result == "" and default_index is not None:
+                return numbered_choices[str(default_index)]
+
+            if result in numbered_choices:
+                return numbered_choices[result]
+
+            normalized_result = result
+            if normalized_result in choices:
+                return normalized_result
+
+            console.print(
+                f"[{self.settings.PROMPT_ERROR_STYLE}]{self.settings.MSG_INVALID_CHOICE}[/{self.settings.PROMPT_ERROR_STYLE}]"
+            )
+
     def validate_email(self, email: str) -> bool:
         """Validate email address format.
 

+ 68 - 16
cli/core/input/prompt_manager.py

@@ -4,7 +4,7 @@ import logging
 from typing import Any, Callable
 
 from rich.console import Console
-from rich.prompt import IntPrompt, Prompt
+from rich.prompt import Prompt
 
 from ..display import DisplayManager
 from ..input import InputManager
@@ -119,13 +119,19 @@ class PromptHandler:
         # Use variable's native methods for prompt text and default value
         prompt_text = variable.get_prompt_text()
         default_value = variable.get_normalized_default()
+        has_explicit_default = "default" in variable._explicit_fields or "value" in variable._explicit_fields
+        if not has_explicit_default and not variable.autogenerated and not variable.is_required():
+            default_value = None
 
-        # Add lock icon before default value for sensitive or autogenerated variables
-        if variable.sensitive or variable.autogenerated:
+        # Add lock icon before default value for secret or autogenerated variables
+        if variable.is_secret() or variable.autogenerated:
             # Format: "Prompt text 🔒 (default)"
             # The lock icon goes between the text and the default value in parentheses
             prompt_text = f"{prompt_text} {self.display.get_lock_icon()}"
 
+        if variable.config.placeholder:
+            prompt_text = f"{prompt_text} [dim]({variable.config.placeholder})[/dim]"
+
         # Check if this specific variable is required (has no default and not autogenerated)
         var_is_required = variable.is_required()
 
@@ -133,12 +139,12 @@ class PromptHandler:
         if var_is_required:
             prompt_text = f"{prompt_text} [bold red]*required[/bold red]"
 
-        handler = self._get_prompt_handler(variable)
+        allow_empty = not var_is_required and default_value is None
+        handler = self._get_prompt_handler(variable, allow_empty=allow_empty)
 
         # Add validation hint (includes both extra text and enum options)
         hint = variable.get_validation_hint()
         if hint:
-            # Show options/extra inline inside parentheses, before the default
             prompt_text = f"{prompt_text} [dim]({hint})[/dim]"
 
         while True:
@@ -158,64 +164,105 @@ class PromptHandler:
                 # Unexpected error — log and retry using the stored (unconverted) value
                 logger.error(f"Error prompting for variable '{variable.name}': {e!s}")
                 default_value = variable.value
-                handler = self._get_prompt_handler(variable)
+                handler = self._get_prompt_handler(variable, allow_empty=allow_empty)
 
-    def _get_prompt_handler(self, variable: Variable) -> Callable:
+    def _get_prompt_handler(self, variable: Variable, allow_empty: bool = False) -> Callable:
         """Return the prompt function for a variable type."""
         handlers = {
-            "bool": self._prompt_bool,
-            "int": self._prompt_int,
+            "bool": lambda text, default: self._prompt_bool(text, default, allow_empty=allow_empty),
+            "int": lambda text, default: self._prompt_int(
+                text,
+                default,
+                allow_empty=allow_empty,
+                min_value=variable.config.min if variable.config else None,
+                max_value=variable.config.max if variable.config else None,
+            ),
             # For enum prompts we pass the variable.extra through so options and extra
             # can be combined into a single inline hint.
             "enum": lambda text, default: self._prompt_enum(
                 text,
                 variable.options or [],
                 default,
+                allow_empty=allow_empty,
                 _extra=getattr(variable, "extra", None),
             ),
         }
         return handlers.get(
             variable.type,
-            lambda text, default: self._prompt_string(text, default, is_sensitive=variable.sensitive),
+            lambda text, default: self._prompt_string(text, default, is_secret=variable.is_secret()),
         )
 
     def _show_validation_error(self, message: str) -> None:
         """Display validation feedback consistently."""
         self.display.error(message)
 
-    def _prompt_string(self, prompt_text: str, default: Any = None, is_sensitive: bool = False) -> str | None:
+    def _prompt_string(self, prompt_text: str, default: Any = None, is_secret: bool = False) -> str | None:
+        if is_secret:
+            value = Prompt.ask(
+                prompt_text,
+                default="",
+                show_default=False,
+                password=True,
+            )
+            stripped = value.strip() if value else None
+            if stripped:
+                return stripped
+            return default
+
         value = Prompt.ask(
             prompt_text,
             default=str(default) if default is not None else "",
             show_default=True,
-            password=is_sensitive,
+            password=False,
         )
         stripped = value.strip() if value else None
         return stripped if stripped else None
 
-    def _prompt_bool(self, prompt_text: str, default: Any = None) -> bool | None:
+    def _prompt_bool(self, prompt_text: str, default: Any = None, allow_empty: bool = False) -> bool | str | None:
         input_mgr = InputManager()
+        if allow_empty and default is None:
+            value = Prompt.ask(prompt_text, default="", show_default=False)
+            stripped = value.strip() if value else ""
+            return stripped if stripped else None
         if default is None:
             return input_mgr.confirm(prompt_text, default=None)
         converted = default if isinstance(default, bool) else str(default).lower() in ("true", "1", "yes", "on")
         return input_mgr.confirm(prompt_text, default=converted)
 
-    def _prompt_int(self, prompt_text: str, default: Any = None) -> int | None:
+    def _prompt_int(
+        self,
+        prompt_text: str,
+        default: Any = None,
+        allow_empty: bool = False,
+        min_value: int | None = None,
+        max_value: int | None = None,
+    ) -> int | str | None:
         converted = None
         if default is not None:
             try:
                 converted = int(default)
             except (ValueError, TypeError):
                 logger.warning(f"Invalid default integer value: {default}")
-        return IntPrompt.ask(prompt_text, default=converted)
+        if allow_empty and converted is None:
+            value = Prompt.ask(prompt_text, default="", show_default=False)
+            stripped = value.strip() if value else ""
+            return stripped if stripped else None
+        input_mgr = InputManager()
+        return input_mgr.integer(
+            prompt_text,
+            default=converted,
+            min_value=min_value,
+            max_value=max_value,
+        )
 
     def _prompt_enum(
         self,
         prompt_text: str,
         options: list[str],
         default: Any = None,
+        allow_empty: bool = False,
         _extra: str | None = None,
-    ) -> str:
+    ) -> str | None:
         """Prompt for enum selection with validation.
 
         Note: prompt_text should already include hint from variable.get_validation_hint()
@@ -228,6 +275,11 @@ class PromptHandler:
         if default and str(default) not in options:
             default = options[0]
 
+        if allow_empty and default is None:
+            value = Prompt.ask(prompt_text, default="", show_default=False)
+            stripped = value.strip() if value else ""
+            return stripped if stripped else None
+
         while True:
             value = Prompt.ask(
                 prompt_text,

+ 82 - 26
cli/core/library.py

@@ -1,17 +1,18 @@
 from __future__ import annotations
 
+import json
 import logging
 from pathlib import Path
 
-import yaml
-
 from .config import ConfigManager
 from .exceptions import DuplicateTemplateError, LibraryError, TemplateNotFoundError
+from .template import normalize_template_slug
 
 logger = logging.getLogger(__name__)
 
 # Qualified ID format: "template_id.library_name"
 QUALIFIED_ID_PARTS = 2
+TEMPLATE_MANIFEST_FILENAME = "template.json"
 
 
 class Library:
@@ -36,22 +37,43 @@ class Library:
 
     def _is_template_draft(self, template_path: Path) -> bool:
         """Check if a template is marked as draft."""
-        # Find the template file
-        for filename in ("template.yaml", "template.yml"):
-            template_file = template_path / filename
-            if template_file.exists():
-                break
-        else:
+        template_file = template_path / TEMPLATE_MANIFEST_FILENAME
+        if not template_file.exists():
             return False
 
         try:
             with template_file.open(encoding="utf-8") as f:
-                docs = [doc for doc in yaml.safe_load_all(f) if doc]
-                return docs[0].get("metadata", {}).get("draft", False) if docs else False
-        except (yaml.YAMLError, OSError) as e:
+                data = json.load(f) or {}
+            return bool(data.get("metadata", {}).get("draft", False))
+        except (json.JSONDecodeError, OSError) as e:
             logger.warning(f"Error checking draft status for {template_path}: {e}")
             return False
 
+    @staticmethod
+    def _has_template_manifest(template_path: Path) -> bool:
+        """Check if a directory contains the supported template manifest."""
+        return template_path.is_dir() and (template_path / TEMPLATE_MANIFEST_FILENAME).exists()
+
+    @staticmethod
+    def _load_template_id(template_path: Path) -> str:
+        """Load the canonical template ID from the manifest slug.
+
+        Falls back to the directory name when manifest metadata is unreadable.
+        """
+        manifest_path = template_path / TEMPLATE_MANIFEST_FILENAME
+        if manifest_path.exists():
+            try:
+                with manifest_path.open(encoding="utf-8") as file_handle:
+                    data = json.load(file_handle) or {}
+                slug = str(data.get("slug", "")).strip()
+                kind = str(data.get("kind", "")).strip()
+                if slug:
+                    return normalize_template_slug(slug, kind)
+            except (json.JSONDecodeError, OSError) as exc:
+                logger.warning("Error reading template slug for %s: %s", template_path, exc)
+
+        return template_path.name
+
     def find_by_id(self, module_name: str, template_id: str) -> tuple[Path, str]:
         """Find a template by its ID in this library.
 
@@ -67,19 +89,24 @@ class Library:
         """
         logger.debug(f"Looking for template '{template_id}' in module '{module_name}' in library '{self.name}'")
 
-        # Build the path to the specific template directory
-        template_path = self.path / module_name / template_id
-
-        # Check if template directory exists with a template file
-        has_template = template_path.is_dir() and any(
-            (template_path / f).exists() for f in ("template.yaml", "template.yml")
-        )
-
-        if not has_template or self._is_template_draft(template_path):
+        module_path = self.path / module_name
+        if not module_path.is_dir():
             raise TemplateNotFoundError(template_id, module_name)
 
-        logger.debug(f"Found template '{template_id}' at: {template_path}")
-        return template_path, self.name
+        try:
+            for item in module_path.iterdir():
+                if not self._has_template_manifest(item) or self._is_template_draft(item):
+                    continue
+                resolved_id = self._load_template_id(item)
+                if resolved_id == template_id:
+                    logger.debug("Found template '%s' at: %s", template_id, item)
+                    return item, self.name
+        except PermissionError as exc:
+            raise LibraryError(
+                f"Permission denied accessing module '{module_name}' in library '{self.name}': {exc}"
+            ) from exc
+
+        raise TemplateNotFoundError(template_id, module_name)
 
     def find(self, module_name: str, sort_results: bool = False) -> list[tuple[Path, str]]:
         """Find templates in this library for a specific module.
@@ -110,9 +137,9 @@ class Library:
         template_dirs = []
         try:
             for item in module_path.iterdir():
-                has_template = item.is_dir() and any((item / f).exists() for f in ("template.yaml", "template.yml"))
+                has_template = self._has_template_manifest(item)
                 if has_template and not self._is_template_draft(item):
-                    template_id = item.name
+                    template_id = self._load_template_id(item)
 
                     # Check for duplicate within same library
                     if template_id in seen_ids:
@@ -148,7 +175,9 @@ class LibraryManager:
         directory = lib_config.get("directory", ".")
         library_base = libraries_path / name
         if directory and directory != ".":
-            return library_base / directory
+            configured_path = library_base / directory
+            fallback_path = self._fallback_template_root(configured_path)
+            return fallback_path or configured_path
         return library_base
 
     def _resolve_static_library_path(self, name: str, lib_config: dict) -> Path | None:
@@ -161,7 +190,34 @@ class LibraryManager:
         library_path = Path(path_str).expanduser()
         if not library_path.is_absolute():
             library_path = (self.config.config_path.parent / library_path).resolve()
-        return library_path
+        fallback_path = self._fallback_template_root(library_path)
+        return fallback_path or library_path
+
+    @staticmethod
+    def _looks_like_template_root(path: Path) -> bool:
+        """Check whether a path looks like the root of a templates repository."""
+        if not path.is_dir():
+            return False
+        try:
+            return any(item.is_dir() for item in path.iterdir())
+        except OSError:
+            return False
+
+    def _fallback_template_root(self, path: Path) -> Path | None:
+        """Resolve common old-style /library paths to the actual template repo root."""
+        if path.exists():
+            if self._looks_like_template_root(path):
+                return path
+            if path.name == "library" and self._looks_like_template_root(path.parent):
+                logger.info("Using parent directory '%s' as template root instead of '%s'", path.parent, path)
+                return path.parent
+            return None
+
+        if path.name == "library" and self._looks_like_template_root(path.parent):
+            logger.info("Using parent directory '%s' as template root instead of missing '%s'", path.parent, path)
+            return path.parent
+
+        return None
 
     def _warn_missing_library(self, name: str, library_path: Path, lib_type: str) -> None:
         """Log warning about missing library."""

+ 109 - 129
cli/core/module/base_commands.py

@@ -6,7 +6,6 @@ import logging
 from dataclasses import dataclass
 from pathlib import Path
 
-from jinja2 import Template as Jinja2Template
 from typer import Exit
 
 from ..config import ConfigManager
@@ -19,6 +18,13 @@ from ..exceptions import (
 from ..input import InputManager
 from ..template import Template
 from ..validators import get_validator_registry
+from .generation_destination import (
+    GenerationDestination,
+    format_remote_destination,
+    prompt_generation_destination,
+    resolve_cli_destination,
+    write_rendered_files_remote,
+)
 from .helpers import (
     apply_cli_overrides,
     apply_var_file,
@@ -38,8 +44,9 @@ class GenerationConfig:
     """Configuration for template generation."""
 
     id: str
-    directory: str | None = None
     output: str | None = None
+    remote: str | None = None
+    remote_path: str | None = None
     interactive: bool = True
     var: list[str] | None = None
     var_file: str | None = None
@@ -48,19 +55,6 @@ class GenerationConfig:
     quiet: bool = False
 
 
-@dataclass
-class ConfirmationContext:
-    """Context for file generation confirmation."""
-
-    output_dir: Path
-    rendered_files: dict[str, str]
-    existing_files: list[Path] | None
-    dir_not_empty: bool
-    dry_run: bool
-    interactive: bool
-    display: DisplayManager
-
-
 def list_templates(module_instance, raw: bool = False) -> list:
     """List all templates."""
     logger.debug(f"Listing templates for module '{module_instance.name}'")
@@ -73,24 +67,26 @@ def list_templates(module_instance, raw: bool = False) -> list:
             # Output raw format (tab-separated values for easy filtering with awk/sed/cut)
             # Format: ID\tNAME\tTAGS\tVERSION\tLIBRARY
             for template in filtered_templates:
+                name = template.metadata.name or "Unnamed Template"
                 tags_list = template.metadata.tags or []
-                ",".join(tags_list) if tags_list else "-"
-                (str(template.metadata.version) if template.metadata.version else "-")
+                tags = ",".join(tags_list) if tags_list else "-"
+                version = template.metadata.version.name if template.metadata.version else "-"
+                library = template.metadata.library or "-"
+                module_instance.display.text("\t".join([template.id, name, tags, version, library]))
         else:
             # Output rich table format
             def format_template_row(template):
                 name = template.metadata.name or "Unnamed Template"
                 tags_list = template.metadata.tags or []
                 tags = ", ".join(tags_list) if tags_list else "-"
-                version = str(template.metadata.version) if template.metadata.version else ""
-                schema = template.schema_version if hasattr(template, "schema_version") else "1.0"
+                version = template.metadata.version.name if template.metadata.version else ""
                 library_name = template.metadata.library or ""
                 library_type = template.metadata.library_type or "git"
                 # Format library with icon and color
                 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}]"
-                return (template.id, name, tags, version, schema, library_display)
+                return (template.id, name, tags, version, library_display)
 
             module_instance.display.data_table(
                 columns=[
@@ -98,7 +94,6 @@ def list_templates(module_instance, raw: bool = False) -> list:
                     {"name": "Name"},
                     {"name": "Tags"},
                     {"name": "Version", "no_wrap": True},
-                    {"name": "Schema", "no_wrap": True},
                     {"name": "Library", "no_wrap": True},
                 ],
                 rows=filtered_templates,
@@ -128,15 +123,14 @@ def search_templates(module_instance, query: str) -> list:
             name = template.metadata.name or "Unnamed Template"
             tags_list = template.metadata.tags or []
             tags = ", ".join(tags_list) if tags_list else "-"
-            version = str(template.metadata.version) if template.metadata.version else ""
-            schema = template.schema_version if hasattr(template, "schema_version") else "1.0"
+            version = template.metadata.version.name if template.metadata.version else ""
             library_name = template.metadata.library or ""
             library_type = template.metadata.library_type or "git"
             # Format library with icon and color
             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}]"
-            return (template.id, name, tags, version, schema, library_display)
+            return (template.id, name, tags, version, library_display)
 
         module_instance.display.data_table(
             columns=[
@@ -144,7 +138,6 @@ def search_templates(module_instance, query: str) -> list:
                 {"name": "Name"},
                 {"name": "Tags"},
                 {"name": "Version", "no_wrap": True},
-                {"name": "Schema", "no_wrap": True},
                 {"name": "Library", "no_wrap": True},
             ],
             rows=filtered_templates,
@@ -234,22 +227,6 @@ def check_output_directory(
     return existing_files
 
 
-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
-
-
-def _collect_subdirectories(rendered_files: dict[str, str]) -> set[Path]:
-    """Collect unique subdirectories from file paths."""
-    subdirs = set()
-    for file_path in rendered_files:
-        parts = Path(file_path).parts
-        for i in range(1, len(parts)):
-            subdirs.add(Path(*parts[:i]))
-    return subdirs
-
-
 def _analyze_file_operations(
     output_dir: Path, rendered_files: dict[str, str]
 ) -> tuple[list[tuple[str, int, str]], int, int, int]:
@@ -285,6 +262,23 @@ def _format_size(total_size: int) -> str:
     return f"{total_size / BYTES_PER_MB:.1f}MB"
 
 
+def _get_rendered_file_stats(rendered_files: dict[str, str]) -> tuple[int, int, str]:
+    """Return file count, total size, and formatted size for rendered output."""
+    total_size = sum(len(content.encode("utf-8")) for content in rendered_files.values())
+    return len(rendered_files), total_size, _format_size(total_size)
+
+
+def _display_rendered_file_contents(rendered_files: dict[str, str], display: DisplayManager) -> None:
+    """Display rendered file contents for dry-run mode."""
+    display.text("")
+    display.heading("File Contents")
+    for file_path, content in sorted(rendered_files.items()):
+        display.text(f"\n[cyan]{file_path}[/cyan]")
+        display.text(f"{'─' * 80}")
+        display.text(content)
+    display.text("")
+
+
 def execute_dry_run(
     id: str,
     output_dir: Path,
@@ -300,26 +294,35 @@ def execute_dry_run(
     _file_operations, total_size, _new_files, overwrite_files = _analyze_file_operations(output_dir, rendered_files)
     size_str = _format_size(total_size)
 
-    # Show file contents if requested
     if show_files:
-        display.text("")
-        display.heading("File Contents")
-        for file_path, content in sorted(rendered_files.items()):
-            display.text(f"\n[cyan]{file_path}[/cyan]")
-            display.text(f"{'─' * 80}")
-            display.text(content)
-        display.text("")
+        _display_rendered_file_contents(rendered_files, display)
 
     logger.info(f"Dry run completed for template '{id}' - {len(rendered_files)} files, {total_size} bytes")
     return len(rendered_files), overwrite_files, size_str
 
 
-def write_rendered_files(
-    output_dir: Path,
+def execute_remote_dry_run(
+    remote_host: str,
+    remote_path: str,
     rendered_files: dict[str, str],
-    _quiet: bool,
-    _display: DisplayManager,
-) -> None:
+    show_files: bool,
+    display: DisplayManager,
+) -> tuple[int, str]:
+    """Preview a remote upload without writing files."""
+    total_files, _total_size, size_str = _get_rendered_file_stats(rendered_files)
+
+    if show_files:
+        _display_rendered_file_contents(rendered_files, display)
+
+    logger.info(
+        "Dry run completed for remote destination '%s' - %s files",
+        format_remote_destination(remote_host, remote_path),
+        total_files,
+    )
+    return total_files, size_str
+
+
+def write_rendered_files(output_dir: Path, rendered_files: dict[str, str]) -> None:
     """Write rendered files to the output directory."""
     output_dir.mkdir(parents=True, exist_ok=True)
 
@@ -376,33 +379,6 @@ def _render_template(template, id: str, display: DisplayManager, interactive: bo
     return rendered_files, variable_values
 
 
-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)
-        used_deprecated_arg = True
-        logger.debug(f"Using deprecated positional directory argument: {directory}")
-    else:
-        output_dir = Path(id)
-
-    # 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:
     """Display template rendering error with clean formatting."""
     display.text("")
@@ -442,9 +418,13 @@ def generate_template(module_instance, config: GenerationConfig) -> None:  # noq
 
     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)
+    slug = getattr(template, "slug", template.id)
 
-    # Determine output directory early to check for deprecated argument usage
-    output_dir, used_deprecated_arg = _determine_output_dir(config.directory, config.output, config.id)
+    try:
+        destination = resolve_cli_destination(config.output, config.remote, config.remote_path, slug)
+    except ValueError as e:
+        display.error(str(e), context="template generation")
+        raise Exit(code=1) from None
 
     if not config.quiet:
         # Display template header
@@ -455,63 +435,64 @@ def generate_template(module_instance, config: GenerationConfig) -> None:  # noq
         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)
+        rendered_files, _variable_values = _render_template(template, config.id, display, config.interactive)
 
-        # Check for conflicts and get confirmation (skip in quiet mode)
-        if not config.quiet:
-            existing_files = check_output_directory(output_dir, rendered_files, config.interactive, display)
-            if existing_files is None:
-                return  # User cancelled
-
-            dir_not_empty = output_dir.exists() and any(output_dir.iterdir())
-            ctx = ConfirmationContext(
-                output_dir=output_dir,
-                rendered_files=rendered_files,
-                existing_files=existing_files,
-                dir_not_empty=dir_not_empty,
-                dry_run=config.dry_run,
-                interactive=config.interactive,
-                display=display,
-            )
-            if not get_generation_confirmation(ctx):
-                return  # User cancelled
+        if destination is None:
+            if config.interactive:
+                destination = prompt_generation_destination(slug)
+            else:
+                destination = GenerationDestination(mode="local", local_output_dir=Path.cwd() / slug)
+
+        if not destination.is_remote:
+            output_dir = destination.local_output_dir or (Path.cwd() / slug)
+            if (
+                not config.quiet
+                and check_output_directory(output_dir, rendered_files, config.interactive, display) is None
+            ):
+                return
 
         # Execute generation (dry run or actual)
         dry_run_stats = None
-        if config.dry_run:
-            if not config.quiet:
-                dry_run_stats = execute_dry_run(config.id, output_dir, rendered_files, config.show_files, display)
+        if destination.is_remote:
+            remote_host = destination.remote_host or ""
+            remote_path = destination.remote_path or f"~/{slug}"
+            if config.dry_run:
+                if not config.quiet:
+                    dry_run_stats = execute_remote_dry_run(
+                        remote_host,
+                        remote_path,
+                        rendered_files,
+                        config.show_files,
+                        display,
+                    )
+            else:
+                write_rendered_files_remote(remote_host, remote_path, rendered_files)
         else:
-            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:
-            display.text("")
-            display.heading("Next Steps")
-            try:
-                next_steps_template = Jinja2Template(template.metadata.next_steps)
-                rendered_next_steps = next_steps_template.render(variable_values)
-                display.status.markdown(rendered_next_steps)
-            except Exception as e:
-                logger.warning(f"Failed to render next_steps as template: {e}")
-                # Fallback to plain text if rendering fails
-                display.status.markdown(template.metadata.next_steps)
+            output_dir = destination.local_output_dir or (Path.cwd() / slug)
+            if config.dry_run:
+                if not config.quiet:
+                    dry_run_stats = execute_dry_run(config.id, output_dir, rendered_files, config.show_files, display)
+            else:
+                write_rendered_files(output_dir, rendered_files)
 
         # Display final status message at the end
         if not config.quiet:
             display.text("")
             display.text("─" * 80, style="dim")
 
-            if config.dry_run and dry_run_stats:
+            if destination.is_remote:
+                remote_host = destination.remote_host or ""
+                remote_path = destination.remote_path or f"~/{slug}"
+                remote_target = format_remote_destination(remote_host, remote_path)
+                if config.dry_run and dry_run_stats:
+                    total_files, size_str = dry_run_stats
+                    display.success(
+                        f"Dry run complete: {total_files} files ({size_str}) would be uploaded to '{remote_target}'"
+                    )
+                else:
+                    display.success(f"Boilerplate uploaded successfully to '{remote_target}'")
+            elif config.dry_run and dry_run_stats:
                 total_files, overwrite_files, size_str = dry_run_stats
                 if overwrite_files > 0:
                     display.warning(
@@ -523,7 +504,6 @@ def generate_template(module_instance, config: GenerationConfig) -> None:  # noq
                         f"Dry run complete: {total_files} files ({size_str}) would be written to '{output_dir}'"
                     )
             else:
-                # Actual generation completed
                 display.success(f"Boilerplate generated successfully in '{output_dir}'")
 
     except TemplateRenderError as e:

+ 23 - 20
cli/core/module/base_module.py

@@ -45,9 +45,6 @@ class Module(ABC):
     name: str
     description: str
 
-    # Schema version supported by this module (override in subclasses)
-    schema_version: str = "1.0"
-
     def __init__(self) -> None:
         # Validate required class attributes
         if not hasattr(self.__class__, "name") or not hasattr(self.__class__, "description"):
@@ -79,9 +76,6 @@ class Module(ABC):
 
                 template = Template(template_dir, library_name=library_name, library_type=library_type)
 
-                # Validate schema version compatibility
-                template._validate_schema_version(self.schema_version, self.name)
-
                 # If template ID needs qualification, set qualified ID
                 if needs_qualification:
                     template.set_qualified_id()
@@ -115,9 +109,6 @@ class Module(ABC):
         try:
             template = Template(template_dir, library_name=library_name, library_type=library_type)
 
-            # Validate schema version compatibility
-            template._validate_schema_version(self.schema_version, self.name)
-
             # If the original ID was qualified, preserve it
             if "." in id:
                 template.id = id
@@ -167,16 +158,28 @@ class Module(ABC):
     def generate(
         self,
         id: Annotated[str, Argument(help="Template ID")],
-        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)",
+                help="Local output directory",
+            ),
+        ] = None,
+        remote: Annotated[
+            str | None,
+            Option(
+                "--remote",
+                "-r",
+                help="Upload generated files to this SSH host instead of a local directory",
+            ),
+        ] = None,
+        remote_path: Annotated[
+            str | None,
+            Option(
+                "--remote-path",
+                help="Remote target directory (defaults to ~/<slug> when --remote is used)",
             ),
         ] = None,
         interactive: Annotated[
@@ -219,16 +222,16 @@ class Module(ABC):
         """Generate from template.
 
         Variable precedence chain (lowest to highest):
-        1. Module spec (defined in cli/modules/*.py)
-        2. Template spec (from template.yaml)
-        3. Config defaults (from ~/.config/boilerplates/config.yaml)
-        4. Variable file (from --var-file)
-        5. CLI overrides (--var flags)
+        1. Template defaults (from template.json)
+        2. Config defaults (from ~/.config/boilerplates/config.yaml)
+        3. Variable file (from --var-file)
+        4. CLI overrides (--var flags)
         """
         config = GenerationConfig(
             id=id,
-            directory=directory,
             output=output,
+            remote=remote,
+            remote_path=remote_path,
             interactive=interactive,
             var=var,
             var_file=var_file,
@@ -254,7 +257,7 @@ class Module(ABC):
             bool,
             Option(
                 "--semantic/--no-semantic",
-                help="Enable semantic validation (Docker Compose schema, etc.)",
+                help="Enable semantic validation (Docker Compose config, YAML structure, etc.)",
             ),
         ] = True,
     ) -> None:

+ 203 - 0
cli/core/module/generation_destination.py

@@ -0,0 +1,203 @@
+"""Helpers for generation destinations and remote uploads."""
+
+from __future__ import annotations
+
+import shlex
+import subprocess
+import tempfile
+from dataclasses import dataclass
+from pathlib import Path
+
+from ..input import InputManager
+
+
+@dataclass
+class GenerationDestination:
+    """Resolved generation target."""
+
+    mode: str
+    local_output_dir: Path | None = None
+    remote_host: str | None = None
+    remote_path: str | None = None
+
+    @property
+    def is_remote(self) -> bool:
+        return self.mode == "remote"
+
+
+def normalize_output_path(path_value: str) -> Path:
+    """Normalize paths that look absolute but were provided without a leading slash."""
+    output_dir = Path(path_value)
+    if not output_dir.is_absolute() and str(output_dir).startswith(("Users/", "home/", "usr/", "opt/", "var/", "tmp/")):
+        output_dir = Path("/") / output_dir
+    return output_dir
+
+
+def resolve_cli_destination(
+    output: str | None,
+    remote: str | None,
+    remote_path: str | None,
+    slug: str,
+) -> GenerationDestination | None:
+    """Resolve generation destination from explicit CLI flags only."""
+    if output and remote:
+        raise ValueError("Use either --output for a local directory or --remote for a remote server, not both")
+    if remote_path and not remote:
+        raise ValueError("--remote-path requires --remote")
+
+    if remote:
+        return GenerationDestination(
+            mode="remote",
+            remote_host=remote,
+            remote_path=remote_path or f"~/{slug}",
+        )
+
+    if output:
+        return GenerationDestination(mode="local", local_output_dir=normalize_output_path(output))
+
+    return None
+
+
+def prompt_generation_destination(slug: str) -> GenerationDestination:
+    """Prompt for local or remote generation target."""
+    input_mgr = InputManager()
+    destination_mode = input_mgr.numbered_choice("Store generated template in", ["local", "remote"], default="local")
+
+    if destination_mode == "local":
+        local_default = str(Path.cwd() / slug)
+        local_output = input_mgr.text("Local output directory", default=local_default).strip() or local_default
+        return GenerationDestination(mode="local", local_output_dir=normalize_output_path(local_output))
+
+    remote_host = input_mgr.text("Remote server host or IP address", default=None).strip()
+    if not remote_host:
+        raise ValueError("Remote server host or IP address cannot be empty")
+
+    remote_default = f"~/{slug}"
+    remote_path = input_mgr.text("Remote target directory", default=remote_default).strip() or remote_default
+    return GenerationDestination(mode="remote", remote_host=remote_host, remote_path=remote_path)
+
+
+def format_remote_destination(host: str, remote_path: str) -> str:
+    """Format host/path for user-facing messages."""
+    return f"{host}:{remote_path}"
+
+
+def build_remote_shell_path(remote_path: str, trailing_slash: bool = False) -> str:
+    """Build a shell-safe remote path expression for ssh/scp commands."""
+    normalized = remote_path.rstrip("/")
+    suffix = "/" if trailing_slash else ""
+
+    if normalized in {"~", ""}:
+        return f'"$HOME"{suffix}'
+
+    if normalized.startswith("~/"):
+        relative = normalized[2:]
+        quoted_relative = shlex.quote(f"{relative}{suffix}")
+        return f'"$HOME"/{quoted_relative}'
+
+    return shlex.quote(f"{normalized}{suffix}")
+
+
+def build_scp_remote_target(remote_host: str, remote_path: str, trailing_slash: bool = False) -> str:
+    """Build an scp destination target for an already-resolved remote path."""
+    normalized = remote_path.rstrip("/")
+    suffix = "/" if trailing_slash else ""
+
+    quoted_path = shlex.quote(f"{normalized}{suffix}")
+    return f"{remote_host}:{quoted_path}"
+
+
+def resolve_remote_home_directory(remote_host: str) -> str:
+    """Resolve the remote user's home directory over SSH."""
+    home_result = subprocess.run(
+        ["ssh", remote_host, "printf '%s' \"$HOME\""],
+        check=False,
+        capture_output=True,
+        text=True,
+    )
+    if home_result.returncode != 0:
+        error_output = home_result.stderr.strip() or home_result.stdout.strip() or "SSH home resolution failed"
+        raise RuntimeError(f"Failed to resolve remote home directory on '{remote_host}': {error_output}")
+
+    remote_home = home_result.stdout.strip()
+    if not remote_home:
+        raise RuntimeError(f"Failed to resolve remote home directory on '{remote_host}': empty response")
+
+    return remote_home
+
+
+def resolve_remote_absolute_path(remote_host: str, remote_path: str, trailing_slash: bool = False) -> str:
+    """Resolve ~-prefixed remote paths to absolute remote filesystem paths."""
+    normalized = remote_path.rstrip("/")
+    suffix = "/" if trailing_slash else ""
+
+    if normalized not in {"~", ""} and not normalized.startswith("~/"):
+        return f"{normalized}{suffix}"
+
+    remote_home = resolve_remote_home_directory(remote_host)
+
+    return f"{remote_home}{suffix}" if normalized in {"~", ""} else f"{remote_home}/{normalized[2:]}{suffix}"
+
+
+def resolve_remote_upload_target(remote_host: str, remote_path: str, trailing_slash: bool = False) -> str:
+    """Resolve remote paths to absolute scp targets."""
+    absolute_path = resolve_remote_absolute_path(remote_host, remote_path, trailing_slash=trailing_slash)
+    return build_scp_remote_target(remote_host, absolute_path, trailing_slash=trailing_slash)
+
+
+def _write_staging_files(staging_dir: Path, rendered_files: dict[str, str]) -> None:
+    """Write rendered files to a local staging directory."""
+    staging_dir.mkdir(parents=True, exist_ok=True)
+    for file_path, content in rendered_files.items():
+        full_path = staging_dir / file_path
+        full_path.parent.mkdir(parents=True, exist_ok=True)
+        full_path.write_text(content, encoding="utf-8")
+
+
+def write_rendered_files_remote(
+    remote_host: str,
+    remote_path: str,
+    rendered_files: dict[str, str],
+) -> None:
+    """Upload rendered files to a remote host over SSH."""
+    with tempfile.TemporaryDirectory(prefix="boilerplates-remote-") as staging_root:
+        staging_dir = Path(staging_root)
+        _write_staging_files(staging_dir, rendered_files)
+
+        remote_mkdir_path = build_remote_shell_path(remote_path)
+        remote_copy_target = resolve_remote_upload_target(remote_host, remote_path, trailing_slash=True)
+
+        mkdir_result = subprocess.run(
+            ["ssh", remote_host, f"mkdir -p -- {remote_mkdir_path}"],
+            check=False,
+            capture_output=True,
+            text=True,
+        )
+        if mkdir_result.returncode != 0:
+            error_output = mkdir_result.stderr.strip() or mkdir_result.stdout.strip() or "SSH mkdir failed"
+            raise RuntimeError(f"Failed to prepare remote directory '{remote_path}' on '{remote_host}': {error_output}")
+
+        upload_result = subprocess.run(
+            ["scp", "-r", f"{staging_dir}/.", remote_copy_target],
+            check=False,
+            capture_output=True,
+            text=True,
+        )
+        if upload_result.returncode != 0:
+            error_output = upload_result.stderr.strip() or upload_result.stdout.strip() or "SCP upload failed"
+            raise RuntimeError(f"Failed to upload files to '{remote_host}:{remote_path}': {error_output}")
+
+
+__all__ = [
+    "GenerationDestination",
+    "build_remote_shell_path",
+    "build_scp_remote_target",
+    "format_remote_destination",
+    "normalize_output_path",
+    "prompt_generation_destination",
+    "resolve_cli_destination",
+    "resolve_remote_absolute_path",
+    "resolve_remote_home_directory",
+    "resolve_remote_upload_target",
+    "write_rendered_files_remote",
+]

+ 0 - 270
cli/core/prompt.py

@@ -1,270 +0,0 @@
-from __future__ import annotations
-
-import logging
-from typing import Any, Callable
-
-from rich.console import Console
-from rich.prompt import Confirm, IntPrompt, Prompt
-
-from .collection import VariableCollection
-from .display import DisplayManager
-from .variable import Variable
-
-logger = logging.getLogger(__name__)
-
-
-class PromptHandler:
-    """Simple interactive prompt handler for collecting template variables."""
-
-    def __init__(self) -> None:
-        self.console = Console()
-        self.display = DisplayManager()
-
-    def collect_variables(self, variables: VariableCollection) -> dict[str, Any]:
-        """Collect values for variables by iterating through sections.
-
-        Args:
-            variables: VariableCollection with organized sections and variables
-
-        Returns:
-            Dict of variable names to collected values
-        """
-        if not Confirm.ask("Customize any settings?", default=False):
-            self.console.print("")  # Add blank line after prompt
-            logger.info("User opted to keep all default values")
-            return {}
-        self.console.print("")  # Add blank line after prompt
-
-        collected: dict[str, Any] = {}
-
-        # Process each section
-        for section_key, section in variables.get_sections().items():
-            if not section.variables:
-                continue
-
-            # Check if dependencies are satisfied
-            if not self._check_section_dependencies(variables, section_key, section):
-                continue
-
-            # Always show section header first
-            self.display.display_section_header(section.title, section.description)
-
-            # Handle section toggle and determine if enabled
-            section_will_be_enabled = self._handle_section_toggle(section, collected)
-
-            # Collect variables in this section
-            self._collect_section_variables(section, section_key, section_will_be_enabled, variables, collected)
-
-        logger.info(f"Variable collection completed. Collected {len(collected)} values")
-        return collected
-
-    def _check_section_dependencies(self, variables: VariableCollection, section_key: str, section) -> bool:
-        """Check if section dependencies are satisfied and display skip message if not."""
-        if not variables.is_section_satisfied(section_key):
-            # Get list of unsatisfied dependencies for better user feedback
-            unsatisfied_keys = [dep for dep in section.needs if not variables.is_section_satisfied(dep)]
-            # Convert section keys to titles for user-friendly display
-            unsatisfied_titles = []
-            for dep_key in unsatisfied_keys:
-                dep_section = variables.get_section(dep_key)
-                unsatisfied_titles.append(dep_section.title if dep_section else dep_key)
-
-            dep_names = ", ".join(unsatisfied_titles) if unsatisfied_titles else "unknown"
-            self.display.display_skipped(section.title, f"requires {dep_names} to be enabled")
-            logger.debug(f"Skipping section '{section_key}' - dependencies not satisfied: {dep_names}")
-            return False
-        return True
-
-    def _handle_section_toggle(self, section, collected: dict[str, Any]) -> bool:
-        """Handle section toggle prompt and return whether section will be enabled."""
-        # Handle sections with toggle
-        if not section.toggle:
-            return True
-
-        toggle_var = section.variables.get(section.toggle)
-        if not toggle_var:
-            return True
-
-        # Prompt for toggle variable
-        current_value = toggle_var.convert(toggle_var.value)
-        new_value = self._prompt_variable(toggle_var, required=False)
-
-        if new_value != current_value:
-            collected[toggle_var.name] = new_value
-            toggle_var.value = new_value
-
-        # Return whether section is enabled
-        return section.is_enabled()
-
-    def _collect_section_variables(
-        self,
-        section,
-        section_key: str,
-        section_enabled: bool,
-        variables: VariableCollection,
-        collected: dict[str, Any],
-    ) -> None:
-        """Collect values for all variables in a section."""
-        for var_name, variable in section.variables.items():
-            # Skip toggle variable (already handled)
-            if section.toggle and var_name == section.toggle:
-                continue
-
-            # Skip variables with unsatisfied needs
-            if not variables.is_variable_satisfied(var_name):
-                logger.debug(f"Skipping variable '{var_name}' - needs not satisfied")
-                continue
-
-            # Skip all variables if section is disabled
-            if not section_enabled:
-                logger.debug(f"Skipping variable '{var_name}' from disabled section '{section_key}'")
-                continue
-
-            # Prompt for the variable and update if changed
-            self._prompt_and_update_variable(variable, collected)
-
-    def _prompt_and_update_variable(self, variable: Variable, collected: dict[str, Any]) -> None:
-        """Prompt for a variable and update collected values if changed."""
-        current_value = variable.convert(variable.value)
-        new_value = self._prompt_variable(variable, required=False)
-
-        # For autogenerated variables, always update even if None (signals autogeneration)
-        if variable.autogenerated and new_value is None:
-            collected[variable.name] = None
-            variable.value = None
-        elif new_value != current_value:
-            collected[variable.name] = new_value
-            variable.value = new_value
-
-    def _prompt_variable(self, variable: Variable, _required: bool = False) -> Any:
-        """Prompt for a single variable value based on its type.
-
-        Args:
-            variable: The variable to prompt for
-            _required: Whether the containing section is required (unused, kept for API compatibility)
-
-        Returns:
-            The validated value entered by the user
-        """
-        logger.debug(f"Prompting for variable '{variable.name}' (type: {variable.type})")
-
-        # Use variable's native methods for prompt text and default value
-        prompt_text = variable.get_prompt_text()
-        default_value = variable.get_normalized_default()
-
-        # Add lock icon before default value for sensitive or autogenerated variables
-        if variable.sensitive or variable.autogenerated:
-            # Format: "Prompt text 🔒 (default)"
-            # The lock icon goes between the text and the default value in parentheses
-            prompt_text = f"{prompt_text} {self.display.get_lock_icon()}"
-
-        # Check if this specific variable is required (has no default and not autogenerated)
-        var_is_required = variable.is_required()
-
-        # If variable is required, mark it in the prompt
-        if var_is_required:
-            prompt_text = f"{prompt_text} [bold red]*required[/bold red]"
-
-        handler = self._get_prompt_handler(variable)
-
-        # Add validation hint (includes both extra text and enum options)
-        hint = variable.get_validation_hint()
-        if hint:
-            # Show options/extra inline inside parentheses, before the default
-            prompt_text = f"{prompt_text} [dim]({hint})[/dim]"
-
-        while True:
-            try:
-                raw = handler(prompt_text, default_value)
-                # Use Variable's centralized validation method that handles:
-                # - Type conversion
-                # - Autogenerated variable detection
-                # - Required field validation
-                return variable.validate_and_convert(raw, check_required=True)
-
-                # Return the converted value (caller will update variable.value)
-            except ValueError as exc:
-                # Conversion/validation failed — show a consistent error message and retry
-                self._show_validation_error(str(exc))
-            except Exception as e:
-                # Unexpected error — log and retry using the stored (unconverted) value
-                logger.error(f"Error prompting for variable '{variable.name}': {e!s}")
-                default_value = variable.value
-                handler = self._get_prompt_handler(variable)
-
-    def _get_prompt_handler(self, variable: Variable) -> Callable:
-        """Return the prompt function for a variable type."""
-        handlers = {
-            "bool": self._prompt_bool,
-            "int": self._prompt_int,
-            # For enum prompts we pass the variable.extra through so options and extra
-            # can be combined into a single inline hint.
-            "enum": lambda text, default: self._prompt_enum(
-                text,
-                variable.options or [],
-                default,
-                extra=getattr(variable, "extra", None),
-            ),
-        }
-        return handlers.get(
-            variable.type,
-            lambda text, default: self._prompt_string(text, default, is_sensitive=variable.sensitive),
-        )
-
-    def _show_validation_error(self, message: str) -> None:
-        """Display validation feedback consistently."""
-        self.display.display_validation_error(message)
-
-    def _prompt_string(self, prompt_text: str, default: Any = None, is_sensitive: bool = False) -> str | None:
-        value = Prompt.ask(
-            prompt_text,
-            default=str(default) if default is not None else "",
-            show_default=True,
-            password=is_sensitive,
-        )
-        stripped = value.strip() if value else None
-        return stripped if stripped else None
-
-    def _prompt_bool(self, prompt_text: str, default: Any = None) -> bool | None:
-        if default is None:
-            return Confirm.ask(prompt_text, default=None)
-        converted = default if isinstance(default, bool) else str(default).lower() in ("true", "1", "yes", "on")
-        return Confirm.ask(prompt_text, default=converted)
-
-    def _prompt_int(self, prompt_text: str, default: Any = None) -> int | None:
-        converted = None
-        if default is not None:
-            try:
-                converted = int(default)
-            except (ValueError, TypeError):
-                logger.warning(f"Invalid default integer value: {default}")
-        return IntPrompt.ask(prompt_text, default=converted)
-
-    def _prompt_enum(
-        self,
-        prompt_text: str,
-        options: list[str],
-        default: Any = None,
-        _extra: str | None = None,
-    ) -> str:
-        """Prompt for enum selection with validation.
-
-        Note: prompt_text should already include hint from variable.get_validation_hint()
-        but we keep this for backward compatibility and fallback.
-        """
-        if not options:
-            return self._prompt_string(prompt_text, default)
-
-        # Validate default is in options
-        if default and str(default) not in options:
-            default = options[0]
-
-        while True:
-            value = Prompt.ask(
-                prompt_text,
-                default=str(default) if default else options[0],
-                show_default=True,
-            )
-            if value in options:
-                return value
-            self.console.print(f"[red]Invalid choice. Select from: {', '.join(options)}[/red]")

+ 30 - 2
cli/core/repo.py

@@ -252,7 +252,9 @@ def _get_library_path_for_git(lib: dict, libraries_path: Path, name: str) -> Pat
     directory = lib.get("directory", "library")
     library_base = libraries_path / name
     if directory and directory != ".":
-        return library_base / directory
+        configured_path = library_base / directory
+        fallback_path = _fallback_template_root(configured_path)
+        return fallback_path or configured_path
     return library_base
 
 
@@ -262,7 +264,33 @@ def _get_library_path_for_static(lib: dict, config: ConfigManager) -> Path:
     library_path = Path(url_or_path).expanduser()
     if not library_path.is_absolute():
         library_path = (config.config_path.parent / library_path).resolve()
-    return library_path
+    fallback_path = _fallback_template_root(library_path)
+    return fallback_path or library_path
+
+
+def _looks_like_template_root(path: Path) -> bool:
+    """Check whether a path looks like the root of a templates repository."""
+    if not path.is_dir():
+        return False
+    try:
+        return any(item.is_dir() for item in path.iterdir())
+    except OSError:
+        return False
+
+
+def _fallback_template_root(path: Path) -> Path | None:
+    """Resolve old-style /library paths to the actual template repo root."""
+    if path.exists():
+        if _looks_like_template_root(path):
+            return path
+        if path.name == "library" and _looks_like_template_root(path.parent):
+            return path.parent
+        return None
+
+    if path.name == "library" and _looks_like_template_root(path.parent):
+        return path.parent
+
+    return None
 
 
 def _get_library_info(lib: dict, config: ConfigManager, libraries_path: Path) -> tuple[str, str, str, str, str, str]:

+ 0 - 17
cli/core/schema/__init__.py

@@ -1,17 +0,0 @@
-"""Schema loading and management for boilerplate modules."""
-
-from .loader import (
-    SchemaLoader,
-    get_loader,
-    has_schema,
-    list_versions,
-    load_schema,
-)
-
-__all__ = [
-    "SchemaLoader",
-    "get_loader",
-    "has_schema",
-    "list_versions",
-    "load_schema",
-]

+ 0 - 15
cli/core/schema/ansible/v1.0.json

@@ -1,15 +0,0 @@
-[
-  {
-    "key": "general",
-    "title": "General",
-    "required": true,
-    "vars": [
-      {
-        "name": "target_hosts",
-        "description": "Target hosts",
-        "type": "str",
-        "required": true
-      }
-    ]
-  }
-]

+ 0 - 229
cli/core/schema/compose/v1.0.json

@@ -1,229 +0,0 @@
-[
-  {
-    "key": "general",
-    "title": "General",
-    "required": true,
-    "vars": [
-      {
-        "name": "service_name",
-        "description": "Service name",
-        "type": "str"
-      },
-      {
-        "name": "container_name",
-        "description": "Container name",
-        "type": "str"
-      },
-      {
-        "name": "container_timezone",
-        "description": "Container timezone (e.g., Europe/Berlin)",
-        "type": "str",
-        "default": "UTC"
-      },
-      {
-        "name": "user_uid",
-        "description": "User UID for container process",
-        "type": "int",
-        "default": 1000
-      },
-      {
-        "name": "user_gid",
-        "description": "User GID for container process",
-        "type": "int",
-        "default": 1000
-      },
-      {
-        "name": "restart_policy",
-        "description": "Container restart policy",
-        "type": "enum",
-        "options": ["unless-stopped", "always", "on-failure", "no"],
-        "default": "unless-stopped"
-      }
-    ]
-  },
-  {
-    "key": "network",
-    "title": "Network",
-    "toggle": "network_enabled",
-    "vars": [
-      {
-        "name": "network_enabled",
-        "description": "Enable custom network block",
-        "type": "bool",
-        "default": false
-      },
-      {
-        "name": "network_name",
-        "description": "Docker network name",
-        "type": "str",
-        "default": "bridge"
-      },
-      {
-        "name": "network_external",
-        "description": "Use existing Docker network",
-        "type": "bool",
-        "default": true
-      }
-    ]
-  },
-  {
-    "key": "ports",
-    "title": "Ports",
-    "toggle": "ports_enabled",
-    "vars": [
-      {
-        "name": "ports_enabled",
-        "description": "Expose ports via 'ports' mapping",
-        "type": "bool",
-        "default": true
-      }
-    ]
-  },
-  {
-    "key": "traefik",
-    "title": "Traefik",
-    "toggle": "traefik_enabled",
-    "description": "Traefik routes external traffic to your service.",
-    "vars": [
-      {
-        "name": "traefik_enabled",
-        "description": "Enable Traefik reverse proxy integration",
-        "type": "bool",
-        "default": false
-      },
-      {
-        "name": "traefik_network",
-        "description": "Traefik network name",
-        "type": "str",
-        "default": "traefik"
-      },
-      {
-        "name": "traefik_host",
-        "description": "Domain name for your service (e.g., app.example.com)",
-        "type": "str"
-      },
-      {
-        "name": "traefik_entrypoint",
-        "description": "HTTP entrypoint (non-TLS)",
-        "type": "str",
-        "default": "web"
-      }
-    ]
-  },
-  {
-    "key": "traefik_tls",
-    "title": "Traefik TLS/SSL",
-    "toggle": "traefik_tls_enabled",
-    "needs": "traefik",
-    "description": "Enable HTTPS/TLS for Traefik with certificate management.",
-    "vars": [
-      {
-        "name": "traefik_tls_enabled",
-        "description": "Enable HTTPS/TLS",
-        "type": "bool",
-        "default": true
-      },
-      {
-        "name": "traefik_tls_entrypoint",
-        "description": "TLS entrypoint",
-        "type": "str",
-        "default": "websecure"
-      },
-      {
-        "name": "traefik_tls_certresolver",
-        "description": "Traefik certificate resolver name",
-        "type": "str",
-        "default": "cloudflare"
-      }
-    ]
-  },
-  {
-    "key": "swarm",
-    "title": "Docker Swarm",
-    "toggle": "swarm_enabled",
-    "description": "Deploy service in Docker Swarm mode with replicas.",
-    "vars": [
-      {
-        "name": "swarm_enabled",
-        "description": "Enable Docker Swarm mode",
-        "type": "bool",
-        "default": false
-      },
-      {
-        "name": "swarm_replicas",
-        "description": "Number of replicas in Swarm",
-        "type": "int",
-        "default": 1
-      },
-      {
-        "name": "swarm_placement_mode",
-        "description": "Swarm placement mode",
-        "type": "enum",
-        "options": ["global", "replicated"],
-        "default": "replicated"
-      },
-      {
-        "name": "swarm_placement_host",
-        "description": "Limit placement to specific node",
-        "type": "str"
-      }
-    ]
-  },
-  {
-    "key": "database",
-    "title": "Database",
-    "toggle": "database_enabled",
-    "description": "Connect to external database (PostgreSQL or MySQL)",
-    "vars": [
-      {
-        "name": "database_enabled",
-        "description": "Enable external database integration",
-        "type": "bool",
-        "default": false
-      },
-      {
-        "name": "database_type",
-        "description": "Database type",
-        "type": "enum",
-        "options": ["postgres", "mysql"],
-        "default": "postgres"
-      },
-      {
-        "name": "database_external",
-        "description": "Use an external database server?",
-        "extra": "skips creation of internal database container",
-        "type": "bool",
-        "default": false
-      },
-      {
-        "name": "database_host",
-        "description": "Database host",
-        "type": "str",
-        "default": "database"
-      },
-      {
-        "name": "database_port",
-        "description": "Database port",
-        "type": "int"
-      },
-      {
-        "name": "database_name",
-        "description": "Database name",
-        "type": "str"
-      },
-      {
-        "name": "database_user",
-        "description": "Database user",
-        "type": "str"
-      },
-      {
-        "name": "database_password",
-        "description": "Database password",
-        "type": "str",
-        "default": "",
-        "sensitive": true,
-        "autogenerated": true
-      }
-    ]
-  }
-]

+ 0 - 312
cli/core/schema/compose/v1.1.json

@@ -1,312 +0,0 @@
-[
-  {
-    "key": "general",
-    "title": "General",
-    "required": true,
-    "vars": [
-      {
-        "name": "service_name",
-        "description": "Service name",
-        "type": "str"
-      },
-      {
-        "name": "container_name",
-        "description": "Container name",
-        "type": "str"
-      },
-      {
-        "name": "container_hostname",
-        "description": "Container internal hostname",
-        "type": "str"
-      },
-      {
-        "name": "container_timezone",
-        "description": "Container timezone (e.g., Europe/Berlin)",
-        "type": "str",
-        "default": "UTC"
-      },
-      {
-        "name": "user_uid",
-        "description": "User UID for container process",
-        "type": "int",
-        "default": 1000
-      },
-      {
-        "name": "user_gid",
-        "description": "User GID for container process",
-        "type": "int",
-        "default": 1000
-      },
-      {
-        "name": "container_loglevel",
-        "description": "Container log level",
-        "type": "enum",
-        "options": ["debug", "info", "warn", "error"],
-        "default": "info"
-      },
-      {
-        "name": "restart_policy",
-        "description": "Container restart policy",
-        "type": "enum",
-        "options": ["unless-stopped", "always", "on-failure", "no"],
-        "default": "unless-stopped"
-      }
-    ]
-  },
-  {
-    "key": "network",
-    "title": "Network",
-    "vars": [
-      {
-        "name": "network_mode",
-        "description": "Docker network mode",
-        "type": "enum",
-        "options": ["bridge", "host", "macvlan"],
-        "default": "bridge",
-        "extra": "bridge=default Docker networking, host=use host network stack, macvlan=dedicated MAC address on physical network"
-      },
-      {
-        "name": "network_name",
-        "description": "Docker network name",
-        "type": "str",
-        "default": "bridge",
-        "needs": "network_mode=bridge,macvlan"
-      },
-      {
-        "name": "network_external",
-        "description": "Use existing Docker network (external)",
-        "type": "bool",
-        "default": false,
-        "needs": "network_mode=bridge,macvlan"
-      },
-      {
-        "name": "network_macvlan_ipv4_address",
-        "description": "Static IP address for container",
-        "type": "str",
-        "default": "192.168.1.253",
-        "needs": "network_mode=macvlan"
-      },
-      {
-        "name": "network_macvlan_parent_interface",
-        "description": "Host network interface name",
-        "type": "str",
-        "default": "eth0",
-        "needs": "network_mode=macvlan"
-      },
-      {
-        "name": "network_macvlan_subnet",
-        "description": "Network subnet in CIDR notation",
-        "type": "str",
-        "default": "192.168.1.0/24",
-        "needs": "network_mode=macvlan"
-      },
-      {
-        "name": "network_macvlan_gateway",
-        "description": "Network gateway IP address",
-        "type": "str",
-        "default": "192.168.1.1",
-        "needs": "network_mode=macvlan"
-      }
-    ]
-  },
-  {
-    "key": "ports",
-    "title": "Ports",
-    "needs": "network_mode=bridge",
-    "vars": []
-  },
-  {
-    "key": "traefik",
-    "title": "Traefik",
-    "toggle": "traefik_enabled",
-    "needs": "network_mode=bridge",
-    "description": "Traefik routes external traffic to your service.",
-    "vars": [
-      {
-        "name": "traefik_enabled",
-        "description": "Enable Traefik reverse proxy integration",
-        "type": "bool",
-        "default": false
-      },
-      {
-        "name": "traefik_network",
-        "description": "Traefik network name",
-        "type": "str",
-        "default": "traefik"
-      },
-      {
-        "name": "traefik_host",
-        "description": "Domain name for your service (e.g., app.example.com)",
-        "type": "str"
-      },
-      {
-        "name": "traefik_entrypoint",
-        "description": "HTTP entrypoint (non-TLS)",
-        "type": "str",
-        "default": "web"
-      }
-    ]
-  },
-  {
-    "key": "traefik_tls",
-    "title": "Traefik TLS/SSL",
-    "toggle": "traefik_tls_enabled",
-    "needs": "traefik_enabled=true;network_mode=bridge",
-    "description": "Enable HTTPS/TLS for Traefik with certificate management.",
-    "vars": [
-      {
-        "name": "traefik_tls_enabled",
-        "description": "Enable HTTPS/TLS",
-        "type": "bool",
-        "default": true
-      },
-      {
-        "name": "traefik_tls_entrypoint",
-        "description": "TLS entrypoint",
-        "type": "str",
-        "default": "websecure"
-      },
-      {
-        "name": "traefik_tls_certresolver",
-        "description": "Traefik certificate resolver name",
-        "type": "str",
-        "default": "cloudflare"
-      }
-    ]
-  },
-  {
-    "key": "swarm",
-    "title": "Docker Swarm",
-    "toggle": "swarm_enabled",
-    "needs": "network_mode=bridge",
-    "description": "Deploy service in Docker Swarm mode.",
-    "vars": [
-      {
-        "name": "swarm_enabled",
-        "description": "Enable Docker Swarm mode",
-        "type": "bool",
-        "default": false
-      },
-      {
-        "name": "swarm_placement_mode",
-        "description": "Swarm placement mode",
-        "type": "enum",
-        "options": ["replicated", "global"],
-        "default": "replicated"
-      },
-      {
-        "name": "swarm_replicas",
-        "description": "Number of replicas",
-        "type": "int",
-        "default": 1,
-        "needs": "swarm_placement_mode=replicated"
-      },
-      {
-        "name": "swarm_placement_host",
-        "description": "Target hostname for placement constraint",
-        "type": "str",
-        "default": "",
-        "optional": true,
-        "needs": "swarm_placement_mode=replicated",
-        "extra": "Constrains service to run on specific node by hostname"
-      },
-      {
-        "name": "swarm_volume_mode",
-        "description": "Swarm volume storage backend",
-        "type": "enum",
-        "options": ["local", "mount", "nfs"],
-        "default": "local",
-        "extra": "WARNING: 'local' only works on single-node deployments!"
-      },
-      {
-        "name": "swarm_volume_mount_path",
-        "description": "Host path for bind mount",
-        "type": "str",
-        "default": "/mnt/storage",
-        "needs": "swarm_volume_mode=mount",
-        "extra": "Useful for shared/replicated storage"
-      },
-      {
-        "name": "swarm_volume_nfs_server",
-        "description": "NFS server address",
-        "type": "str",
-        "default": "192.168.1.1",
-        "needs": "swarm_volume_mode=nfs",
-        "extra": "IP address or hostname of NFS server"
-      },
-      {
-        "name": "swarm_volume_nfs_path",
-        "description": "NFS export path",
-        "type": "str",
-        "default": "/export",
-        "needs": "swarm_volume_mode=nfs",
-        "extra": "Path to NFS export on the server"
-      },
-      {
-        "name": "swarm_volume_nfs_options",
-        "description": "NFS mount options",
-        "type": "str",
-        "default": "rw,nolock,soft",
-        "needs": "swarm_volume_mode=nfs",
-        "extra": "Comma-separated NFS mount options"
-      }
-    ]
-  },
-  {
-    "key": "database",
-    "title": "Database",
-    "toggle": "database_enabled",
-    "description": "Connect to external database (PostgreSQL or MySQL)",
-    "vars": [
-      {
-        "name": "database_enabled",
-        "description": "Enable external database integration",
-        "type": "bool",
-        "default": false
-      },
-      {
-        "name": "database_type",
-        "description": "Database type",
-        "type": "enum",
-        "options": ["default", "sqlite", "postgres", "mysql"],
-        "default": "default"
-      },
-      {
-        "name": "database_external",
-        "description": "Use an external database server?",
-        "extra": "skips creation of internal database container",
-        "type": "bool",
-        "default": false
-      },
-      {
-        "name": "database_host",
-        "description": "Database host",
-        "type": "str",
-        "default": "database"
-      },
-      {
-        "name": "database_port",
-        "description": "Database port",
-        "type": "int"
-      },
-      {
-        "name": "database_name",
-        "description": "Database name",
-        "type": "str"
-      },
-      {
-        "name": "database_user",
-        "description": "Database user",
-        "type": "str"
-      },
-      {
-        "name": "database_password",
-        "description": "Database password",
-        "type": "str",
-        "default": "",
-        "sensitive": true,
-        "autogenerated": true
-      }
-    ]
-  }
-]

+ 0 - 528
cli/core/schema/compose/v1.2.json

@@ -1,528 +0,0 @@
-[
-  {
-    "key": "general",
-    "title": "General",
-    "vars": [
-      {
-        "name": "service_name",
-        "description": "Service name",
-        "type": "str",
-        "required": true
-      },
-      {
-        "name": "container_name",
-        "description": "Container name",
-        "type": "str"
-      },
-      {
-        "name": "container_hostname",
-        "description": "Container internal hostname",
-        "type": "str"
-      },
-      {
-        "name": "container_timezone",
-        "description": "Container timezone (e.g., Europe/Berlin)",
-        "type": "str"
-      },
-      {
-        "name": "user_uid",
-        "description": "User UID for container process",
-        "type": "int",
-        "default": 1000
-      },
-      {
-        "name": "user_gid",
-        "description": "User GID for container process",
-        "type": "int",
-        "default": 1000
-      },
-      {
-        "name": "container_loglevel",
-        "description": "Container log level",
-        "type": "enum",
-        "options": ["debug", "info", "warn", "error"]
-      },
-      {
-        "name": "restart_policy",
-        "description": "Container restart policy",
-        "type": "enum",
-        "options": ["unless-stopped", "always", "on-failure", "no"],
-        "default": "unless-stopped",
-        "required": true
-      }
-    ]
-  },
-  {
-    "key": "network",
-    "title": "Network",
-    "vars": [
-      {
-        "name": "network_mode",
-        "description": "Docker network mode",
-        "type": "enum",
-        "options": ["bridge", "host", "macvlan"],
-        "extra": "bridge=default Docker networking, host=use host network stack, macvlan=dedicated MAC address on physical network"
-      },
-      {
-        "name": "network_name",
-        "description": "Docker network name",
-        "type": "str",
-        "default": "bridge",
-        "needs": ["network_mode=bridge,macvlan"],
-        "required": true
-      },
-      {
-        "name": "network_external",
-        "description": "Use existing Docker network (external)",
-        "type": "bool",
-        "default": false,
-        "needs": ["network_mode=bridge,macvlan"]
-      },
-      {
-        "name": "network_macvlan_ipv4_address",
-        "description": "Static IP address for container",
-        "type": "str",
-        "default": "192.168.1.253",
-        "needs": ["network_mode=macvlan"],
-        "required": true
-      },
-      {
-        "name": "network_macvlan_parent_interface",
-        "description": "Host network interface name",
-        "type": "str",
-        "default": "eth0",
-        "needs": ["network_mode=macvlan"],
-        "required": true
-      },
-      {
-        "name": "network_macvlan_subnet",
-        "description": "Network subnet in CIDR notation",
-        "type": "str",
-        "default": "192.168.1.0/24",
-        "needs": ["network_mode=macvlan"],
-        "required": true
-      },
-      {
-        "name": "network_macvlan_gateway",
-        "description": "Network gateway IP address",
-        "type": "str",
-        "default": "192.168.1.1",
-        "needs": ["network_mode=macvlan"],
-        "required": true
-      }
-    ]
-  },
-  {
-    "key": "ports",
-    "title": "Ports",
-    "needs": ["network_mode!=host,macvlan"],
-    "description": "Expose service ports to the host.",
-    "vars": [
-      {
-        "name": "ports_http",
-        "description": "HTTP port on host",
-        "type": "int",
-        "needs": ["traefik_enabled=false"],
-        "default": 8080,
-        "required": true
-      },
-      {
-        "name": "ports_https",
-        "description": "HTTPS port on host",
-        "type": "int",
-        "needs": ["traefik_enabled=false"],
-        "default": 8443,
-        "required": true
-      },
-      {
-        "name": "ports_ssh",
-        "description": "SSH port on host",
-        "type": "int",
-        "default": 22,
-        "required": true
-      },
-      {
-        "name": "ports_dns",
-        "description": "DNS port on host",
-        "type": "int",
-        "default": 53,
-        "required": true
-      },
-      {
-        "name": "ports_dhcp",
-        "description": "DHCP port on host",
-        "type": "int",
-        "default": 67,
-        "required": true
-      },
-      {
-        "name": "ports_smtp",
-        "description": "SMTP port on host",
-        "type": "int",
-        "default": 25,
-        "required": true
-      },
-      {
-        "name": "ports_snmp",
-        "description": "SNMP trap port",
-        "type": "int",
-        "default": 162,
-        "required": true
-      }
-    ]
-  },
-  {
-    "key": "traefik",
-    "title": "Traefik",
-    "toggle": "traefik_enabled",
-    "needs": ["network_mode!=host,macvlan"],
-    "description": "Traefik routes external traffic to your service.",
-    "vars": [
-      {
-        "name": "traefik_enabled",
-        "description": "Enable Traefik reverse proxy integration",
-        "type": "bool",
-        "default": false
-      },
-      {
-        "name": "traefik_network",
-        "description": "Traefik network name",
-        "type": "str",
-        "default": "traefik",
-        "required": true
-      },
-      {
-        "name": "traefik_host",
-        "description": "Service subdomain or full hostname (e.g., 'app' or 'app.example.com')",
-        "type": "str",
-        "required": true
-      },
-      {
-        "name": "traefik_domain",
-        "description": "Base domain (e.g., example.com)",
-        "type": "str",
-        "default": "home.arpa",
-        "required": true
-      }
-    ]
-  },
-  {
-    "key": "traefik_tls",
-    "title": "Traefik TLS/SSL",
-    "toggle": "traefik_tls_enabled",
-    "needs": ["traefik_enabled=true", "network_mode!=host,macvlan"],
-    "description": "Enable HTTPS/TLS for Traefik with certificate management.",
-    "vars": [
-      {
-        "name": "traefik_tls_enabled",
-        "description": "Enable HTTPS/TLS",
-        "type": "bool",
-        "default": true
-      },
-      {
-        "name": "traefik_tls_certresolver",
-        "description": "Traefik certificate resolver name",
-        "type": "str",
-        "default": "cloudflare",
-        "required": true
-      }
-    ]
-  },
-  {
-    "key": "volume",
-    "title": "Volume Storage",
-    "description": "Configure persistent storage for your service.",
-    "vars": [
-      {
-        "name": "volume_mode",
-        "description": "Volume storage backend",
-        "type": "enum",
-        "options": ["local", "mount", "nfs"],
-        "default": "local",
-        "required": true
-      },
-      {
-        "name": "volume_mount_path",
-        "description": "Host path for bind mounts",
-        "type": "str",
-        "default": "/mnt/storage",
-        "needs": ["volume_mode=mount"],
-        "required": true
-      },
-      {
-        "name": "volume_nfs_server",
-        "description": "NFS server address",
-        "type": "str",
-        "default": "192.168.1.1",
-        "needs": ["volume_mode=nfs"],
-        "required": true
-      },
-      {
-        "name": "volume_nfs_path",
-        "description": "NFS export path",
-        "type": "str",
-        "default": "/export",
-        "needs": ["volume_mode=nfs"],
-        "required": true
-      },
-      {
-        "name": "volume_nfs_options",
-        "description": "NFS mount options (comma-separated)",
-        "type": "str",
-        "default": "rw,nolock,soft",
-        "needs": ["volume_mode=nfs"],
-        "required": true
-      }
-    ]
-  },
-  {
-    "key": "resources",
-    "title": "Resource Limits",
-    "toggle": "resources_enabled",
-    "description": "Set CPU and memory limits for the service.",
-    "vars": [
-      {
-        "name": "resources_enabled",
-        "description": "Enable resource limits",
-        "type": "bool",
-        "default": false
-      },
-      {
-        "name": "resources_cpu_limit",
-        "description": "Maximum CPU cores (e.g., 0.5, 1.0, 2.0)",
-        "type": "str",
-        "default": "1.0",
-        "required": true
-      },
-      {
-        "name": "resources_cpu_reservation",
-        "description": "Reserved CPU cores",
-        "type": "str",
-        "default": "0.25",
-        "needs": ["swarm_enabled=true"],
-        "required": true
-      },
-      {
-        "name": "resources_memory_limit",
-        "description": "Maximum memory (e.g., 512M, 1G, 2G)",
-        "type": "str",
-        "default": "1G",
-        "required": true
-      },
-      {
-        "name": "resources_memory_reservation",
-        "description": "Reserved memory",
-        "type": "str",
-        "default": "512M",
-        "needs": ["swarm_enabled=true"],
-        "required": true
-      }
-    ]
-  },
-  {
-    "key": "swarm",
-    "title": "Docker Swarm",
-    "toggle": "swarm_enabled",
-    "needs": ["network_mode!=host,macvlan"],
-    "description": "Deploy service in Docker Swarm mode.",
-    "vars": [
-      {
-        "name": "swarm_enabled",
-        "description": "Enable Docker Swarm mode",
-        "type": "bool",
-        "default": false
-      },
-      {
-        "name": "swarm_placement_mode",
-        "description": "Swarm placement mode",
-        "type": "enum",
-        "options": ["replicated", "global"],
-        "default": "replicated",
-        "required": true
-      },
-      {
-        "name": "swarm_replicas",
-        "description": "Number of replicas",
-        "type": "int",
-        "default": 1,
-        "needs": ["swarm_placement_mode=replicated"],
-        "required": true
-      },
-      {
-        "name": "swarm_placement_host",
-        "description": "Target hostname for placement constraint",
-        "type": "str",
-        "default": "",
-        "needs": ["swarm_placement_mode=replicated"],
-        "extra": "Constrains service to run on specific node by hostname"
-      }
-    ]
-  },
-  {
-    "key": "database",
-    "title": "Database",
-    "toggle": "database_enabled",
-    "description": "Connect to external database (PostgreSQL or MySQL)",
-    "vars": [
-      {
-        "name": "database_enabled",
-        "description": "Enable external database integration",
-        "type": "bool",
-        "default": false
-      },
-      {
-        "name": "database_type",
-        "description": "Database type",
-        "type": "enum",
-        "options": ["sqlite", "postgres", "mysql"],
-        "default": "sqlite",
-        "required": true
-      },
-      {
-        "name": "database_external",
-        "description": "Use an external database server?",
-        "extra": "skips creation of internal database container",
-        "type": "bool",
-        "needs": ["database_type=postgres,mysql"],
-        "default": false
-      },
-      {
-        "name": "database_host",
-        "description": "Database host",
-        "type": "str",
-        "needs": ["database_external=true;database_type=postgres,mysql"],
-        "required": true
-      },
-      {
-        "name": "database_port",
-        "description": "Database port",
-        "type": "int",
-        "needs": ["database_external=true;database_type=postgres,mysql"],
-        "required": true
-      },
-      {
-        "name": "database_name",
-        "description": "Database name",
-        "type": "str",
-        "needs": ["database_type=postgres,mysql"],
-        "required": true
-      },
-      {
-        "name": "database_user",
-        "description": "Database user",
-        "type": "str",
-        "needs": ["database_type=postgres,mysql"],
-        "required": true
-      },
-      {
-        "name": "database_password",
-        "description": "Database password",
-        "type": "str",
-        "needs": ["database_type=postgres,mysql"],
-        "sensitive": true,
-        "autogenerated": true,
-        "required": true
-      }
-    ]
-  },
-  {
-    "key": "email",
-    "title": "Email Server",
-    "toggle": "email_enabled",
-    "description": "Configure email server for notifications and user management.",
-    "vars": [
-      {
-        "name": "email_enabled",
-        "description": "Enable email server configuration",
-        "type": "bool",
-        "default": false
-      },
-      {
-        "name": "email_host",
-        "description": "SMTP server hostname",
-        "type": "str",
-        "required": true
-      },
-      {
-        "name": "email_port",
-        "description": "SMTP server port",
-        "type": "int",
-        "default": 25,
-        "required": true
-      },
-      {
-        "name": "email_username",
-        "description": "SMTP username",
-        "type": "str",
-        "required": true
-      },
-      {
-        "name": "email_password",
-        "description": "SMTP password",
-        "type": "str",
-        "sensitive": true,
-        "required": true
-      },
-      {
-        "name": "email_from",
-        "description": "From email address",
-        "type": "str",
-        "required": true
-      },
-      {
-        "name": "email_encryption",
-        "description": "Email encryption method to use",
-        "type": "enum",
-        "options": ["none", "starttls", "ssl"]
-      }
-    ]
-  },
-  {
-    "key": "authentik",
-    "title": "Authentik SSO",
-    "toggle": "authentik_enabled",
-    "description": "Integrate with Authentik for Single Sign-On authentication.",
-    "vars": [
-      {
-        "name": "authentik_enabled",
-        "description": "Enable Authentik SSO integration",
-        "type": "bool",
-        "default": false
-      },
-      {
-        "name": "authentik_url",
-        "description": "Authentik base URL (e.g., https://auth.example.com)",
-        "type": "url",
-        "required": true
-      },
-      {
-        "name": "authentik_slug",
-        "description": "Authentik application slug",
-        "type": "str",
-        "required": true
-      },
-      {
-        "name": "authentik_traefik_middleware",
-        "description": "Traefik middleware name for Authentik authentication",
-        "type": "str",
-        "default": "authentik-middleware@file",
-        "needs": ["traefik_enabled=true"],
-        "required": true
-      },
-      {
-        "name": "authentik_client_id",
-        "description": "Authentik OAuth2 client ID",
-        "type": "str",
-        "sensitive": true,
-        "required": true
-      },
-      {
-        "name": "authentik_client_secret",
-        "description": "Authentik OAuth2 client secret",
-        "type": "str",
-        "sensitive": true,
-        "required": true
-      }
-    ]
-  }
-]

+ 0 - 202
cli/core/schema/helm/v1.0.json

@@ -1,202 +0,0 @@
-[
-  {
-    "key": "general",
-    "title": "General",
-    "required": true,
-    "vars": [
-      {
-        "name": "release_name",
-        "description": "Helm release name",
-        "type": "str",
-        "required": true
-      },
-      {
-        "name": "namespace",
-        "description": "Kubernetes namespace",
-        "type": "str"
-      }
-    ]
-  },
-  {
-    "key": "networking",
-    "title": "Networking",
-    "vars": [
-      {
-        "name": "network_mode",
-        "description": "Kubernetes service type",
-        "type": "enum",
-        "options": ["ClusterIP", "NodePort", "LoadBalancer"],
-        "default": "ClusterIP"
-      }
-    ]
-  },
-  {
-    "key": "database",
-    "title": "Database Configuration",
-    "toggle": "database_enabled",
-    "vars": [
-      {
-        "name": "database_enabled",
-        "description": "Enable external database configuration",
-        "type": "bool",
-        "default": false
-      },
-      {
-        "name": "database_type",
-        "description": "Database type",
-        "type": "enum",
-        "options": ["postgres", "mysql", "mariadb"],
-        "default": "postgres"
-      },
-      {
-        "name": "database_host",
-        "description": "Database hostname",
-        "type": "hostname"
-      },
-      {
-        "name": "database_port",
-        "description": "Database port",
-        "type": "int",
-        "default": 5432
-      },
-      {
-        "name": "database_name",
-        "description": "Database name",
-        "type": "str"
-      },
-      {
-        "name": "database_user",
-        "description": "Database username",
-        "type": "str"
-      },
-      {
-        "name": "database_password",
-        "description": "Database password",
-        "type": "str",
-        "sensitive": true
-      }
-    ]
-  },
-  {
-    "key": "email",
-    "title": "Email Configuration",
-    "toggle": "email_enabled",
-    "vars": [
-      {
-        "name": "email_enabled",
-        "description": "Enable email configuration",
-        "type": "bool",
-        "default": false
-      },
-      {
-        "name": "email_host",
-        "description": "SMTP server hostname",
-        "type": "hostname"
-      },
-      {
-        "name": "email_port",
-        "description": "SMTP server port",
-        "type": "int",
-        "default": 587
-      },
-      {
-        "name": "email_username",
-        "description": "SMTP username",
-        "type": "str"
-      },
-      {
-        "name": "email_password",
-        "description": "SMTP password",
-        "type": "str",
-        "sensitive": true
-      },
-      {
-        "name": "email_from",
-        "description": "From email address",
-        "type": "email"
-      },
-      {
-        "name": "email_use_tls",
-        "description": "Use TLS encryption",
-        "type": "bool",
-        "default": true
-      },
-      {
-        "name": "email_use_ssl",
-        "description": "Use SSL encryption",
-        "type": "bool",
-        "default": false
-      }
-    ]
-  },
-  {
-    "key": "traefik",
-    "title": "Traefik Ingress",
-    "toggle": "traefik_enabled",
-    "vars": [
-      {
-        "name": "traefik_enabled",
-        "description": "Enable Traefik ingress",
-        "type": "bool",
-        "default": false
-      },
-      {
-        "name": "traefik_host",
-        "description": "Ingress hostname (FQDN)",
-        "type": "hostname"
-      }
-    ]
-  },
-  {
-    "key": "traefik_tls",
-    "title": "Traefik TLS/SSL",
-    "needs": "traefik",
-    "toggle": "traefik_tls_enabled",
-    "vars": [
-      {
-        "name": "traefik_tls_enabled",
-        "description": "Enable TLS for ingress",
-        "type": "bool",
-        "default": true
-      },
-      {
-        "name": "traefik_tls_certmanager",
-        "description": "Use cert-manager for TLS certificates",
-        "type": "bool",
-        "default": false
-      },
-      {
-        "name": "certmanager_issuer",
-        "description": "Cert-manager cluster issuer name",
-        "type": "str",
-        "needs": "traefik_tls_certmanager=true",
-        "default": "letsencrypt-prod"
-      },
-      {
-        "name": "traefik_tls_secret",
-        "description": "TLS secret name",
-        "type": "str"
-      }
-    ]
-  },
-  {
-    "key": "volumes",
-    "title": "Persistent Volumes",
-    "vars": [
-      {
-        "name": "volumes_mode",
-        "description": "Volume configuration mode",
-        "type": "enum",
-        "options": ["dynamic-pvc", "existing-pvc"],
-        "default": "dynamic-pvc",
-        "extra": "dynamic-pvc=auto-provision storage, existing-pvc=use existing PVC"
-      },
-      {
-        "name": "volumes_pvc_name",
-        "description": "Existing PVC name",
-        "type": "str",
-        "needs": "volumes_mode=existing-pvc"
-      }
-    ]
-  }
-]

+ 0 - 247
cli/core/schema/kubernetes/v1.0.json

@@ -1,247 +0,0 @@
-[
-  {
-    "key": "general",
-    "title": "General",
-    "required": true,
-    "vars": [
-      {
-        "name": "resource_name",
-        "description": "Kubernetes resource name",
-        "type": "str",
-        "required": true
-      },
-      {
-        "name": "namespace",
-        "description": "Kubernetes namespace",
-        "type": "str",
-        "default": "default",
-        "required": true
-      }
-    ]
-  },
-  {
-    "key": "resources",
-    "title": "Resource Limits",
-    "toggle": "resources_enabled",
-    "description": "Set CPU and memory limits for the resource.",
-    "vars": [
-      {
-        "name": "resources_enabled",
-        "description": "Enable resource limits",
-        "type": "bool",
-        "default": false
-      },
-      {
-        "name": "resources_cpu_limit",
-        "description": "Maximum CPU cores (e.g., 100m, 500m, 1, 2)",
-        "type": "str",
-        "default": "1",
-        "required": true
-      },
-      {
-        "name": "resources_cpu_request",
-        "description": "Requested CPU cores",
-        "type": "str",
-        "default": "250m",
-        "required": true
-      },
-      {
-        "name": "resources_memory_limit",
-        "description": "Maximum memory (e.g., 512Mi, 1Gi, 2Gi)",
-        "type": "str",
-        "default": "1Gi",
-        "required": true
-      },
-      {
-        "name": "resources_memory_request",
-        "description": "Requested memory",
-        "type": "str",
-        "default": "512Mi",
-        "required": true
-      }
-    ]
-  },
-  {
-    "key": "traefik",
-    "title": "Traefik",
-    "toggle": "traefik_enabled",
-    "description": "Traefik routes external traffic to your service.",
-    "vars": [
-      {
-        "name": "traefik_enabled",
-        "description": "Enable Traefik ingress configuration",
-        "type": "bool",
-        "default": false
-      },
-      {
-        "name": "traefik_host",
-        "description": "Service subdomain or full hostname (e.g., 'app' or 'app.example.com')",
-        "type": "str",
-        "required": true
-      },
-      {
-        "name": "traefik_domain",
-        "description": "Base domain (e.g., example.com)",
-        "type": "str",
-        "default": "home.arpa",
-        "required": true
-      }
-    ]
-  },
-  {
-    "key": "traefik_tls",
-    "title": "Traefik TLS/SSL",
-    "toggle": "traefik_tls_enabled",
-    "needs": ["traefik"],
-    "description": "Enable HTTPS/TLS for Traefik with certificate management.",
-    "vars": [
-      {
-        "name": "traefik_tls_enabled",
-        "description": "Enable HTTPS/TLS",
-        "type": "bool",
-        "default": true
-      },
-      {
-        "name": "traefik_tls_certresolver",
-        "description": "Traefik certificate resolver name",
-        "type": "str",
-        "default": "cloudflare",
-        "required": true
-      }
-    ]
-  },
-  {
-    "key": "database",
-    "title": "Database",
-    "toggle": "database_enabled",
-    "description": "Connect to external database (PostgreSQL or MySQL)",
-    "vars": [
-      {
-        "name": "database_enabled",
-        "description": "Enable external database integration",
-        "type": "bool",
-        "default": false
-      },
-      {
-        "name": "database_type",
-        "description": "Database type",
-        "type": "enum",
-        "options": ["sqlite", "postgres", "mysql", "mariadb"],
-        "default": "postgres",
-        "required": true
-      },
-      {
-        "name": "database_host",
-        "description": "Database host",
-        "type": "str",
-        "default": "database",
-        "required": true
-      },
-      {
-        "name": "database_port",
-        "description": "Database port",
-        "type": "int",
-        "required": true
-      },
-      {
-        "name": "database_name",
-        "description": "Database name",
-        "type": "str",
-        "required": true
-      },
-      {
-        "name": "database_user",
-        "description": "Database user",
-        "type": "str",
-        "required": true
-      },
-      {
-        "name": "database_password",
-        "description": "Database password",
-        "type": "str",
-        "default": "",
-        "sensitive": true,
-        "autogenerated": true,
-        "required": true
-      }
-    ]
-  },
-  {
-    "key": "email",
-    "title": "Email Server",
-    "toggle": "email_enabled",
-    "description": "Configure email server for notifications and user management.",
-    "vars": [
-      {
-        "name": "email_enabled",
-        "description": "Enable email server configuration",
-        "type": "bool",
-        "default": false
-      },
-      {
-        "name": "email_host",
-        "description": "SMTP server hostname",
-        "type": "str",
-        "required": true
-      },
-      {
-        "name": "email_port",
-        "description": "SMTP server port",
-        "type": "int",
-        "default": 25,
-        "required": true
-      },
-      {
-        "name": "email_username",
-        "description": "SMTP username",
-        "type": "str",
-        "required": true
-      },
-      {
-        "name": "email_password",
-        "description": "SMTP password",
-        "type": "str",
-        "sensitive": true,
-        "required": true
-      },
-      {
-        "name": "email_from",
-        "description": "From email address",
-        "type": "str",
-        "required": true
-      },
-      {
-        "name": "email_encryption",
-        "description": "Email encryption method to use",
-        "type": "enum",
-        "options": ["none", "starttls", "ssl"]
-      }
-    ]
-  },
-  {
-    "key": "authentik",
-    "title": "Authentik SSO",
-    "toggle": "authentik_enabled",
-    "description": "Integrate with Authentik for Single Sign-On authentication.",
-    "vars": [
-      {
-        "name": "authentik_enabled",
-        "description": "Enable Authentik SSO integration",
-        "type": "bool",
-        "default": false
-      },
-      {
-        "name": "authentik_url",
-        "description": "Authentik base URL (e.g., https://auth.example.com)",
-        "type": "url",
-        "required": true
-      },
-      {
-        "name": "authentik_slug",
-        "description": "Authentik application slug",
-        "type": "str",
-        "required": true
-      }
-    ]
-  }
-]

+ 0 - 220
cli/core/schema/loader.py

@@ -1,220 +0,0 @@
-"""JSON Schema Loading and Validation.
-
-This module provides functionality to load, cache, and validate JSON schemas
-for boilerplate modules. Schemas are stored in cli/core/schema/<module>/v*.json files.
-"""
-
-import json
-from pathlib import Path
-from typing import Any
-
-from cli.core.exceptions import SchemaError
-
-
-class SchemaLoader:
-    """Loads and validates JSON schemas for modules."""
-
-    def __init__(self, schema_dir: Path | None = None):
-        """Initialize schema loader.
-
-        Args:
-            schema_dir: Directory containing schema files. If None, uses cli/core/schema/
-        """
-        if schema_dir is None:
-            # Use path relative to this file (in cli/core/schema/)
-            # __file__ is cli/core/schema/loader.py, parent is cli/core/schema/
-            self.schema_dir = Path(__file__).parent
-        else:
-            self.schema_dir = schema_dir
-
-    def load_schema(self, module: str, version: str) -> list[dict[str, Any]]:
-        """Load a JSON schema from file.
-
-        Args:
-            module: Module name (e.g., 'compose', 'ansible')
-            version: Schema version (e.g., '1.0', '1.2')
-
-        Returns:
-            Schema as list of section specifications
-
-        Raises:
-            SchemaError: If schema file not found or invalid JSON
-        """
-        schema_file = self.schema_dir / module / f"v{version}.json"
-
-        if not schema_file.exists():
-            raise SchemaError(
-                f"Schema file not found: {schema_file}",
-                details=f"Module: {module}, Version: {version}",
-            )
-
-        try:
-            with schema_file.open(encoding="utf-8") as f:
-                schema = json.load(f)
-        except json.JSONDecodeError as e:
-            raise SchemaError(
-                f"Invalid JSON in schema file: {schema_file}",
-                details=f"Error: {e}",
-            ) from e
-        except Exception as e:
-            raise SchemaError(
-                f"Failed to read schema file: {schema_file}",
-                details=f"Error: {e}",
-            ) from e
-
-        # Validate schema structure
-        self._validate_schema_structure(schema, module, version)
-
-        return schema
-
-    def _validate_schema_structure(self, schema: Any, module: str, version: str) -> None:
-        """Validate that schema has correct structure.
-
-        Args:
-            schema: Schema to validate
-            module: Module name for error messages
-            version: Version for error messages
-
-        Raises:
-            SchemaError: If schema structure is invalid
-        """
-        if not isinstance(schema, list):
-            raise SchemaError(
-                f"Schema must be a list, got {type(schema).__name__}",
-                details=f"Module: {module}, Version: {version}",
-            )
-
-        for idx, section in enumerate(schema):
-            if not isinstance(section, dict):
-                raise SchemaError(
-                    f"Section {idx} must be a dict, got {type(section).__name__}",
-                    details=f"Module: {module}, Version: {version}",
-                )
-
-            # Check required fields
-            if "key" not in section:
-                raise SchemaError(
-                    f"Section {idx} missing required field 'key'",
-                    details=f"Module: {module}, Version: {version}",
-                )
-
-            if "vars" not in section:
-                raise SchemaError(
-                    f"Section '{section.get('key')}' missing required field 'vars'",
-                    details=f"Module: {module}, Version: {version}",
-                )
-
-            if not isinstance(section["vars"], list):
-                raise SchemaError(
-                    f"Section '{section['key']}' vars must be a list",
-                    details=f"Module: {module}, Version: {version}",
-                )
-
-            # Validate variables
-            for var_idx, var in enumerate(section["vars"]):
-                if not isinstance(var, dict):
-                    raise SchemaError(
-                        f"Variable {var_idx} in section '{section['key']}' must be a dict",
-                        details=f"Module: {module}, Version: {version}",
-                    )
-
-                if "name" not in var:
-                    raise SchemaError(
-                        f"Variable {var_idx} in section '{section['key']}' missing 'name'",
-                        details=f"Module: {module}, Version: {version}",
-                    )
-
-                if "type" not in var:
-                    raise SchemaError(
-                        f"Variable '{var.get('name')}' in section '{section['key']}' missing 'type'",
-                        details=f"Module: {module}, Version: {version}",
-                    )
-
-    def list_versions(self, module: str) -> list[str]:
-        """List available schema versions for a module.
-
-        Args:
-            module: Module name
-
-        Returns:
-            List of version strings (e.g., ['1.0', '1.1', '1.2'])
-        """
-        module_dir = self.schema_dir / module
-
-        if not module_dir.exists():
-            return []
-
-        versions = []
-        for file in module_dir.glob("v*.json"):
-            # Extract version from filename (v1.0.json -> 1.0)
-            version = file.stem[1:]  # Remove 'v' prefix
-            versions.append(version)
-
-        return sorted(versions)
-
-    def has_schema(self, module: str, version: str) -> bool:
-        """Check if a schema exists.
-
-        Args:
-            module: Module name
-            version: Schema version
-
-        Returns:
-            True if schema exists
-        """
-        schema_file = self.schema_dir / module / f"v{version}.json"
-        return schema_file.exists()
-
-
-# Global schema loader instance
-_loader: SchemaLoader | None = None
-
-
-def get_loader() -> SchemaLoader:
-    """Get global schema loader instance.
-
-    Returns:
-        SchemaLoader instance
-    """
-    global _loader  # noqa: PLW0603
-    if _loader is None:
-        _loader = SchemaLoader()
-    return _loader
-
-
-def load_schema(module: str, version: str) -> list[dict[str, Any]]:
-    """Load a schema using the global loader.
-
-    Args:
-        module: Module name
-        version: Schema version
-
-    Returns:
-        Schema as list of section specifications
-    """
-    return get_loader().load_schema(module, version)
-
-
-def list_versions(module: str) -> list[str]:
-    """List available versions for a module.
-
-    Args:
-        module: Module name
-
-    Returns:
-        List of version strings
-    """
-    return get_loader().list_versions(module)
-
-
-def has_schema(module: str, version: str) -> bool:
-    """Check if a schema exists.
-
-    Args:
-        module: Module name
-        version: Schema version
-
-    Returns:
-        True if schema exists
-    """
-    return get_loader().has_schema(module, version)

+ 0 - 14
cli/core/schema/packer/v1.0.json

@@ -1,14 +0,0 @@
-[
-  {
-    "key": "general",
-    "title": "General",
-    "required": true,
-    "vars": [
-      {
-        "name": "playbook_name",
-        "description": "Ansible playbook name",
-        "type": "str"
-      }
-    ]
-  }
-]

+ 0 - 87
cli/core/schema/terraform/v1.0.json

@@ -1,87 +0,0 @@
-[
-  {
-    "key": "general",
-    "title": "General",
-    "required": true,
-    "vars": [
-      {
-        "name": "resource_name",
-        "description": "Terraform resource name (alphanumeric and underscores only)",
-        "type": "str",
-        "default": "resource"
-      }
-    ]
-  },
-  {
-    "key": "depends_on",
-    "title": "Dependencies",
-    "toggle": "depends_on_enabled",
-    "required": false,
-    "vars": [
-      {
-        "name": "depends_on_enabled",
-        "description": "Enable resource dependencies",
-        "type": "bool",
-        "default": false
-      },
-      {
-        "name": "dependencies",
-        "description": "Comma-separated list of resource dependencies",
-        "type": "str",
-        "default": ""
-      }
-    ]
-  },
-  {
-    "key": "lifecycle",
-    "title": "Lifecycle",
-    "toggle": "lifecycle_enabled",
-    "required": false,
-    "vars": [
-      {
-        "name": "lifecycle_enabled",
-        "description": "Enable lifecycle rules",
-        "type": "bool",
-        "default": false
-      },
-      {
-        "name": "prevent_destroy",
-        "description": "Prevent resource destruction",
-        "type": "bool",
-        "default": false
-      },
-      {
-        "name": "create_before_destroy",
-        "description": "Create replacement before destroying",
-        "type": "bool",
-        "default": false
-      },
-      {
-        "name": "ignore_changes",
-        "description": "Comma-separated list of attributes to ignore changes for",
-        "type": "str",
-        "default": ""
-      }
-    ]
-  },
-  {
-    "key": "tags",
-    "title": "Tags",
-    "toggle": "tags_enabled",
-    "required": false,
-    "vars": [
-      {
-        "name": "tags_enabled",
-        "description": "Enable resource tags",
-        "type": "bool",
-        "default": false
-      },
-      {
-        "name": "tags_json",
-        "description": "Resource tags in JSON format (e.g., {\"Environment\": \"Production\"})",
-        "type": "str",
-        "default": "{}"
-      }
-    ]
-  }
-]

+ 10 - 1
cli/core/template/__init__.py

@@ -4,7 +4,14 @@ This package provides Template, VariableCollection, VariableSection, and Variabl
 classes for managing templates and their variables.
 """
 
-from .template import Template, TemplateErrorHandler, TemplateFile, TemplateMetadata
+from .template import (
+    Template,
+    TemplateErrorHandler,
+    TemplateFile,
+    TemplateMetadata,
+    TemplateVersionMetadata,
+    normalize_template_slug,
+)
 from .variable import Variable
 from .variable_collection import VariableCollection
 from .variable_section import VariableSection
@@ -14,7 +21,9 @@ __all__ = [
     "TemplateErrorHandler",
     "TemplateFile",
     "TemplateMetadata",
+    "TemplateVersionMetadata",
     "Variable",
     "VariableCollection",
     "VariableSection",
+    "normalize_template_slug",
 ]

Plik diff jest za duży
+ 454 - 700
cli/core/template/template.py


+ 487 - 221
cli/core/template/variable.py

@@ -1,6 +1,6 @@
 from __future__ import annotations
 
-import logging
+from dataclasses import dataclass
 from typing import TYPE_CHECKING, Any
 from urllib.parse import urlparse
 
@@ -9,105 +9,369 @@ from email_validator import EmailNotValidError, validate_email
 if TYPE_CHECKING:
     from cli.core.template.variable_section import VariableSection
 
-logger = logging.getLogger(__name__)
-
-# Constants
 DEFAULT_AUTOGENERATED_LENGTH = 32
+DEFAULT_AUTOGENERATED_BYTES = 32
 TRUE_VALUES = {"true", "1", "yes", "on"}
 FALSE_VALUES = {"false", "0", "no", "off"}
+SECRET_TYPE = "secret"
+SECRET_AUTOGENERATED_KIND_CHARACTERS = "characters"
+SECRET_AUTOGENERATED_KIND_BASE64 = "base64"
+
+
+@dataclass
+class SecretAutogeneratedConfig:
+    """Structured autogeneration settings for secret variables."""
+
+    kind: str = SECRET_AUTOGENERATED_KIND_CHARACTERS
+    length: int | None = None
+    characters: list[str] | None = None
+    bytes: int | None = None
+
+    def clone(self) -> SecretAutogeneratedConfig:
+        return SecretAutogeneratedConfig(
+            kind=self.kind,
+            length=self.length,
+            characters=self.characters.copy() if self.characters else None,
+            bytes=self.bytes,
+        )
+
+    def length_or_default(self) -> int:
+        return self.length if self.length is not None else DEFAULT_AUTOGENERATED_LENGTH
+
+    def bytes_or_default(self) -> int:
+        return self.bytes if self.bytes is not None else DEFAULT_AUTOGENERATED_BYTES
+
+
+@dataclass
+class VariableConfig:
+    """Type-specific variable configuration."""
+
+    placeholder: str | None = None
+    textarea: bool = False
+    unit: str | None = None
+    options: list[str] | None = None
+    slider: bool = False
+    min: int | None = None
+    max: int | None = None
+    step: int | None = None
+    autogenerated: SecretAutogeneratedConfig | None = None
+
+    def clone(self) -> VariableConfig:
+        return VariableConfig(
+            placeholder=self.placeholder,
+            textarea=self.textarea,
+            unit=self.unit,
+            options=self.options.copy() if self.options else None,
+            slider=self.slider,
+            min=self.min,
+            max=self.max,
+            step=self.step,
+            autogenerated=self.autogenerated.clone() if self.autogenerated else None,
+        )
+
+    def is_empty(self) -> bool:
+        return (
+            not self.placeholder
+            and not self.textarea
+            and not self.unit
+            and not self.options
+            and not self.slider
+            and self.min is None
+            and self.max is None
+            and self.step is None
+            and self.autogenerated is None
+        )
 
 
 class Variable:
     """Represents a single templating variable with lightweight validation."""
 
     def __init__(self, data: dict[str, Any]) -> None:
-        """Initialize Variable from a dictionary containing variable specification.
-
-        Args:
-            data: Dictionary containing variable specification with required
-                  'name' key and optional keys: description, type, options,
-                  prompt, value, default, section, origin
-
-        Raises:
-            ValueError: If data is not a dict, missing 'name' key, or has invalid default value
-        """
-        # Validate input
-        if not isinstance(data, dict):
-            raise ValueError("Variable data must be a dictionary")
-
-        if "name" not in data:
-            raise ValueError("Variable data must contain 'name' key")
-
-        # Track which fields were explicitly provided in source data
+        self._validate_input_data(data)
         self._explicit_fields: set[str] = set(data.keys())
 
-        # Initialize fields
         self.name: str = data["name"]
-        # Reference to parent section (set by VariableCollection)
         self.parent_section: VariableSection | None = data.get("parent_section")
         self.description: str | None = data.get("description") or data.get("display", "")
         self.type: str = data.get("type", "str")
-        self.options: list[Any] | None = data.get("options", [])
         self.prompt: str | None = data.get("prompt")
-        if "value" in data:
-            self.value: Any = data.get("value")
-        elif "default" in data:
-            self.value: Any = data.get("default")
-        else:
-            # RULE: If bool variables don't have any default value or value at all,
-            # automatically set them to false
-            self.value: Any = False if self.type == "bool" else None
+        self.value: Any = self._resolve_initial_value(data)
         self.origin: str | None = data.get("origin")
-        self.sensitive: bool = data.get("sensitive", False)
-        # Optional extra explanation used by interactive prompts
         self.extra: str | None = data.get("extra")
-        # Flag indicating this variable should be auto-generated when empty
-        self.autogenerated: bool = data.get("autogenerated", False)
-        # Length of auto-generated value
-        self.autogenerated_length: int = data.get("autogenerated_length", DEFAULT_AUTOGENERATED_LENGTH)
-        # Flag indicating if autogenerated value should be base64 encoded
-        self.autogenerated_base64: bool = data.get("autogenerated_base64", False)
-        # Flag indicating this variable is required (must have a value)
         self.required: bool = data.get("required", False)
-        # Original value before config override (used for display)
         self.original_value: Any | None = data.get("original_value")
-        # Variable dependencies - can be string or list of strings in format "var_name=value"
-        # Supports semicolon-separated multiple conditions: "var1=value1;var2=value2,value3"
-        needs_value = data.get("needs")
-        if needs_value:
-            if isinstance(needs_value, str):
-                # Split by semicolon to support multiple AND conditions in a single string
-                # Example: "traefik_enabled=true;network_mode=bridge,macvlan"
-                self.needs: list[str] = [need.strip() for need in needs_value.split(";") if need.strip()]
-            elif isinstance(needs_value, list):
-                self.needs: list[str] = needs_value
-            else:
-                raise ValueError(f"Variable '{self.name}' has invalid 'needs' value: must be string or list")
+
+        self.config = self._normalize_config(self.name, self.type, data.get("config"))
+        self._apply_config_state()
+        self._validate_secret_defaults(data)
+        self.needs = self._parse_needs(data.get("needs"))
+        self._validate_initial_value()
+
+    @staticmethod
+    def _validate_input_data(data: dict[str, Any]) -> None:
+        if not isinstance(data, dict):
+            raise ValueError("Variable data must be a dictionary")
+        if "name" not in data:
+            raise ValueError("Variable data must contain 'name' key")
+
+    def _resolve_initial_value(self, data: dict[str, Any]) -> Any:
+        if "value" in data:
+            return data.get("value")
+        if "default" in data:
+            return data.get("default")
+        return False if self.type == "bool" else None
+
+    def _apply_config_state(self) -> None:
+        self.options: list[str] | None = self.config.options.copy() if self.config.options else None
+        self.autogenerated_config: SecretAutogeneratedConfig | None = (
+            self.config.autogenerated.clone() if self.config.autogenerated else None
+        )
+        self.autogenerated: bool = self.autogenerated_config is not None
+        self.autogenerated_length: int = (
+            self.autogenerated_config.length_or_default() if self.autogenerated_config else DEFAULT_AUTOGENERATED_LENGTH
+        )
+        self.autogenerated_base64: bool = bool(
+            self.autogenerated_config and self.autogenerated_config.kind == SECRET_AUTOGENERATED_KIND_BASE64
+        )
+
+    def _validate_secret_defaults(self, data: dict[str, Any]) -> None:
+        if self.type == SECRET_TYPE and self.autogenerated and "default" in data:
+            raise ValueError(
+                f"Invalid default for variable '{self.name}': autogenerated secrets cannot define defaults"
+            )
+
+    def _parse_needs(self, needs_value: Any) -> list[str]:
+        if not needs_value:
+            return []
+        if isinstance(needs_value, str):
+            return [need.strip() for need in needs_value.split(";") if need.strip()]
+        if isinstance(needs_value, list):
+            return needs_value
+        raise ValueError(f"Variable '{self.name}' has invalid 'needs' value: must be string or list")
+
+    def _validate_initial_value(self) -> None:
+        if self.value is None:
+            return
+        try:
+            self.value = self.convert(self.value)
+            if self.type == "int" and self.value is not None and self.config.slider:
+                self._validate_slider_value(self.value)
+        except ValueError as exc:
+            raise ValueError(f"Invalid default for variable '{self.name}': {exc}") from exc
+
+    @staticmethod
+    def _normalize_str_list(values: list[Any] | None) -> list[str] | None:
+        if not values:
+            return None
+
+        normalized: list[str] = []
+        seen: set[str] = set()
+        for value in values:
+            item = str(value).strip()
+            if not item or item in seen:
+                continue
+            seen.add(item)
+            normalized.append(item)
+        return normalized or None
+
+    @classmethod
+    def _parse_secret_autogenerated(
+        cls,
+        variable_name: str,
+        autogenerated_input: Any,
+        legacy_length: Any = None,
+        legacy_base64: bool = False,
+    ) -> SecretAutogeneratedConfig | None:
+        if autogenerated_input in (None, False):
+            return cls._legacy_secret_autogenerated(legacy_length, legacy_base64)
+
+        if autogenerated_input is True:
+            return cls._boolean_secret_autogenerated(variable_name, legacy_length, legacy_base64)
+
+        if not isinstance(autogenerated_input, dict):
+            raise ValueError("autogenerated must be a boolean or object")
+
+        config = cls._dict_secret_autogenerated(autogenerated_input, legacy_length, legacy_base64)
+        return cls._validate_secret_autogenerated(variable_name, config)
+
+    @staticmethod
+    def _legacy_secret_autogenerated(
+        legacy_length: Any,
+        legacy_base64: bool,
+    ) -> SecretAutogeneratedConfig | None:
+        if not legacy_base64:
+            return None
+        return SecretAutogeneratedConfig(
+            kind=SECRET_AUTOGENERATED_KIND_BASE64,
+            bytes=int(legacy_length) if legacy_length is not None else DEFAULT_AUTOGENERATED_BYTES,
+        )
+
+    @classmethod
+    def _boolean_secret_autogenerated(
+        cls,
+        variable_name: str,
+        legacy_length: Any,
+        legacy_base64: bool,
+    ) -> SecretAutogeneratedConfig:
+        legacy_config = cls._legacy_secret_autogenerated(legacy_length, legacy_base64)
+        if legacy_config is not None:
+            return legacy_config
+        config = SecretAutogeneratedConfig()
+        if legacy_length is not None:
+            config.length = int(legacy_length)
+        return cls._validate_secret_autogenerated(variable_name, config)
+
+    @classmethod
+    def _dict_secret_autogenerated(
+        cls,
+        autogenerated_input: dict[str, Any],
+        legacy_length: Any,
+        legacy_base64: bool,
+    ) -> SecretAutogeneratedConfig:
+        kind = str(
+            autogenerated_input.get("kind")
+            or (SECRET_AUTOGENERATED_KIND_BASE64 if legacy_base64 else SECRET_AUTOGENERATED_KIND_CHARACTERS)
+        ).strip()
+        config = SecretAutogeneratedConfig(kind=kind)
+
+        if autogenerated_input.get("length") is not None:
+            config.length = int(autogenerated_input["length"])
+        elif legacy_length is not None and kind != SECRET_AUTOGENERATED_KIND_BASE64:
+            config.length = int(legacy_length)
+
+        if autogenerated_input.get("bytes") is not None:
+            config.bytes = int(autogenerated_input["bytes"])
+        if autogenerated_input.get("characters") is not None:
+            config.characters = cls._normalize_secret_characters(autogenerated_input["characters"])
+        return config
+
+    @staticmethod
+    def _normalize_secret_characters(characters: Any) -> list[str] | None:
+        if not isinstance(characters, list):
+            raise ValueError("autogenerated.characters must be a list")
+
+        normalized_characters = []
+        seen: set[str] = set()
+        for char in characters:
+            item = str(char).strip()
+            if not item:
+                continue
+            if len(item) != 1:
+                raise ValueError("autogenerated.characters entries must each be exactly one character")
+            if item in seen:
+                continue
+            seen.add(item)
+            normalized_characters.append(item)
+        return normalized_characters or None
+
+    @staticmethod
+    def _validate_secret_autogenerated(
+        variable_name: str,
+        config: SecretAutogeneratedConfig,
+    ) -> SecretAutogeneratedConfig:
+        kind = (config.kind or SECRET_AUTOGENERATED_KIND_CHARACTERS).strip()
+        if kind not in (SECRET_AUTOGENERATED_KIND_CHARACTERS, SECRET_AUTOGENERATED_KIND_BASE64):
+            raise ValueError(
+                f"variable '{variable_name}' autogenerated.kind must be one of "
+                f"'{SECRET_AUTOGENERATED_KIND_CHARACTERS}' or '{SECRET_AUTOGENERATED_KIND_BASE64}'"
+            )
+        config.kind = kind
+
+        if kind == SECRET_AUTOGENERATED_KIND_CHARACTERS:
+            if config.bytes is not None:
+                raise ValueError(f"variable '{variable_name}' character autogenerated secrets cannot define bytes")
+            if config.length is not None and config.length <= 0:
+                raise ValueError(f"variable '{variable_name}' autogenerated.length must be greater than 0")
+            if config.characters is not None and not config.characters:
+                raise ValueError(f"variable '{variable_name}' autogenerated.characters must not be empty")
+            return config
+
+        if config.length is not None:
+            raise ValueError(
+                f"variable '{variable_name}' base64 autogenerated secrets must use bytes instead of length"
+            )
+        if config.characters is not None:
+            raise ValueError(f"variable '{variable_name}' base64 autogenerated secrets cannot define characters")
+        if config.bytes is not None and config.bytes <= 0:
+            raise ValueError(f"variable '{variable_name}' autogenerated.bytes must be greater than 0")
+        return config
+
+    @classmethod
+    def _normalize_config(
+        cls,
+        variable_name: str,
+        variable_type: str,
+        config_input: Any,
+    ) -> VariableConfig:
+        if config_input is None:
+            config_data: dict[str, Any] = {}
+        elif isinstance(config_input, dict):
+            config_data = config_input.copy()
         else:
-            self.needs: list[str] = []
+            raise ValueError(f"Variable '{variable_name}' config must be a dictionary")
+
+        options = cls._normalize_str_list(config_data.get("options"))
+
+        placeholder = config_data.get("placeholder")
+        placeholder = str(placeholder).strip() if placeholder not in (None, "") else None
+        textarea = bool(config_data.get("textarea", False))
+        unit = config_data.get("unit")
+        unit = str(unit).strip() if unit not in (None, "") else None
+        slider = bool(config_data.get("slider", False))
+
+        min_value = config_data.get("min")
+        max_value = config_data.get("max")
+        step_value = config_data.get("step")
+
+        min_int = int(min_value) if min_value is not None else None
+        max_int = int(max_value) if max_value is not None else None
+        step_int = int(step_value) if step_value is not None else None
+
+        autogenerated_input = config_data.get("autogenerated")
+        autogenerated_config = None
+        if autogenerated_input not in (None, False) and variable_type != SECRET_TYPE:
+            raise ValueError("autogenerated is only supported for secret variables")
+        if variable_type == SECRET_TYPE:
+            autogenerated_config = cls._parse_secret_autogenerated(
+                variable_name,
+                autogenerated_input,
+            )
 
-        # Validate and convert the default/initial value if present
-        if self.value is not None:
-            try:
-                self.value = self.convert(self.value)
-            except ValueError as exc:
-                raise ValueError(f"Invalid default for variable '{self.name}': {exc}") from exc
+        config = VariableConfig(
+            placeholder=placeholder,
+            textarea=textarea,
+            unit=unit,
+            options=options,
+            slider=slider,
+            min=min_int,
+            max=max_int,
+            step=step_int,
+            autogenerated=autogenerated_config,
+        )
+
+        if variable_type == "enum" and not config.options:
+            raise ValueError("enum variables require non-empty options")
+
+        if variable_type == "int" and config.slider:
+            if config.min is None or config.max is None:
+                raise ValueError("slider variables require min and max")
+            if config.max < config.min:
+                raise ValueError("slider variables require max >= min")
+            if config.step is not None and config.step <= 0:
+                raise ValueError("slider variables require step > 0")
+
+        return config
+
+    def is_secret(self) -> bool:
+        return self.type == SECRET_TYPE
 
     def convert(self, value: Any) -> Any:
-        """Validate and convert a raw value based on the variable type.
-
-        This method performs type conversion but does NOT check if the value
-        is required. Use validate_and_convert() for full validation including
-        required field checks.
-        """
         if value is None:
             return None
 
-        # Treat empty strings as None to avoid storing "" for missing values.
         if isinstance(value, str) and value.strip() == "":
             return None
 
-        # Type conversion mapping for cleaner code
         converters = {
             "bool": self._convert_bool,
             "int": self._convert_int,
@@ -121,44 +385,14 @@ class Variable:
         if converter:
             return converter(value)
 
-        # Default to string conversion
         return str(value)
 
     def validate_and_convert(self, value: Any, check_required: bool = True) -> Any:
-        """Validate and convert a value with comprehensive checks.
-
-        This method combines type conversion with validation logic including
-        required field checks. It's the recommended method for user input validation.
-
-        Args:
-            value: The raw value to validate and convert
-            check_required: If True, raises ValueError for required fields with empty values
-
-        Returns:
-            The converted and validated value
-
-        Raises:
-            ValueError: If validation fails (invalid format, required field empty, etc.)
-
-        Examples:
-            # Basic validation
-            var.validate_and_convert("example@email.com")  # Returns validated email
-
-            # Required field validation
-            var.validate_and_convert("", check_required=True)  # Raises ValueError if required
-
-            # Autogenerated variables - allow empty values
-            var.validate_and_convert("", check_required=False)  # Returns None for autogeneration
-        """
-        # First, convert the value using standard type conversion
         converted = self.convert(value)
 
-        # Special handling for autogenerated variables
-        # Allow empty values as they will be auto-generated later
-        if self.autogenerated and (converted is None or (isinstance(converted, str) and (converted in {"", "*auto"}))):
-            return None  # Signal that auto-generation should happen
+        if self.autogenerated and (converted is None or (isinstance(converted, str) and converted in {"", "*auto"})):
+            return None
 
-        # Check if this is a required field and the value is empty
         if (
             check_required
             and self.is_required()
@@ -166,10 +400,12 @@ class Variable:
         ):
             raise ValueError("This field is required and cannot be empty")
 
+        if self.type == "int" and converted is not None and self.config.slider:
+            self._validate_slider_value(converted)
+
         return converted
 
     def _convert_bool(self, value: Any) -> bool:
-        """Convert value to boolean."""
         if isinstance(value, bool):
             return value
         if isinstance(value, str):
@@ -181,7 +417,8 @@ class Variable:
         raise ValueError("value must be a boolean (true/false)")
 
     def _convert_int(self, value: Any) -> int | None:
-        """Convert value to integer."""
+        if isinstance(value, bool):
+            raise ValueError("value must be an integer")
         if isinstance(value, int):
             return value
         if isinstance(value, str) and value.strip() == "":
@@ -192,9 +429,10 @@ class Variable:
             raise ValueError("value must be an integer") from exc
 
     def _convert_float(self, value: Any) -> float | None:
-        """Convert value to float."""
-        if isinstance(value, float):
-            return value
+        if isinstance(value, bool):
+            raise ValueError("value must be a float")
+        if isinstance(value, (int, float)):
+            return float(value)
         if isinstance(value, str) and value.strip() == "":
             return None
         try:
@@ -210,7 +448,7 @@ class Variable:
             raise ValueError(f"value must be one of: {', '.join(self.options)}")
         return val
 
-    def _convert_url(self, value: Any) -> str:
+    def _convert_url(self, value: Any) -> str | None:
         val = str(value).strip()
         if not val:
             return None
@@ -219,213 +457,241 @@ class Variable:
             raise ValueError("value must be a valid URL (include scheme and host)")
         return val
 
-    def _convert_email(self, value: Any) -> str:
+    def _convert_email(self, value: Any) -> str | None:
         val = str(value).strip()
         if not val:
             return None
         try:
-            # Validate email using RFC 5321/5322 compliant parser
             validated = validate_email(val, check_deliverability=False)
             return validated.normalized
         except EmailNotValidError as exc:
             raise ValueError(f"value must be a valid email address: {exc}") from exc
 
+    def _validate_slider_value(self, value: int) -> None:
+        if not self.config.slider:
+            return
+
+        min_value = self.config.min
+        max_value = self.config.max
+        if min_value is None or max_value is None:
+            return
+
+        if value < min_value:
+            raise ValueError(f"value must be at least {min_value}")
+        if value > max_value:
+            raise ValueError(f"value must be at most {max_value}")
+
+        step = self.config.step if self.config.step is not None else 1
+        if step > 0 and (value - min_value) % step != 0:
+            raise ValueError(f"value must align with step {step} starting at {min_value}")
+
     def to_dict(self) -> dict[str, Any]:
-        """Serialize Variable to a dictionary for storage."""
-        result = {}
+        result: dict[str, Any] = {}
 
-        # Always include type
         if self.type:
             result["type"] = self.type
 
-        # Include value/default if not None
         if self.value is not None:
             result["default"] = self.value
 
-        # Include string fields if truthy
         for field in ("description", "prompt", "extra", "origin"):
             if value := getattr(self, field):
                 result[field] = value
 
-        # Include boolean/list fields if truthy (but empty list is OK for options)
-        if self.sensitive:
-            result["sensitive"] = True
-        if self.autogenerated:
-            result["autogenerated"] = True
-            # Only include length if not default
-            if self.autogenerated_length != DEFAULT_AUTOGENERATED_LENGTH:
-                result["autogenerated_length"] = self.autogenerated_length
-            # Include base64 flag if enabled
-            if self.autogenerated_base64:
-                result["autogenerated_base64"] = True
         if self.required:
             result["required"] = True
-        if self.options is not None:  # Allow empty list
-            result["options"] = self.options
+        config_dict = self._serialize_config()
+        if config_dict:
+            result["config"] = config_dict
 
-        # Store dependencies (single value if only one, list otherwise)
         if self.needs:
             result["needs"] = self.needs[0] if len(self.needs) == 1 else self.needs
 
         return result
 
-    def get_display_value(self, mask_sensitive: bool = True, max_length: int = 30, show_none: bool = True) -> str:
-        """Get formatted display value with optional masking and truncation.
+    def _serialize_config(self) -> dict[str, Any]:
+        if not self.config or self.config.is_empty():
+            return {}
+
+        config_dict: dict[str, Any] = {}
+        value_fields = {
+            "placeholder": self.config.placeholder,
+            "unit": self.config.unit,
+            "options": self.config.options,
+        }
+        for field_name, field_value in value_fields.items():
+            if field_value:
+                config_dict[field_name] = field_value
+
+        if self.config.textarea:
+            config_dict["textarea"] = True
+        if self.config.slider:
+            config_dict["slider"] = True
+
+        for field_name in ("min", "max", "step"):
+            field_value = getattr(self.config, field_name)
+            if field_value is not None:
+                config_dict[field_name] = field_value
+
+        autogenerated_dict = self._serialize_autogenerated_config()
+        if autogenerated_dict is not None:
+            config_dict["autogenerated"] = autogenerated_dict
 
-        Args:
-            mask_sensitive: If True, mask sensitive values with asterisks
-            max_length: Maximum length before truncation (0 = no limit)
-            show_none: If True, display "(none)" for None values instead of empty string
+        return config_dict
 
-        Returns:
-            Formatted string representation of the value
-        """
+    def _serialize_autogenerated_config(self) -> bool | dict[str, Any] | None:
+        autogenerated = self.config.autogenerated
+        if not autogenerated:
+            return None
+        if self._is_default_character_autogenerated(autogenerated):
+            return True
+
+        autogenerated_dict = {"kind": autogenerated.kind}
+        for field_name in ("length", "characters", "bytes"):
+            field_value = getattr(autogenerated, field_name)
+            if field_value is not None:
+                autogenerated_dict[field_name] = field_value
+        return autogenerated_dict
+
+    @staticmethod
+    def _is_default_character_autogenerated(autogenerated: SecretAutogeneratedConfig) -> bool:
+        return (
+            autogenerated.kind == SECRET_AUTOGENERATED_KIND_CHARACTERS
+            and autogenerated.length is None
+            and autogenerated.characters is None
+            and autogenerated.bytes is None
+        )
+
+    def get_display_value(self, mask_secret: bool = True, max_length: int = 30, show_none: bool = True) -> str:
         if self.value is None or self.value == "":
-            # Show (*auto) for autogenerated variables instead of (none)
             if self.autogenerated:
                 return "[dim](*auto)[/dim]" if show_none else ""
             return "[dim](none)[/dim]" if show_none else ""
 
-        # Mask sensitive values
-        if self.sensitive and mask_sensitive:
+        if self.is_secret() and mask_secret:
             return "********"
 
-        # Convert to string
         display = str(self.value)
-
-        # Truncate if needed
         if max_length > 0 and len(display) > max_length:
             return display[: max_length - 3] + "..."
-
         return display
 
     def get_normalized_default(self) -> Any:
-        """Get normalized default value suitable for prompts and display."""
-        try:
-            typed = self.convert(self.value)
-        except Exception:
-            typed = self.value
-
-        # Autogenerated: return display hint
+        typed = self._coerce_default_value()
         if self.autogenerated and not typed:
             return "*auto"
+        normalizers = {
+            "enum": self._normalize_enum_default,
+            "bool": self._normalize_bool_default,
+            "int": lambda value: self._normalize_numeric_default(value, int),
+            "float": lambda value: self._normalize_numeric_default(value, float),
+        }
+        normalizer = normalizers.get(self.type, self._normalize_string_default)
+        return normalizer(typed)
 
-        # Type-specific handlers
-        if self.type == "enum":
-            return (
-                typed
-                if not self.options
-                else (self.options[0] if typed is None or str(typed) not in self.options else str(typed))
-            )
-
-        if self.type == "bool":
-            return typed if isinstance(typed, bool) else (None if typed is None else bool(typed))
+    def _coerce_default_value(self) -> Any:
+        try:
+            return self.convert(self.value)
+        except Exception:
+            return self.value
+
+    def _normalize_enum_default(self, typed: Any) -> Any:
+        if not self.options:
+            return typed
+        if typed is None or str(typed) not in self.options:
+            return self.options[0]
+        return str(typed)
+
+    @staticmethod
+    def _normalize_bool_default(typed: Any) -> bool | None:
+        if isinstance(typed, bool):
+            return typed
+        if typed is None:
+            return None
+        return bool(typed)
 
-        if self.type == "int":
-            try:
-                return int(typed) if typed not in (None, "") else None
-            except Exception:
-                return None
+    @staticmethod
+    def _normalize_numeric_default(typed: Any, caster) -> Any:
+        try:
+            return caster(typed) if typed not in (None, "") else None
+        except Exception:
+            return None
 
-        # Default: return string or None
+    @staticmethod
+    def _normalize_string_default(typed: Any) -> str | None:
         return None if typed is None else str(typed)
 
     def get_prompt_text(self) -> str:
-        """Get formatted prompt text for interactive input.
-
-        Returns:
-            Prompt text with optional type hints and descriptions
-        """
         prompt_text = self.prompt or self.description or self.name
-
-        # Add type hint for semantic types if there's a default
         if self.value is not None and self.type in ["email", "url"]:
             prompt_text += f" ({self.type})"
-
         return prompt_text
 
     def get_validation_hint(self) -> str | None:
-        """Get validation hint for prompts (e.g., enum options).
-
-        Returns:
-            Formatted hint string or None if no hint needed
-        """
         hints = []
 
-        # Add enum options
         if self.type == "enum" and self.options:
             hints.append(f"Options: {', '.join(self.options)}")
 
-        # Add extra help text
+        if self.type == "int" and self.config.slider and self.config.min is not None and self.config.max is not None:
+            slider_hint = f"Range: {self.config.min}..{self.config.max}"
+            step = self.config.step if self.config.step is not None else 1
+            if step != 1:
+                slider_hint += f", step {step}"
+            if self.config.unit:
+                slider_hint += f" {self.config.unit}"
+            hints.append(slider_hint)
+        elif self.type == "int" and self.config.unit:
+            hints.append(f"Unit: {self.config.unit}")
+
+        if self.autogenerated:
+            if self.autogenerated_base64:
+                bytes_value = (
+                    self.autogenerated_config.bytes_or_default()
+                    if self.autogenerated_config
+                    else DEFAULT_AUTOGENERATED_BYTES
+                )
+                hints.append(f"Auto-generated base64 secret ({bytes_value} bytes) if empty")
+            else:
+                length = (
+                    self.autogenerated_config.length_or_default()
+                    if self.autogenerated_config
+                    else DEFAULT_AUTOGENERATED_LENGTH
+                )
+                hints.append(f"Auto-generated secret (length {length}) if empty")
+
         if self.extra:
             hints.append(self.extra)
 
         return " — ".join(hints) if hints else None
 
     def is_required(self) -> bool:
-        """Check if this variable requires a value (cannot be empty/None).
-
-        A variable is considered required ONLY if it has an explicit 'required: true' flag.
-        All other variables are optional by default.
-
-        Returns:
-            True if the variable must have a non-empty value, False otherwise
-        """
-        # Only explicitly marked required variables are required
-        # Autogenerated variables can still be empty (will be generated later)
         return self.required and not self.autogenerated
 
     def get_parent(self) -> VariableSection | None:
-        """Get the parent VariableSection that contains this variable.
-
-        Returns:
-            The parent VariableSection if set, None otherwise
-        """
         return self.parent_section
 
     def clone(self, update: dict[str, Any] | None = None) -> Variable:
-        """Create a deep copy of the variable with optional field updates.
-
-        This is more efficient than converting to dict and back when copying variables.
-
-        Args:
-            update: Optional dictionary of field updates to apply to the clone
-
-        Returns:
-            New Variable instance with copied data
-
-        Example:
-            var2 = var1.clone(update={'origin': 'template'})
-        """
         data = {
             "name": self.name,
             "type": self.type,
             "value": self.value,
             "description": self.description,
             "prompt": self.prompt,
-            "options": self.options.copy() if self.options else None,
             "origin": self.origin,
-            "sensitive": self.sensitive,
             "extra": self.extra,
-            "autogenerated": self.autogenerated,
-            "autogenerated_length": self.autogenerated_length,
-            "autogenerated_base64": self.autogenerated_base64,
             "required": self.required,
             "original_value": self.original_value,
             "needs": self.needs.copy() if self.needs else None,
             "parent_section": self.parent_section,
+            "config": self.config.clone() if self.config else None,
         }
 
-        # Apply updates if provided
         if update:
             data.update(update)
 
-        # Create new variable
         cloned = Variable(data)
-
-        # Preserve explicit fields from original, and add any update keys
         cloned._explicit_fields = self._explicit_fields.copy()
         if update:
             cloned._explicit_fields.update(update.keys())
-
         return cloned

+ 11 - 12
cli/core/template/variable_collection.py

@@ -751,9 +751,9 @@ class VariableCollection:
 
         return satisfied_values
 
-    def get_sensitive_variables(self) -> dict[str, Any]:
-        """Get only the sensitive variables with their values."""
-        return {name: var.value for name, var in self._variable_map.items() if var.sensitive and var.value}
+    def get_secret_variables(self) -> dict[str, Any]:
+        """Get only secret variables with their current values."""
+        return {name: var.value for name, var in self._variable_map.items() if var.is_secret() and var.value}
 
     def apply_defaults(self, defaults: dict[str, Any], origin: str = "cli") -> list[str]:
         """Apply default values to variables, updating their origin.
@@ -1027,9 +1027,8 @@ class VariableCollection:
                     "type": other_var.type,
                     "description": other_var.description,
                     "prompt": other_var.prompt,
-                    "options": other_var.options,
-                    "sensitive": other_var.sensitive,
                     "extra": other_var.extra,
+                    "config": other_var.config.clone() if other_var.config else None,
                 }
 
                 # Add fields that were explicitly provided, even if falsy/empty
@@ -1039,7 +1038,7 @@ class VariableCollection:
 
                 # For boolean flags, only copy if explicitly provided in other
                 # This prevents False defaults from overriding True values
-                for bool_field in ("autogenerated", "required"):
+                for bool_field in ("required",):
                     if bool_field in other_var._explicit_fields:
                         update[bool_field] = getattr(other_var, bool_field)
 
@@ -1058,8 +1057,8 @@ class VariableCollection:
 
         return merged_section
 
-    def filter_to_used(self, used_variables: set[str], keep_sensitive: bool = True) -> VariableCollection:
-        """Filter collection to only variables that are used (or sensitive).
+    def filter_to_used(self, used_variables: set[str], keep_secret: bool = True) -> VariableCollection:
+        """Filter collection to only variables that are used (or secret).
 
         OPTIMIZED: Works directly on objects without dict conversions for better performance.
 
@@ -1068,7 +1067,7 @@ class VariableCollection:
 
         Args:
             used_variables: Set of variable names that are actually used
-            keep_sensitive: If True, also keep sensitive variables even if not in used set
+            keep_secret: If True, also keep secret variables even if not in used set
 
         Returns:
             New VariableCollection with filtered variables
@@ -1076,7 +1075,7 @@ class VariableCollection:
         Example:
             all_vars = VariableCollection(spec)
             used_vars = all_vars.filter_to_used({'var1', 'var2', 'var3'})
-            # Only var1, var2, var3 (and any sensitive vars) remain
+            # Only var1, var2, var3 (and any secret vars) remain
         """
         # Create new collection without calling __init__ (optimization)
         filtered = VariableCollection.__new__(VariableCollection)
@@ -1098,8 +1097,8 @@ class VariableCollection:
 
             # Clone only the variables that should be included
             for var_name, variable in section.variables.items():
-                # Include if used OR if sensitive (and keep_sensitive is True)
-                should_include = var_name in used_variables or (keep_sensitive and variable.sensitive)
+                # Include if used OR if secret (and keep_secret is True)
+                should_include = var_name in used_variables or (keep_secret and variable.is_secret())
 
                 if should_include:
                     filtered_section.variables[var_name] = variable.clone()

+ 3 - 2
cli/core/template/variable_section.py

@@ -137,9 +137,10 @@ class VariableSection:
 
     def _topological_sort(self, var_list: list[str], dependencies: dict[str, list[str]]) -> list[str]:
         """Perform topological sort using Kahn's algorithm."""
+        order = {name: index for index, name in enumerate(var_list)}
         in_degree = {var_name: len(deps) for var_name, deps in dependencies.items()}
         queue = [var for var, degree in in_degree.items() if degree == 0]
-        queue.sort(key=lambda v: var_list.index(v))
+        queue.sort(key=order.__getitem__)
         result = []
 
         while queue:
@@ -152,7 +153,7 @@ class VariableSection:
                     in_degree[var_name] -= 1
                     if in_degree[var_name] == 0:
                         queue.append(var_name)
-                        queue.sort(key=lambda v: var_list.index(v))
+                        queue.sort(key=order.__getitem__)
 
         # If not all variables were sorted (cycle), append remaining in original order
         if len(result) != len(var_list):

+ 1 - 75
cli/modules/ansible/__init__.py

@@ -1,79 +1,7 @@
-"""Ansible module with multi-schema support."""
-
-import logging
-from collections import OrderedDict
+"""Ansible module."""
 
 from ...core.module import Module
 from ...core.registry import registry
-from ...core.schema import has_schema, list_versions, load_schema
-
-logger = logging.getLogger(__name__)
-
-
-def _load_json_spec_as_dict(version: str) -> OrderedDict:
-    """Load JSON schema and convert to dict format for backward compatibility.
-
-    Args:
-        version: Schema version
-
-    Returns:
-        OrderedDict in the same format as Python specs
-    """
-    logger.debug(f"Loading ansible schema {version} from JSON")
-    json_spec = load_schema("ansible", version)
-
-    # Convert JSON array format to OrderedDict format
-    spec_dict = OrderedDict()
-    for section_data in json_spec:
-        section_key = section_data["key"]
-
-        # Build section dict
-        section_dict = {}
-        if "title" in section_data:
-            section_dict["title"] = section_data["title"]
-        if "description" in section_data:
-            section_dict["description"] = section_data["description"]
-        if "toggle" in section_data:
-            section_dict["toggle"] = section_data["toggle"]
-        if "required" in section_data:
-            section_dict["required"] = section_data["required"]
-        if "needs" in section_data:
-            section_dict["needs"] = section_data["needs"]
-
-        # Convert vars array to dict
-        vars_dict = OrderedDict()
-        for var_data in section_data["vars"]:
-            var_name = var_data["name"]
-            var_dict = {k: v for k, v in var_data.items() if k != "name"}
-            vars_dict[var_name] = var_dict
-
-        section_dict["vars"] = vars_dict
-        spec_dict[section_key] = section_dict
-
-    return spec_dict
-
-
-# Schema version mapping - loads JSON schemas on-demand
-class _SchemaDict(dict):
-    """Dict subclass that loads JSON schemas on-demand."""
-
-    def __getitem__(self, version):
-        if not has_schema("ansible", version):
-            raise KeyError(
-                f"Schema version {version} not found for ansible module. "
-                f"Available: {', '.join(list_versions('ansible'))}"
-            )
-        return _load_json_spec_as_dict(version)
-
-    def __contains__(self, version):
-        return has_schema("ansible", version)
-
-
-# Initialize schema dict
-SCHEMAS = _SchemaDict()
-
-# Default spec - load latest version
-spec = _load_json_spec_as_dict("1.0")
 
 
 class AnsibleModule(Module):
@@ -81,8 +9,6 @@ class AnsibleModule(Module):
 
     name = "ansible"
     description = "Manage Ansible configurations"
-    schema_version = "1.0"  # Current schema version supported by this module
-    schemas = SCHEMAS  # Available schema versions
 
 
 registry.register(AnsibleModule)

+ 3 - 89
cli/modules/compose/__init__.py

@@ -1,7 +1,6 @@
-"""Docker Compose module with multi-schema support."""
+"""Docker Compose module."""
 
 import logging
-from collections import OrderedDict
 from typing import Annotated
 
 from typer import Argument, Option
@@ -9,85 +8,16 @@ from typer import Argument, Option
 from ...core.module import Module
 from ...core.module.base_commands import validate_templates
 from ...core.registry import registry
-from ...core.schema import has_schema, list_versions, load_schema
 from .validate import run_docker_validation
 
 logger = logging.getLogger(__name__)
 
 
-def _load_json_spec_as_dict(version: str) -> OrderedDict:
-    """Load JSON schema and convert to dict format for backward compatibility.
-
-    Args:
-        version: Schema version
-
-    Returns:
-        OrderedDict in the same format as Python specs
-    """
-    logger.debug(f"Loading compose schema {version} from JSON")
-    json_spec = load_schema("compose", version)
-
-    # Convert JSON array format to OrderedDict format
-    spec_dict = OrderedDict()
-    for section_data in json_spec:
-        section_key = section_data["key"]
-
-        # Build section dict
-        section_dict = {}
-        if "title" in section_data:
-            section_dict["title"] = section_data["title"]
-        if "description" in section_data:
-            section_dict["description"] = section_data["description"]
-        if "toggle" in section_data:
-            section_dict["toggle"] = section_data["toggle"]
-        if "required" in section_data:
-            section_dict["required"] = section_data["required"]
-        if "needs" in section_data:
-            section_dict["needs"] = section_data["needs"]
-
-        # Convert vars array to dict
-        vars_dict = OrderedDict()
-        for var_data in section_data["vars"]:
-            var_name = var_data["name"]
-            var_dict = {k: v for k, v in var_data.items() if k != "name"}
-            vars_dict[var_name] = var_dict
-
-        section_dict["vars"] = vars_dict
-        spec_dict[section_key] = section_dict
-
-    return spec_dict
-
-
-# Schema version mapping - loads JSON schemas on-demand
-class _SchemaDict(dict):
-    """Dict subclass that loads JSON schemas on-demand."""
-
-    def __getitem__(self, version):
-        if not has_schema("compose", version):
-            raise KeyError(
-                f"Schema version {version} not found for compose module. "
-                f"Available: {', '.join(list_versions('compose'))}"
-            )
-        return _load_json_spec_as_dict(version)
-
-    def __contains__(self, version):
-        return has_schema("compose", version)
-
-
-# Initialize schema dict
-SCHEMAS = _SchemaDict()
-
-# Default spec - load latest version
-spec = _load_json_spec_as_dict("1.2")
-
-
 class ComposeModule(Module):
     """Docker Compose module with extended validation."""
 
     name = "compose"
     description = "Manage Docker Compose configurations"
-    schema_version = "1.2"  # Current schema version supported by this module
-    schemas = SCHEMAS  # Available schema versions
 
     def validate(  # noqa: PLR0913
         self,
@@ -105,7 +35,7 @@ class ComposeModule(Module):
             bool,
             Option(
                 "--semantic/--no-semantic",
-                help="Enable semantic validation (Docker Compose schema, etc.)",
+                help="Enable semantic validation (Docker Compose config, YAML structure, etc.)",
             ),
         ] = True,
         docker: Annotated[
@@ -123,25 +53,9 @@ class ComposeModule(Module):
             ),
         ] = False,
     ) -> None:
-        """Validate templates for Jinja2 syntax, undefined variables, and semantic correctness.
-
-        Extended for Docker Compose with optional docker compose config validation.
-        Use --docker for single config test, --docker-test-all for comprehensive testing.
-
-        Examples:
-            # Validate specific template
-            compose validate netbox
-
-            # Validate all templates
-            compose validate
-
-            # Validate with Docker Compose config check
-            compose validate netbox --docker
-        """
-        # Run standard validation first
+        """Validate Compose templates."""
         validate_templates(self, template_id, path, verbose, semantic)
 
-        # If docker validation is enabled and we have a specific template
         if docker and (template_id or path):
             run_docker_validation(self, template_id, path, docker_test_all, verbose)
 

+ 1 - 74
cli/modules/helm/__init__.py

@@ -1,78 +1,7 @@
-"""Helm module with multi-schema support."""
-
-import logging
-from collections import OrderedDict
+"""Helm module."""
 
 from ...core.module import Module
 from ...core.registry import registry
-from ...core.schema import has_schema, list_versions, load_schema
-
-logger = logging.getLogger(__name__)
-
-
-def _load_json_spec_as_dict(version: str) -> OrderedDict:
-    """Load JSON schema and convert to dict format for backward compatibility.
-
-    Args:
-        version: Schema version
-
-    Returns:
-        OrderedDict in the same format as Python specs
-    """
-    logger.debug(f"Loading helm schema {version} from JSON")
-    json_spec = load_schema("helm", version)
-
-    # Convert JSON array format to OrderedDict format
-    spec_dict = OrderedDict()
-    for section_data in json_spec:
-        section_key = section_data["key"]
-
-        # Build section dict
-        section_dict = {}
-        if "title" in section_data:
-            section_dict["title"] = section_data["title"]
-        if "description" in section_data:
-            section_dict["description"] = section_data["description"]
-        if "toggle" in section_data:
-            section_dict["toggle"] = section_data["toggle"]
-        if "required" in section_data:
-            section_dict["required"] = section_data["required"]
-        if "needs" in section_data:
-            section_dict["needs"] = section_data["needs"]
-
-        # Convert vars array to dict
-        vars_dict = OrderedDict()
-        for var_data in section_data["vars"]:
-            var_name = var_data["name"]
-            var_dict = {k: v for k, v in var_data.items() if k != "name"}
-            vars_dict[var_name] = var_dict
-
-        section_dict["vars"] = vars_dict
-        spec_dict[section_key] = section_dict
-
-    return spec_dict
-
-
-# Schema version mapping - loads JSON schemas on-demand
-class _SchemaDict(dict):
-    """Dict subclass that loads JSON schemas on-demand."""
-
-    def __getitem__(self, version):
-        if not has_schema("helm", version):
-            raise KeyError(
-                f"Schema version {version} not found for helm module. Available: {', '.join(list_versions('helm'))}"
-            )
-        return _load_json_spec_as_dict(version)
-
-    def __contains__(self, version):
-        return has_schema("helm", version)
-
-
-# Initialize schema dict
-SCHEMAS = _SchemaDict()
-
-# Default spec - load latest version
-spec = _load_json_spec_as_dict("1.0")
 
 
 class HelmModule(Module):
@@ -80,8 +9,6 @@ class HelmModule(Module):
 
     name = "helm"
     description = "Manage Helm configurations"
-    schema_version = "1.0"  # Current schema version supported by this module
-    schemas = SCHEMAS  # Available schema versions
 
 
 registry.register(HelmModule)

+ 1 - 75
cli/modules/kubernetes/__init__.py

@@ -1,79 +1,7 @@
-"""Kubernetes module with multi-schema support."""
-
-import logging
-from collections import OrderedDict
+"""Kubernetes module."""
 
 from ...core.module import Module
 from ...core.registry import registry
-from ...core.schema import has_schema, list_versions, load_schema
-
-logger = logging.getLogger(__name__)
-
-
-def _load_json_spec_as_dict(version: str) -> OrderedDict:
-    """Load JSON schema and convert to dict format for backward compatibility.
-
-    Args:
-        version: Schema version
-
-    Returns:
-        OrderedDict in the same format as Python specs
-    """
-    logger.debug(f"Loading kubernetes schema {version} from JSON")
-    json_spec = load_schema("kubernetes", version)
-
-    # Convert JSON array format to OrderedDict format
-    spec_dict = OrderedDict()
-    for section_data in json_spec:
-        section_key = section_data["key"]
-
-        # Build section dict
-        section_dict = {}
-        if "title" in section_data:
-            section_dict["title"] = section_data["title"]
-        if "description" in section_data:
-            section_dict["description"] = section_data["description"]
-        if "toggle" in section_data:
-            section_dict["toggle"] = section_data["toggle"]
-        if "required" in section_data:
-            section_dict["required"] = section_data["required"]
-        if "needs" in section_data:
-            section_dict["needs"] = section_data["needs"]
-
-        # Convert vars array to dict
-        vars_dict = OrderedDict()
-        for var_data in section_data["vars"]:
-            var_name = var_data["name"]
-            var_dict = {k: v for k, v in var_data.items() if k != "name"}
-            vars_dict[var_name] = var_dict
-
-        section_dict["vars"] = vars_dict
-        spec_dict[section_key] = section_dict
-
-    return spec_dict
-
-
-# Schema version mapping - loads JSON schemas on-demand
-class _SchemaDict(dict):
-    """Dict subclass that loads JSON schemas on-demand."""
-
-    def __getitem__(self, version):
-        if not has_schema("kubernetes", version):
-            raise KeyError(
-                f"Schema version {version} not found for kubernetes module. "
-                f"Available: {', '.join(list_versions('kubernetes'))}"
-            )
-        return _load_json_spec_as_dict(version)
-
-    def __contains__(self, version):
-        return has_schema("kubernetes", version)
-
-
-# Initialize schema dict
-SCHEMAS = _SchemaDict()
-
-# Default spec - load latest version
-spec = _load_json_spec_as_dict("1.0")
 
 
 class KubernetesModule(Module):
@@ -81,8 +9,6 @@ class KubernetesModule(Module):
 
     name = "kubernetes"
     description = "Manage Kubernetes configurations"
-    schema_version = "1.0"  # Current schema version supported by this module
-    schemas = SCHEMAS  # Available schema versions
 
 
 registry.register(KubernetesModule)

+ 1 - 74
cli/modules/packer/__init__.py

@@ -1,78 +1,7 @@
-"""Packer module with multi-schema support."""
-
-import logging
-from collections import OrderedDict
+"""Packer module."""
 
 from ...core.module import Module
 from ...core.registry import registry
-from ...core.schema import has_schema, list_versions, load_schema
-
-logger = logging.getLogger(__name__)
-
-
-def _load_json_spec_as_dict(version: str) -> OrderedDict:
-    """Load JSON schema and convert to dict format for backward compatibility.
-
-    Args:
-        version: Schema version
-
-    Returns:
-        OrderedDict in the same format as Python specs
-    """
-    logger.debug(f"Loading packer schema {version} from JSON")
-    json_spec = load_schema("packer", version)
-
-    # Convert JSON array format to OrderedDict format
-    spec_dict = OrderedDict()
-    for section_data in json_spec:
-        section_key = section_data["key"]
-
-        # Build section dict
-        section_dict = {}
-        if "title" in section_data:
-            section_dict["title"] = section_data["title"]
-        if "description" in section_data:
-            section_dict["description"] = section_data["description"]
-        if "toggle" in section_data:
-            section_dict["toggle"] = section_data["toggle"]
-        if "required" in section_data:
-            section_dict["required"] = section_data["required"]
-        if "needs" in section_data:
-            section_dict["needs"] = section_data["needs"]
-
-        # Convert vars array to dict
-        vars_dict = OrderedDict()
-        for var_data in section_data["vars"]:
-            var_name = var_data["name"]
-            var_dict = {k: v for k, v in var_data.items() if k != "name"}
-            vars_dict[var_name] = var_dict
-
-        section_dict["vars"] = vars_dict
-        spec_dict[section_key] = section_dict
-
-    return spec_dict
-
-
-# Schema version mapping - loads JSON schemas on-demand
-class _SchemaDict(dict):
-    """Dict subclass that loads JSON schemas on-demand."""
-
-    def __getitem__(self, version):
-        if not has_schema("packer", version):
-            raise KeyError(
-                f"Schema version {version} not found for packer module. Available: {', '.join(list_versions('packer'))}"
-            )
-        return _load_json_spec_as_dict(version)
-
-    def __contains__(self, version):
-        return has_schema("packer", version)
-
-
-# Initialize schema dict
-SCHEMAS = _SchemaDict()
-
-# Default spec - load latest version
-spec = _load_json_spec_as_dict("1.0")
 
 
 class PackerModule(Module):
@@ -80,8 +9,6 @@ class PackerModule(Module):
 
     name = "packer"
     description = "Manage Packer configurations"
-    schema_version = "1.0"  # Current schema version supported by this module
-    schemas = SCHEMAS  # Available schema versions
 
 
 registry.register(PackerModule)

+ 63 - 0
cli/modules/swarm/__init__.py

@@ -0,0 +1,63 @@
+"""Docker Swarm module with compose-compatible validation."""
+
+import logging
+from typing import Annotated
+
+from typer import Argument, Option
+
+from ...core.module import Module
+from ...core.module.base_commands import validate_templates
+from ...core.registry import registry
+from ..compose.validate import run_docker_validation
+
+logger = logging.getLogger(__name__)
+
+
+class SwarmModule(Module):
+    """Docker Swarm module."""
+
+    name = "swarm"
+    description = "Manage Docker Swarm stack templates"
+
+    def validate(  # noqa: PLR0913
+        self,
+        template_id: Annotated[
+            str | None,
+            Argument(help="Template ID to validate (omit to validate all templates)"),
+        ] = None,
+        *,
+        path: Annotated[
+            str | None,
+            Option("--path", help="Path to template directory for validation"),
+        ] = None,
+        verbose: Annotated[bool, Option("--verbose", "-v", help="Show detailed validation information")] = False,
+        semantic: Annotated[
+            bool,
+            Option(
+                "--semantic/--no-semantic",
+                help="Enable semantic validation (Docker Compose config, YAML structure, etc.)",
+            ),
+        ] = True,
+        docker: Annotated[
+            bool,
+            Option(
+                "--docker/--no-docker",
+                help="Enable Docker Compose validation using 'docker compose config'",
+            ),
+        ] = False,
+        docker_test_all: Annotated[
+            bool,
+            Option(
+                "--docker-test-all",
+                help="Test all variable combinations (minimal, maximal, each toggle). Requires --docker",
+            ),
+        ] = False,
+    ) -> None:
+        """Validate Swarm templates."""
+        validate_templates(self, template_id, path, verbose, semantic)
+
+        if docker and (template_id or path):
+            run_docker_validation(self, template_id, path, docker_test_all, verbose)
+
+
+registry.register(SwarmModule)

+ 1 - 75
cli/modules/terraform/__init__.py

@@ -1,79 +1,7 @@
-"""Terraform module with multi-schema support."""
-
-import logging
-from collections import OrderedDict
+"""Terraform module."""
 
 from ...core.module import Module
 from ...core.registry import registry
-from ...core.schema import has_schema, list_versions, load_schema
-
-logger = logging.getLogger(__name__)
-
-
-def _load_json_spec_as_dict(version: str) -> OrderedDict:
-    """Load JSON schema and convert to dict format for backward compatibility.
-
-    Args:
-        version: Schema version
-
-    Returns:
-        OrderedDict in the same format as Python specs
-    """
-    logger.debug(f"Loading terraform schema {version} from JSON")
-    json_spec = load_schema("terraform", version)
-
-    # Convert JSON array format to OrderedDict format
-    spec_dict = OrderedDict()
-    for section_data in json_spec:
-        section_key = section_data["key"]
-
-        # Build section dict
-        section_dict = {}
-        if "title" in section_data:
-            section_dict["title"] = section_data["title"]
-        if "description" in section_data:
-            section_dict["description"] = section_data["description"]
-        if "toggle" in section_data:
-            section_dict["toggle"] = section_data["toggle"]
-        if "required" in section_data:
-            section_dict["required"] = section_data["required"]
-        if "needs" in section_data:
-            section_dict["needs"] = section_data["needs"]
-
-        # Convert vars array to dict
-        vars_dict = OrderedDict()
-        for var_data in section_data["vars"]:
-            var_name = var_data["name"]
-            var_dict = {k: v for k, v in var_data.items() if k != "name"}
-            vars_dict[var_name] = var_dict
-
-        section_dict["vars"] = vars_dict
-        spec_dict[section_key] = section_dict
-
-    return spec_dict
-
-
-# Schema version mapping - loads JSON schemas on-demand
-class _SchemaDict(dict):
-    """Dict subclass that loads JSON schemas on-demand."""
-
-    def __getitem__(self, version):
-        if not has_schema("terraform", version):
-            raise KeyError(
-                f"Schema version {version} not found for terraform module. "
-                f"Available: {', '.join(list_versions('terraform'))}"
-            )
-        return _load_json_spec_as_dict(version)
-
-    def __contains__(self, version):
-        return has_schema("terraform", version)
-
-
-# Initialize schema dict
-SCHEMAS = _SchemaDict()
-
-# Default spec - load latest version
-spec = _load_json_spec_as_dict("1.0")
 
 
 class TerraformModule(Module):
@@ -81,8 +9,6 @@ class TerraformModule(Module):
 
     name = "terraform"
     description = "Manage Terraform configurations"
-    schema_version = "1.0"  # Current schema version supported by this module
-    schemas = SCHEMAS  # Available schema versions
 
 
 registry.register(TerraformModule)

+ 2 - 4
library/ansible/checkmk-install-agent/template.yaml

@@ -15,8 +15,6 @@ metadata:
     provider: selfh
     id: checkmk
   draft: false
-  next_steps: ""
-schema: "1.0"
 spec:
   checkmk:
     title: Checkmk Configuration
@@ -49,10 +47,10 @@ spec:
         description: Checkmk Automation User
         required: true
       checkmk_pass:
-        type: str
+        type: secret
         description: Checkmk Automation User Password
         required: true
-        sensitive: true
+
       checkmk_host:
         type: str
         description: Checkmk Host Name

+ 2 - 4
library/ansible/checkmk-manage-host/template.yaml

@@ -15,8 +15,6 @@ metadata:
     provider: selfh
     id: checkmk
   draft: false
-  next_steps: ""
-schema: "1.0"
 spec:
   checkmk:
     title: Checkmk Configuration
@@ -43,10 +41,10 @@ spec:
         description: Checkmk Automation User
         required: true
       checkmk_pass:
-        type: str
+        type: secret
         description: Checkmk Automation User Password
         required: true
-        sensitive: true
+
   host:
     title: Host Configuration
     vars:

+ 0 - 1
library/ansible/docker-certs-enable/template.yaml

@@ -1,6 +1,5 @@
 ---
 kind: ansible
-schema: "1.0"
 metadata:
   icon:
     provider: selfh

+ 0 - 1
library/ansible/docker-certs/template.yaml

@@ -1,6 +1,5 @@
 ---
 kind: ansible
-schema: "1.0"
 metadata:
   icon:
     provider: selfh

+ 0 - 1
library/ansible/docker-install-ubuntu/template.yaml

@@ -1,6 +1,5 @@
 ---
 kind: ansible
-schema: "1.0"
 metadata:
   icon:
     provider: selfh

+ 0 - 1
library/ansible/docker-prune/template.yaml

@@ -1,6 +1,5 @@
 ---
 kind: ansible
-schema: "1.0"
 metadata:
   icon:
     provider: selfh

+ 0 - 1
library/ansible/ubuntu-add-sshkey/template.yaml

@@ -1,6 +1,5 @@
 ---
 kind: ansible
-schema: "1.0"
 metadata:
   icon:
     provider: selfh

+ 0 - 1
library/ansible/ubuntu-apt-update/template.yaml

@@ -1,6 +1,5 @@
 ---
 kind: ansible
-schema: "1.0"
 metadata:
   icon:
     provider: selfh

+ 0 - 1
library/ansible/ubuntu-vm-core/template.yaml

@@ -1,6 +1,5 @@
 ---
 kind: ansible
-schema: "1.0"
 metadata:
   icon:
     provider: selfh

+ 0 - 2
library/compose/adguardhome/template.yaml

@@ -27,9 +27,7 @@ metadata:
     - traefik
     - network
     - volume
-  next_steps:
   draft: true
-schema: 1.2
 spec:
   general:
     vars:

+ 0 - 2
library/compose/alloy/template.yaml

@@ -19,8 +19,6 @@ metadata:
   icon:
     provider: selfh
     id: grafana
-  next_steps:
-schema: 1.2
 spec:
   general:
     vars:

+ 4 - 12
library/compose/authentik/template.yaml

@@ -20,19 +20,12 @@ metadata:
   icon:
     provider: selfh
     id: authentik
-  next_steps: |-
-    Log in with your initial admin user:
-    ```bash
-    Username: akadmin
-    Password: {{ authentik_admin_password }}
-    ```
   version: 2025.10.3
   author: Christian Lempa
   date: '2025-12-16'
   tags:
     - traefik
     - volume
-schema: "1.2"
 spec:
   general:
     vars:
@@ -61,14 +54,13 @@ spec:
       authentik_secret_key:
         description: Secret Key
         extra: Used for cookie signing and unique user IDs
-        type: str
-        sensitive: true
+        type: secret
         required: true
       authentik_admin_password:
         description: Initial admin user password
-        type: str
-        sensitive: true
-        autogenerated: true
+        type: secret
+        config:
+          autogenerated: true
       authentik_error_reporting:
         description: Enable error reporting to Authentik developers
         type: bool

+ 3 - 4
library/compose/bind9/template.yaml

@@ -19,7 +19,6 @@ metadata:
     provider: selfh
     id: bind-9
   draft: true
-schema: "1.2"
 spec:
   dns_security:
     title: dns_security
@@ -39,9 +38,9 @@ spec:
         type: bool
       tsig_key_secret:
         description: TSIG key secret
-        type: str
-        sensitive: true
-        autogenerated: true
+        type: secret
+        config:
+          autogenerated: true
         needs: [tsig_enabled=true]
   general:
     vars:

+ 4 - 10
library/compose/checkmk/template.yaml

@@ -14,28 +14,22 @@ metadata:
     * **Project:** https://checkmk.com/
     * **Documentation:** https://docs.checkmk.com/latest/en/
     * **GitHub:** https://github.com/tribe29/checkmk
-  next_steps: |-
-    Log in with your initial admin user:
-    ```bash
-    Username: cmkadmin
-    Password: {{ cmk_password }}
-    ```
   version: 2.4.0-latest
   author: Christian Lempa
   date: '2025-12-10'
   tags:
     - traefik
-schema: 1.2
 spec:
   general:
     vars:
       service_name:
         default: checkmk
       cmk_password:
-        type: str
+        type: secret
         description: CheckMK admin password
-        sensitive: true
-        autogenerated: true
+
+        config:
+          autogenerated: true
         required: true
       cmk_site_id:
         type: str

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

@@ -17,7 +17,6 @@ metadata:
   date: '2025-09-28'
   tags:
     - traefik
-schema: 1.2
 spec:
   general:
     vars:

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

@@ -22,7 +22,6 @@ metadata:
   date: '2025-12-10'
   tags:
     - traefik
-schema: 1.2
 spec:
   general:
     vars:

+ 0 - 2
library/compose/gitlab-runner/template.yaml

@@ -17,6 +17,4 @@ metadata:
     provider: selfh
     id: gitlab
   draft: true
-  next_steps: ""
-schema: "1.2"
 spec: {}

+ 8 - 37
library/compose/gitlab/template.yaml

@@ -1,6 +1,5 @@
 ---
 kind: compose
-schema: "1.2"
 metadata:
   name: GitLab
   description: |
@@ -30,36 +29,6 @@ metadata:
   tags:
     - traefik
     - swarm
-  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 }}
-       {% if not traefik_enabled and network_mode == 'bridge' %}- Direct access: http://localhost:{{ ports_http }}{% endif %}
-       {%- else -%}
-       - 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/
   draft: true
 spec:
   general:
@@ -75,10 +44,11 @@ spec:
         description: Initial root user email address
         default: admin@example.com
       root_password:
-        type: str
+        type: secret
         description: Initial root user password (only used on first initialization)
-        sensitive: true
-        autogenerated: true
+
+        config:
+          autogenerated: true
         extra: "Leave empty to auto-generate. WARNING: Only sets password on FIRST startup!"
       default_theme:
         type: int
@@ -134,9 +104,10 @@ spec:
       performance_preset:
         type: enum
         description: Performance optimization profile
-        options:
-          - homelab
-          - default
+        config:
+          options:
+            - homelab
+            - default
         default: homelab
         extra: homelab is optimized for low-resource environments, default is for standard servers
       prometheus_enabled:

+ 4 - 10
library/compose/grafana/template.yaml

@@ -12,19 +12,12 @@ metadata:
   icon:
     provider: selfh
     id: grafana
-  next_steps: |-
-    Log in with the initial admin user:
-    ```bash
-    Username: admin
-    Password: admin
-    ```
   version: 12.3.1
   author: Christian Lempa
   date: '2025-12-16'
   tags:
     - traefik
     - authentik
-schema: 1.2
 spec:
   general:
     vars:
@@ -47,9 +40,10 @@ spec:
   database:
     vars:
       database_type:
-        options:
-          - sqlite
-          - postgres
+        config:
+          options:
+            - sqlite
+            - postgres
       database_name:
         default: grafana
       database_user:

+ 0 - 2
library/compose/homeassistant/template.yaml

@@ -16,8 +16,6 @@ metadata:
     provider: selfh
     id: home-assistant
   draft: true
-  next_steps: ""
-schema: "1.2"
 spec:
   general:
     vars:

+ 0 - 2
library/compose/homepage/template.yaml

@@ -20,8 +20,6 @@ metadata:
     provider: simpleicons
     id: homepage
   draft: true
-  next_steps: ""
-schema: "1.2"
 spec:
   general:
     vars:

+ 0 - 29
library/compose/homer/template.yaml

@@ -1,6 +1,5 @@
 ---
 kind: compose
-schema: "1.2"
 metadata:
   icon:
     provider: selfh
@@ -21,34 +20,6 @@ metadata:
     - swarm
     - authentik
   draft: true
-  next_steps: |
-    1. Start the Homer dashboard:
-       docker compose up -d
-
-    2. Customize your dashboard:
-       - Edit assets/config.yml to add your services
-       - Organize services into groups (Applications, Monitoring, etc.)
-       - Add links to the navbar for quick access
-
-    3. Optional: Add a logo:
-       - Place your logo.png file in the assets/ directory
-       - Or update the logo path in assets/config.yml
-       - Supported formats: PNG, SVG, JPG
-
-    4. Optional: Customize the theme:
-       - Uncomment and modify the colors section in config.yml
-       - Available themes: default, sui
-       - See documentation for advanced theming options
-
-    5. Access your dashboard:
-       {% if traefik_enabled -%}
-       - Via Traefik: https://{{ traefik_host }}
-       {% if not traefik_enabled and network_mode == 'bridge' %}- Direct access: http://localhost:{{ ports_http }}{% endif %}
-       {%- else -%}
-       - Open http://localhost:{{ ports_http }} in your browser
-       {%- endif %}
-
-    For more information, visit: https://github.com/bastienwirtz/homer/blob/main/docs/configuration.md
 spec:
   general:
     vars:

+ 3 - 10
library/compose/influxdb/template.yaml

@@ -12,19 +12,12 @@ metadata:
     * **Project:** https://www.influxdata.com/
     * **Documentation:** https://docs.influxdata.com/influxdb/
     * **GitHub:** https://github.com/influxdata/influxdb
-  next_steps: |-
-    Log in with your initial admin user:
-    ```bash
-    Username: {{ influxdb_init_username }}
-    Password: {{ influxdb_init_password }}
-    ```
   version: 2.8.0-alpine
   author: Christian Lempa
   date: '2025-12-11'
   tags:
     - traefik
   draft: true
-schema: "1.2"
 spec:
   ports:
     vars:
@@ -43,9 +36,9 @@ spec:
         required: true
       influxdb_init_password:
         description: "Initial admin password"
-        type: str
-        sensitive: true
-        autogenerated: true
+        type: secret
+        config:
+          autogenerated: true
         required: true
   general:
     vars:

+ 8 - 40
library/compose/komodo/template.yaml

@@ -1,6 +1,5 @@
 ---
 kind: compose
-schema: "1.2"
 metadata:
   icon:
     provider: selfh
@@ -28,39 +27,6 @@ metadata:
     - swarm
     - deployment
     - automation
-  next_steps: |
-    ### 1. Prerequisites
-    * Deploy MongoDB or FerretDB database
-    * Configure database connection in environment variables
-    * Install Periphery agent on servers you want to manage
-    ### 2. Deploy the Service
-    {% if swarm_enabled -%}
-    Deploy to Docker Swarm:
-    ```bash
-    docker stack deploy -c compose.yaml komodo
-    ```
-    {% else -%}
-    Start Komodo using Docker Compose:
-    ```bash
-    docker compose up -d
-    ```
-    {% endif -%}
-    ### 3. Access the Web Interface
-    {% if traefik_enabled -%}
-    * Navigate to: **https://{{ traefik_host }}.{{ traefik_domain }}**
-    {% else -%}
-    * Navigate to: **http://localhost:{{ ports_http }}**
-    {% endif -%}
-    * Complete initial setup and create admin user
-    ### 4. Install Periphery Agent
-    On each server you want to manage:
-    ```bash
-    curl -sSL https://raw.githubusercontent.com/moghtech/komodo/main/scripts/setup-periphery.py | python3
-    ```
-    ### 5. Configure Servers
-    * Add servers to Komodo through the web interface
-    * Configure API keys for programmatic access
-    * Start managing deployments, stacks, and builds
 spec:
   general:
     vars:
@@ -114,21 +80,23 @@ spec:
         description: "Database username (optional)"
         needs: "environment_enabled=true"
       environment_database_password:
-        type: str
+        type: secret
         default: ""
-        sensitive: true
+
         description: "Database password (optional)"
         needs: "environment_enabled=true"
       environment_jwt_secret:
-        type: str
+        type: secret
         default: ""
-        sensitive: true
-        autogenerated: true
+
+        config:
+          autogenerated: true
         description: "JWT secret for authentication (auto-generated if empty)"
         needs: "environment_enabled=true"
       environment_log_level:
         type: enum
         default: "info"
-        options: ["debug", "info", "warn", "error"]
+        config:
+          options: ["debug", "info", "warn", "error"]
         description: "Log level"
         needs: "environment_enabled=true"

+ 8 - 40
library/compose/komodo/template.yaml.backup

@@ -1,6 +1,5 @@
 ---
 kind: compose
-schema: "1.2"
 metadata:
   name: Komodo
   description: |
@@ -24,39 +23,6 @@ metadata:
     - swarm
     - deployment
     - automation
-  next_steps: |
-    ### 1. Prerequisites
-    * Deploy MongoDB or FerretDB database
-    * Configure database connection in environment variables
-    * Install Periphery agent on servers you want to manage
-    ### 2. Deploy the Service
-    {% if swarm_enabled -%}
-    Deploy to Docker Swarm:
-    ```bash
-    docker stack deploy -c compose.yaml komodo
-    ```
-    {% else -%}
-    Start Komodo using Docker Compose:
-    ```bash
-    docker compose up -d
-    ```
-    {% endif -%}
-    ### 3. Access the Web Interface
-    {% if traefik_enabled -%}
-    * Navigate to: **https://{{ traefik_host }}.{{ traefik_domain }}**
-    {% else -%}
-    * Navigate to: **http://localhost:{{ ports_http }}**
-    {% endif -%}
-    * Complete initial setup and create admin user
-    ### 4. Install Periphery Agent
-    On each server you want to manage:
-    ```bash
-    curl -sSL https://raw.githubusercontent.com/moghtech/komodo/main/scripts/setup-periphery.py | python3
-    ```
-    ### 5. Configure Servers
-    * Add servers to Komodo through the web interface
-    * Configure API keys for programmatic access
-    * Start managing deployments, stacks, and builds
 spec:
   general:
     vars:
@@ -110,21 +76,23 @@ spec:
         description: "Database username (optional)"
         needs: "environment_enabled=true"
       environment_database_password:
-        type: str
+        type: secret
         default: ""
-        sensitive: true
+
         description: "Database password (optional)"
         needs: "environment_enabled=true"
       environment_jwt_secret:
-        type: str
+        type: secret
         default: ""
-        sensitive: true
-        autogenerated: true
+
+        config:
+          autogenerated: true
         description: "JWT secret for authentication (auto-generated if empty)"
         needs: "environment_enabled=true"
       environment_log_level:
         type: enum
         default: "info"
-        options: ["debug", "info", "warn", "error"]
+        config:
+          options: ["debug", "info", "warn", "error"]
         description: "Log level"
         needs: "environment_enabled=true"

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

@@ -18,7 +18,6 @@ metadata:
   tags:
     - traefik
     - authentik
-schema: 1.2
 spec:
   general:
     vars:

+ 0 - 7
library/compose/mariadb/template.yaml

@@ -12,18 +12,11 @@ metadata:
     * **Project:** https://mariadb.org/
     * **Documentation:** https://mariadb.com/kb/en/documentation/
     * **GitHub:** https://github.com/MariaDB/server
-  next_steps: |-
-    Log in with your initial admin user:
-    ```bash
-    Username: `root` or `{{ database_user }}`
-    Password: {{ database_password }}
-    ```
   version: 12.0.2
   author: Christian Lempa
   date: '2025-09-28'
   tags: []
   draft: true
-schema: 1.2
 spec:
   general:
     vars:

+ 18 - 15
library/compose/n8n/template.yaml

@@ -30,7 +30,6 @@ metadata:
     - traefik
     - database
   draft: true
-schema: 1.2
 spec:
   general:
     vars:
@@ -83,9 +82,10 @@ spec:
       database_type:
         type: enum
         description: "Database type"
-        options:
-          - "postgres"
-          - "mysql"
+        config:
+          options:
+            - "postgres"
+            - "mysql"
         default: "postgres"
         needs: "database_enabled"
       database_host:
@@ -109,18 +109,19 @@ spec:
         default: "n8n"
         needs: "database_enabled"
       database_password:
-        type: str
+        type: secret
         description: "Database password"
-        sensitive: true
+
         needs: "database_enabled"
   security:
     title: "Security"
     vars:
       encryption_key:
-        type: str
+        type: secret
         description: "N8N encryption key for credentials"
-        sensitive: true
-        autogenerated: true
+
+        config:
+          autogenerated: true
         extra: "Keep this secure! Used to encrypt stored credentials."
       proxy_hops:
         type: int
@@ -155,16 +156,18 @@ spec:
       execution_save_on_error:
         type: enum
         description: "Save execution data on error"
-        options:
-          - "all"
-          - "none"
+        config:
+          options:
+            - "all"
+            - "none"
         default: "all"
       execution_save_on_success:
         type: enum
         description: "Save execution data on success"
-        options:
-          - "all"
-          - "none"
+        config:
+          options:
+            - "all"
+            - "none"
         default: "none"
         extra: "Set to 'none' to reduce database size"
   network:

+ 8 - 14
library/compose/netbox/template.yaml

@@ -21,13 +21,6 @@ metadata:
     provider: selfh
     id: netbox
   draft: false
-  next_steps: |-
-    Log in with your initial admin user:
-    ```bash
-    Username: admin
-    Password: admin
-    ```
-schema: 1.2
 spec:
   database:
     vars:
@@ -37,9 +30,9 @@ spec:
         default: netbox
       redis_password:
         description: Redis password for authentication
-        type: str
-        sensitive: true
-        autogenerated: true
+        type: secret
+        config:
+          autogenerated: true
         required: true
   general:
     vars:
@@ -55,10 +48,11 @@ spec:
         default: false
       netbox_secret_key:
         description: Secret Key
-        type: str
-        sensitive: true
-        autogenerated: true
-        autogenerated_length: 50
+        type: secret
+        config:
+          autogenerated:
+            kind: characters
+            length: 50
         required: true
         extra: Used for cryptographic signing and session management
   ports:

+ 5 - 5
library/compose/nextcloud/template.yaml

@@ -1,6 +1,5 @@
 ---
 kind: compose
-schema: "1.2"
 metadata:
   name: Nextcloud
   description: >
@@ -30,7 +29,8 @@ spec:
       database_type:
         description: "Database type (Nextcloud supports PostgreSQL or MySQL/MariaDB)"
         type: enum
-        options: ["postgres", "mysql"]
+        config:
+          options: ["postgres", "mysql"]
         default: "postgres"
   network:
     vars:
@@ -54,7 +54,7 @@ spec:
         default: "admin"
       admin_password:
         description: "Nextcloud admin password"
-        type: str
-        sensitive: true
-        autogenerated: true
+        type: secret
+        config:
+          autogenerated: true
         default: ""

+ 4 - 3
library/compose/nginx/template.yaml

@@ -1,6 +1,5 @@
 ---
 kind: compose
-schema: "1.2"
 metadata:
   icon:
     provider: selfh
@@ -29,7 +28,8 @@ spec:
         default: "nginx"
       restart_policy:
         type: enum
-        options: ["unless-stopped", "always", "on-failure", "no"]
+        config:
+          options: ["unless-stopped", "always", "on-failure", "no"]
         default: "unless-stopped"
       container_name:
         default: "nginx"
@@ -69,7 +69,8 @@ spec:
     vars:
       network_mode:
         type: enum
-        options: ["bridge", "host", "macvlan"]
+        config:
+          options: ["bridge", "host", "macvlan"]
         default: "bridge"
       network_name:
         default: "bridge"

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

@@ -1,6 +1,5 @@
 ---
 kind: compose
-schema: "1.2"
 metadata:
   icon:
     provider: selfh

+ 2 - 31
library/compose/pangolin/template.yaml

@@ -1,6 +1,5 @@
 ---
 kind: compose
-schema: "1.2"
 metadata:
   icon:
     provider: selfh
@@ -24,35 +23,6 @@ metadata:
     - proxy
     - wireguard
   draft: true
-  next_steps: |
-    ### 1. Configure Database
-    {% if postgres_enabled -%}
-    Make sure PostgreSQL is running and accessible at:
-    * Connection string: {{ postgres_connection_string }}
-    {% else -%}
-    Pangolin will use SQLite database stored in the data volume.
-    {% endif -%}
-    ### 2. Deploy the Service
-    {% if swarm_enabled -%}
-    Deploy to Docker Swarm:
-    ```bash
-    docker stack deploy -c compose.yaml pangolin
-    ```
-    {% else -%}
-    Start Pangolin using Docker Compose:
-    ```bash
-    docker compose up -d
-    ```
-    {% endif -%}
-    ### 3. Access the Web Interface
-    {% if traefik_enabled -%}
-    * Navigate to: **https://{{ traefik_host }}.{{ traefik_domain }}**
-    {% else -%}
-    * Navigate to: **http://localhost:{{ ports_http }}**
-    {% endif -%}
-    ### 4. Configure WireGuard Clients
-    * Use the Pangolin web interface to create and manage WireGuard tunnels
-    * Deploy Newt client on remote machines to establish secure connections
 spec:
   general:
     vars:
@@ -112,6 +82,7 @@ spec:
       environment_log_level:
         type: enum
         default: "info"
-        options: ["debug", "info", "warn", "error"]
+        config:
+          options: ["debug", "info", "warn", "error"]
         description: "Log level"
         needs: "environment_enabled=true"

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

@@ -19,7 +19,6 @@ metadata:
     - traefik
     - database
   draft: true
-schema: 1.2
 spec:
   general:
     vars:

+ 3 - 10
library/compose/pihole/template.yaml

@@ -1,6 +1,5 @@
 ---
 kind: compose
-schema: "1.2"
 metadata:
   icon:
     provider: selfh
@@ -25,12 +24,6 @@ metadata:
     - swarm
     - network
     - volume
-  next_steps: |-
-    Log in with your initial admin user:
-    ```bash
-    Username: admin
-    Password: {{ webpassword }}
-    ```
 spec:
   general:
     vars:
@@ -44,9 +37,9 @@ spec:
     vars:
       webpassword:
         description: "Web interface admin password"
-        type: str
-        sensitive: true
-        autogenerated: true
+        type: secret
+        config:
+          autogenerated: true
   ports:
     vars:
       ports_http:

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

@@ -20,7 +20,6 @@ metadata:
     - traefik
     - swarm
     - volumes
-schema: 1.2
 spec:
   general:
     vars:

+ 5 - 5
library/compose/postgres/template.yaml

@@ -1,6 +1,5 @@
 ---
 kind: compose
-schema: "1.2"
 metadata:
   icon:
     provider: selfh
@@ -59,10 +58,11 @@ spec:
         description: Volume mounting mode (local, mount, nfs)
         type: str
         default: "local"
-        options:
-          - local
-          - mount
-          - nfs
+        config:
+          options:
+            - local
+            - mount
+            - nfs
       volume_mount_path:
         description: Path for bind mounts when volume_mode is 'mount'
         type: str

+ 0 - 17
library/compose/prometheus/template.yaml

@@ -1,6 +1,5 @@
 ---
 kind: compose
-schema: "1.2"
 metadata:
   icon:
     provider: selfh
@@ -26,22 +25,6 @@ metadata:
     - traefik
     - swarm
     - authentik
-  next_steps: |
-    {% if swarm_enabled -%}
-    1. Deploy to Docker Swarm:
-       docker stack deploy -c compose.yaml {{ service_name }}
-    2. Access Prometheus:
-       {%- if traefik_enabled %} https://{{ traefik_host }}
-       {%- else %} http://<swarm-node-ip>:{{ ports_http }}{%- endif %}
-    {% else -%}
-    1. Start Prometheus with Docker Compose:
-       docker compose up -d
-    2. Access Prometheus:
-       {%- if traefik_enabled %} https://{{ traefik_host }}
-       {%- else %} http://localhost:{{ ports_http }}{%- endif %}
-    {% endif -%}
-    3. Edit config/prometheus.yaml to add scrape targets
-    4. Reload configuration: docker exec {{ container_name if not swarm_enabled else service_name }} kill -HUP 1
 spec:
   general:
     vars:

+ 17 - 14
library/compose/renovate/template.yaml

@@ -1,6 +1,5 @@
 ---
 kind: compose
-schema: "1.2"
 metadata:
   icon:
     provider: selfh
@@ -36,7 +35,8 @@ spec:
         default: "renovate"
       restart_policy:
         type: enum
-        options: ["unless-stopped", "always", "on-failure", "no"]
+        config:
+          options: ["unless-stopped", "always", "on-failure", "no"]
         default: "unless-stopped"
       container_name:
         default: "renovate"
@@ -51,10 +51,11 @@ spec:
       renovate_platform:
         type: "enum"
         description: "Git Platform Type"
-        options:
-          - "gitlab"
-          - "github"
-          - "gitea"
+        config:
+          options:
+            - "gitlab"
+            - "github"
+            - "gitea"
         default: "gitlab"
       renovate_endpoint:
         type: "url"
@@ -71,19 +72,19 @@ spec:
     required: true
     vars:
       git_token:
-        type: "str"
+        type: "secret"
         description: "Git platform Personal Access Token"
-        sensitive: true
+
         extra: "Also used for public package lookups to avoid rate limiting"
       license_key:
-        type: "str"
+        type: "secret"
         description: "Mend Renovate CE License Key"
-        sensitive: true
+
         extra: "Get a FREE license key at https://www.mend.io/mend-renovate-community/#self-hosted"
       webhook_secret:
-        type: "str"
+        type: "secret"
         description: "Webhook secret for platform integration"
-        sensitive: true
+
         optional: true
         default: "renovate"
   ports:
@@ -116,7 +117,8 @@ spec:
     vars:
       network_mode:
         type: enum
-        options: ["bridge", "host", "macvlan"]
+        config:
+          options: ["bridge", "host", "macvlan"]
         default: "bridge"
       network_name:
         default: "bridge"
@@ -127,7 +129,8 @@ spec:
         default: false
       swarm_placement_mode:
         type: enum
-        options: ["replicated", "global"]
+        config:
+          options: ["replicated", "global"]
         default: "replicated"
       swarm_replicas:
         type: int

+ 10 - 16
library/compose/semaphoreui/template.yaml

@@ -24,13 +24,6 @@ metadata:
   tags:
     - traefik
     - database
-  next_steps: |-
-    Log in with your initial admin user:
-    ```bash
-    Username: {{ admin_user }}
-    Password: {{ admin_pass }}
-    ```
-schema: 1.2
 spec:
   general:
     vars:
@@ -40,9 +33,9 @@ spec:
         default: semaphoreui
       secret_key:
         description: "Secret key for encrypting access keys"
-        type: str
-        sensitive: true
-        autogenerated: true
+        type: secret
+        config:
+          autogenerated: true
         required: true
       admin_user:
         description: "Administrator username"
@@ -61,9 +54,9 @@ spec:
         default: admin@home.arpa
       admin_pass:
         description: "Administrator password"
-        type: str
-        sensitive: true
-        autogenerated: true
+        type: secret
+        config:
+          autogenerated: true
         required: true
       ansible_host_key_checking:
         description: "Enable Ansible SSH host key checking"
@@ -79,9 +72,10 @@ spec:
   database:
     vars:
       database_type:
-        options:
-          - postgres
-          - mysql
+        config:
+          options:
+            - postgres
+            - mysql
         default: mysql
       database_name:
         default: semaphore

+ 6 - 17
library/compose/traefik/template.yaml

@@ -19,17 +19,6 @@ metadata:
     provider: simpleicons
     id: traefikproxy
   draft: false
-  next_steps: |-
-    Start the `{{ service_name }}` project
-    {% if swarm_enabled %}
-    1. Deploy Traefik to Docker Swarm:
-      `docker stack deploy -c docker-compose.yaml {{ service_name }}`
-    {% else %}
-    1. Copy the project directory for `{{ service_name }}` to the host.
-    2. Start Traefik with Docker Compose from the project directory:
-      `docker compose up -d`
-    {% endif %}
-schema: "1.2"
 spec:
   general:
     vars:
@@ -97,8 +86,7 @@ spec:
         needs: [traefik_tls_certresolver=azure]
       traefik_tls_acme_secret_key:
         description: DNS provider secret key
-        type: str
-        sensitive: true
+        type: secret
         required: true
         needs: ['traefik_tls_certresolver=azure,godaddy,porkbun,route53']
         extra: AZURE_CLIENT_SECRET, GODADDY_API_SECRET, PORKBUN_SECRET_API_KEY, or AWS_SECRET_ACCESS_KEY
@@ -114,8 +102,7 @@ spec:
         needs: [traefik_tls_certresolver=azure]
       traefik_tls_acme_token:
         description: DNS provider API token
-        type: str
-        sensitive: true
+        type: secret
         required: true
         needs: ['traefik_tls_certresolver=cloudflare,digitalocean,godaddy,namecheap,porkbun']
         extra: CF_DNS_API_TOKEN, DO_AUTH_TOKEN, GODADDY_API_KEY, NAMECHEAP_API_KEY, or PORKBUN_API_KEY
@@ -126,7 +113,8 @@ spec:
         needs: [traefik_tls_certresolver=namecheap]
       traefik_tls_certresolver:
         description: ACME DNS challenge provider
-        options: [cloudflare, porkbun, godaddy, digitalocean, route53, azure, namecheap]
+        config:
+          options: [cloudflare, porkbun, godaddy, digitalocean, route53, azure, namecheap]
         extra: DNS provider for domain validation
       traefik_tls_enabled:
         description: Enable HTTPS/TLS with ACME
@@ -134,7 +122,8 @@ spec:
       traefik_tls_min_version:
         description: Minimum TLS version
         type: enum
-        options: [VersionTLS12, VersionTLS13]
+        config:
+          options: [VersionTLS12, VersionTLS13]
         extra: TLS 1.2 is recommended for compatibility, TLS 1.3 for maximum security
       traefik_tls_redirect:
         description: Redirect all HTTP traffic to HTTPS

Niektóre pliki nie zostały wyświetlone z powodu dużej ilości zmienionych plików