diff --git a/bin/disinto b/bin/disinto
index ed4c2b5..17d5e3b 100755
--- a/bin/disinto
+++ b/bin/disinto
@@ -27,6 +27,7 @@ FACTORY_ROOT="$(cd "$(dirname "$0")/.." && pwd)"
source "${FACTORY_ROOT}/lib/env.sh"
source "${FACTORY_ROOT}/lib/ops-setup.sh"
source "${FACTORY_ROOT}/lib/hire-agent.sh"
+source "${FACTORY_ROOT}/lib/generators.sh"
# ── Helpers ──────────────────────────────────────────────────────────────────
@@ -175,400 +176,35 @@ write_secrets_encrypted() {
FORGEJO_DATA_DIR="${HOME}/.disinto/forgejo"
# Generate docker-compose.yml in the factory root.
+# (Implementation in lib/generators.sh)
generate_compose() {
- local forge_port="${1:-3000}"
- local compose_file="${FACTORY_ROOT}/docker-compose.yml"
-
- # Check if compose file already exists
- if [ -f "$compose_file" ]; then
- echo "Compose: ${compose_file} (already exists, skipping)"
- return 0
- fi
-
- cat > "$compose_file" <<'COMPOSEEOF'
-# docker-compose.yml — generated by disinto init
-# Brings up Forgejo, Woodpecker, and the agent runtime.
-
-services:
- forgejo:
- image: codeberg.org/forgejo/forgejo:11.0
- restart: unless-stopped
- security_opt:
- - apparmor=unconfined
- volumes:
- - forgejo-data:/data
- environment:
- FORGEJO__database__DB_TYPE: sqlite3
- FORGEJO__server__ROOT_URL: http://forgejo:3000/
- FORGEJO__server__HTTP_PORT: "3000"
- FORGEJO__security__INSTALL_LOCK: "true"
- FORGEJO__service__DISABLE_REGISTRATION: "true"
- FORGEJO__webhook__ALLOWED_HOST_LIST: "private"
- networks:
- - disinto-net
-
- woodpecker:
- image: woodpeckerci/woodpecker-server:v3
- restart: unless-stopped
- security_opt:
- - apparmor=unconfined
- ports:
- - "8000:8000"
- - "9000:9000"
- volumes:
- - woodpecker-data:/var/lib/woodpecker
- environment:
- WOODPECKER_FORGEJO: "true"
- WOODPECKER_FORGEJO_URL: http://forgejo:3000
- WOODPECKER_FORGEJO_CLIENT: ${WP_FORGEJO_CLIENT:-}
- WOODPECKER_FORGEJO_SECRET: ${WP_FORGEJO_SECRET:-}
- WOODPECKER_HOST: ${WOODPECKER_HOST:-http://woodpecker:8000}
- WOODPECKER_OPEN: "true"
- WOODPECKER_AGENT_SECRET: ${WOODPECKER_AGENT_SECRET:-}
- WOODPECKER_DATABASE_DRIVER: sqlite3
- WOODPECKER_DATABASE_DATASOURCE: /var/lib/woodpecker/woodpecker.sqlite
- depends_on:
- - forgejo
- networks:
- - disinto-net
-
- woodpecker-agent:
- image: woodpeckerci/woodpecker-agent:v3
- restart: unless-stopped
- network_mode: host
- privileged: true
- volumes:
- - /var/run/docker.sock:/var/run/docker.sock
- environment:
- WOODPECKER_SERVER: localhost:9000
- WOODPECKER_AGENT_SECRET: ${WOODPECKER_AGENT_SECRET:-}
- WOODPECKER_GRPC_SECURE: "false"
- WOODPECKER_HEALTHCHECK_ADDR: ":3333"
- WOODPECKER_BACKEND_DOCKER_NETWORK: disinto_disinto-net
- WOODPECKER_MAX_WORKFLOWS: 1
- depends_on:
- - woodpecker
-
- agents:
- build:
- context: .
- dockerfile: docker/agents/Dockerfile
- restart: unless-stopped
- security_opt:
- - apparmor=unconfined
- volumes:
- - agent-data:/home/agent/data
- - project-repos:/home/agent/repos
- - ${HOME}/.claude:/home/agent/.claude
- - ${HOME}/.claude.json:/home/agent/.claude.json:ro
- - CLAUDE_BIN_PLACEHOLDER:/usr/local/bin/claude:ro
- - ${HOME}/.ssh:/home/agent/.ssh:ro
- - ${HOME}/.config/sops/age:/home/agent/.config/sops/age:ro
- - woodpecker-data:/woodpecker-data:ro
- environment:
- FORGE_URL: http://forgejo:3000
- WOODPECKER_SERVER: http://woodpecker:8000
- DISINTO_CONTAINER: "1"
- PROJECT_REPO_ROOT: /home/agent/repos/${PROJECT_NAME:-project}
- WOODPECKER_DATA_DIR: /woodpecker-data
- env_file:
- - .env
- # IMPORTANT: agents get .env only (forge tokens, CI tokens, config).
- # Vault-only secrets (GITHUB_TOKEN, CLAWHUB_TOKEN, deploy keys) live in
- # .env.vault.enc and are NEVER injected here — only the runner
- # container receives them at fire time (AD-006, #745).
- depends_on:
- - forgejo
- - woodpecker
- networks:
- - disinto-net
-
- runner:
- build:
- context: .
- dockerfile: docker/agents/Dockerfile
- profiles: ["vault"]
- security_opt:
- - apparmor=unconfined
- volumes:
- - agent-data:/home/agent/data
- environment:
- FORGE_URL: http://forgejo:3000
- DISINTO_CONTAINER: "1"
- PROJECT_REPO_ROOT: /home/agent/repos/${PROJECT_NAME:-project}
- # Vault redesign in progress (PR-based approval, see #73-#77)
- # This container is being replaced — entrypoint will be updated in follow-up
- networks:
- - disinto-net
-
- # Edge proxy — reverse proxy to Forgejo, Woodpecker, and staging
- # Serves on ports 80/443, routes based on path
- edge:
- build: ./docker/edge
- ports:
- - "80:80"
- - "443:443"
- environment:
- - DISINTO_VERSION=${DISINTO_VERSION:-main}
- - FORGE_URL=http://forgejo:3000
- - FORGE_REPO=${FORGE_REPO:-disinto-admin/disinto}
- - FORGE_OPS_REPO=${FORGE_OPS_REPO:-disinto-admin/disinto-ops}
- - FORGE_TOKEN=${FORGE_TOKEN:-}
- - FORGE_ADMIN_USERS=${FORGE_ADMIN_USERS:-disinto-admin}
- - FORGE_ADMIN_TOKEN=${FORGE_ADMIN_TOKEN:-}
- - OPS_REPO_ROOT=/opt/disinto-ops
- - PROJECT_REPO_ROOT=/opt/disinto
- - PRIMARY_BRANCH=main
- volumes:
- - ./docker/Caddyfile:/etc/caddy/Caddyfile
- - caddy_data:/data
- - /var/run/docker.sock:/var/run/docker.sock
- depends_on:
- - forgejo
- - woodpecker
- - staging
- networks:
- - disinto-net
-
- # Staging container — static file server for staging artifacts
- # Edge proxy routes to this container for default requests
- staging:
- image: caddy:alpine
- command: ["caddy", "file-server", "--root", "/srv/site"]
- volumes:
- - ./docker:/srv/site:ro
- networks:
- - disinto-net
-
- # Staging deployment slot — activated by Woodpecker staging pipeline (#755).
- # Profile-gated: only starts when explicitly targeted by deploy commands.
- # Customize image/ports/volumes for your project after init.
- staging-deploy:
- image: alpine:3
- profiles: ["staging"]
- security_opt:
- - apparmor=unconfined
- environment:
- DEPLOY_ENV: staging
- networks:
- - disinto-net
- command: ["echo", "staging slot — replace with project image"]
-
-volumes:
- forgejo-data:
- woodpecker-data:
- agent-data:
- project-repos:
- caddy_data:
-
-networks:
- disinto-net:
- driver: bridge
-COMPOSEEOF
-
- # Patch the Claude CLI binary path — resolve from host PATH at init time.
- local claude_bin
- claude_bin="$(command -v claude 2>/dev/null || true)"
- if [ -n "$claude_bin" ]; then
- # Resolve symlinks to get the real binary path
- claude_bin="$(readlink -f "$claude_bin")"
- sed -i "s|CLAUDE_BIN_PLACEHOLDER|${claude_bin}|" "$compose_file"
- else
- echo "Warning: claude CLI not found in PATH — update docker-compose.yml volumes manually" >&2
- sed -i "s|CLAUDE_BIN_PLACEHOLDER|/usr/local/bin/claude|" "$compose_file"
- fi
-
- # Patch the forgejo port mapping into the file if non-default
- if [ "$forge_port" != "3000" ]; then
- # Add port mapping to forgejo service so it's reachable from host during init
- sed -i "/image: codeberg\.org\/forgejo\/forgejo:11\.0/a\\ ports:\\n - \"${forge_port}:3000\"" "$compose_file"
- else
- sed -i "/image: codeberg\.org\/forgejo\/forgejo:11\.0/a\\ ports:\\n - \"3000:3000\"" "$compose_file"
- fi
-
- echo "Created: ${compose_file}"
+ _generate_compose_impl "$@"
}
# Generate docker/agents/ files if they don't already exist.
+# (Implementation in lib/generators.sh)
generate_agent_docker() {
- local docker_dir="${FACTORY_ROOT}/docker/agents"
- mkdir -p "$docker_dir"
-
- if [ ! -f "${docker_dir}/Dockerfile" ]; then
- echo "Warning: docker/agents/Dockerfile not found — expected in repo" >&2
- fi
- if [ ! -f "${docker_dir}/entrypoint.sh" ]; then
- echo "Warning: docker/agents/entrypoint.sh not found — expected in repo" >&2
- fi
+ _generate_agent_docker_impl "$@"
}
# Generate docker/Caddyfile template for edge proxy.
+# (Implementation in lib/generators.sh)
generate_caddyfile() {
- local docker_dir="${FACTORY_ROOT}/docker"
- local caddyfile="${docker_dir}/Caddyfile"
-
- if [ -f "$caddyfile" ]; then
- echo "Caddyfile: ${caddyfile} (already exists, skipping)"
- return
- fi
-
- cat > "$caddyfile" <<'CADDYFILEEOF'
-# Caddyfile — edge proxy configuration
-# IP-only binding at bootstrap; domain + TLS added later via vault resource request
-
-:80 {
- # Reverse proxy to Forgejo
- handle /forgejo/* {
- reverse_proxy forgejo:3000
- }
-
- # Reverse proxy to Woodpecker CI
- handle /ci/* {
- reverse_proxy woodpecker:8000
- }
-
- # Default: proxy to staging container
- handle {
- reverse_proxy staging:80
- }
-}
-CADDYFILEEOF
-
- echo "Created: ${caddyfile}"
+ _generate_caddyfile_impl "$@"
}
# Generate docker/index.html default page.
+# (Implementation in lib/generators.sh)
generate_staging_index() {
- local docker_dir="${FACTORY_ROOT}/docker"
- local index_file="${docker_dir}/index.html"
-
- if [ -f "$index_file" ]; then
- echo "Staging: ${index_file} (already exists, skipping)"
- return
- fi
-
- cat > "$index_file" <<'INDEXEOF'
-
-
-
-
-
- Nothing shipped yet
-
-
-
-
-
Nothing shipped yet
-
CI pipelines will update this page with your staging artifacts.
-
-
-
-INDEXEOF
-
- echo "Created: ${index_file}"
+ _generate_staging_index_impl "$@"
}
# Generate template .woodpecker/ deployment pipeline configs in a project repo.
# Creates staging.yml and production.yml alongside the project's existing CI config.
# These pipelines trigger on Woodpecker's deployment event with environment filters.
+# (Implementation in lib/generators.sh)
generate_deploy_pipelines() {
- local repo_root="$1" project_name="$2"
- local wp_dir="${repo_root}/.woodpecker"
-
- mkdir -p "$wp_dir"
-
- # Skip if deploy pipelines already exist
- if [ -f "${wp_dir}/staging.yml" ] && [ -f "${wp_dir}/production.yml" ]; then
- echo "Deploy: .woodpecker/{staging,production}.yml (already exist)"
- return
- fi
-
- if [ ! -f "${wp_dir}/staging.yml" ]; then
- cat > "${wp_dir}/staging.yml" <<'STAGINGEOF'
-# .woodpecker/staging.yml — Staging deployment pipeline
-# Triggered by runner via Woodpecker promote API.
-# Human approves promotion in vault → runner calls promote → this runs.
-
-when:
- event: deployment
- environment: staging
-
-steps:
- - name: deploy-staging
- image: docker:27
- commands:
- - echo "Deploying to staging environment..."
- - echo "Pipeline ${CI_PIPELINE_NUMBER} promoted from CI #${CI_PIPELINE_PARENT}"
- # Pull the image built by CI and deploy to staging
- # Customize these commands for your project:
- # - docker compose -f docker-compose.yml --profile staging up -d
- - echo "Staging deployment complete"
-
- - name: verify-staging
- image: alpine:3
- commands:
- - echo "Verifying staging deployment..."
- # Add health checks, smoke tests, or integration tests here:
- # - curl -sf http://staging:8080/health || exit 1
- - echo "Staging verification complete"
-STAGINGEOF
- echo "Created: ${wp_dir}/staging.yml"
- fi
-
- if [ ! -f "${wp_dir}/production.yml" ]; then
- cat > "${wp_dir}/production.yml" <<'PRODUCTIONEOF'
-# .woodpecker/production.yml — Production deployment pipeline
-# Triggered by runner via Woodpecker promote API.
-# Human approves promotion in vault → runner calls promote → this runs.
-
-when:
- event: deployment
- environment: production
-
-steps:
- - name: deploy-production
- image: docker:27
- commands:
- - echo "Deploying to production environment..."
- - echo "Pipeline ${CI_PIPELINE_NUMBER} promoted from staging"
- # Pull the verified image and deploy to production
- # Customize these commands for your project:
- # - docker compose -f docker-compose.yml up -d
- - echo "Production deployment complete"
-
- - name: verify-production
- image: alpine:3
- commands:
- - echo "Verifying production deployment..."
- # Add production health checks here:
- # - curl -sf http://production:8080/health || exit 1
- - echo "Production verification complete"
-PRODUCTIONEOF
- echo "Created: ${wp_dir}/production.yml"
- fi
+ _generate_deploy_pipelines_impl "$@"
}
# Check whether compose mode is active (docker-compose.yml exists).
diff --git a/lib/generators.sh b/lib/generators.sh
new file mode 100644
index 0000000..4ce686a
--- /dev/null
+++ b/lib/generators.sh
@@ -0,0 +1,424 @@
+#!/usr/bin/env bash
+# =============================================================================
+# generators — template generation functions for disinto init
+#
+# Generates docker-compose.yml, Dockerfile, Caddyfile, staging index, and
+# deployment pipeline configs.
+#
+# Globals expected (must be set before sourcing):
+# FACTORY_ROOT - Root of the disinto factory
+# PROJECT_NAME - Project name for the project repo
+# PRIMARY_BRANCH - Primary branch name (defaults to main)
+#
+# Usage:
+# source "${FACTORY_ROOT}/lib/generators.sh"
+# generate_compose "$forge_port"
+# generate_caddyfile
+# generate_staging_index
+# generate_deploy_pipelines "$repo_root" "$project_name"
+# =============================================================================
+set -euo pipefail
+
+# Assert required globals are set
+: "${FACTORY_ROOT:?FACTORY_ROOT must be set}"
+: "${PROJECT_NAME:?PROJECT_NAME must be set}"
+: "${PRIMARY_BRANCH:-main}"
+
+# Generate docker-compose.yml in the factory root.
+_generate_compose_impl() {
+ local forge_port="${1:-3000}"
+ local compose_file="${FACTORY_ROOT}/docker-compose.yml"
+
+ # Check if compose file already exists
+ if [ -f "$compose_file" ]; then
+ echo "Compose: ${compose_file} (already exists, skipping)"
+ return 0
+ fi
+
+ cat > "$compose_file" <<'COMPOSEEOF'
+# docker-compose.yml — generated by disinto init
+# Brings up Forgejo, Woodpecker, and the agent runtime.
+
+services:
+ forgejo:
+ image: codeberg.org/forgejo/forgejo:11.0
+ restart: unless-stopped
+ security_opt:
+ - apparmor=unconfined
+ volumes:
+ - forgejo-data:/data
+ environment:
+ FORGEJO__database__DB_TYPE: sqlite3
+ FORGEJO__server__ROOT_URL: http://forgejo:3000/
+ FORGEJO__server__HTTP_PORT: "3000"
+ FORGEJO__security__INSTALL_LOCK: "true"
+ FORGEJO__service__DISABLE_REGISTRATION: "true"
+ FORGEJO__webhook__ALLOWED_HOST_LIST: "private"
+ networks:
+ - disinto-net
+
+ woodpecker:
+ image: woodpeckerci/woodpecker-server:v3
+ restart: unless-stopped
+ security_opt:
+ - apparmor=unconfined
+ ports:
+ - "8000:8000"
+ - "9000:9000"
+ volumes:
+ - woodpecker-data:/var/lib/woodpecker
+ environment:
+ WOODPECKER_FORGEJO: "true"
+ WOODPECKER_FORGEJO_URL: http://forgejo:3000
+ WOODPECKER_FORGEJO_CLIENT: ${WP_FORGEJO_CLIENT:-}
+ WOODPECKER_FORGEJO_SECRET: ${WP_FORGEJO_SECRET:-}
+ WOODPECKER_HOST: ${WOODPECKER_HOST:-http://woodpecker:8000}
+ WOODPECKER_OPEN: "true"
+ WOODPECKER_AGENT_SECRET: ${WOODPECKER_AGENT_SECRET:-}
+ WOODPECKER_DATABASE_DRIVER: sqlite3
+ WOODPECKER_DATABASE_DATASOURCE: /var/lib/woodpecker/woodpecker.sqlite
+ depends_on:
+ - forgejo
+ networks:
+ - disinto-net
+
+ woodpecker-agent:
+ image: woodpeckerci/woodpecker-agent:v3
+ restart: unless-stopped
+ network_mode: host
+ privileged: true
+ volumes:
+ - /var/run/docker.sock:/var/run/docker.sock
+ environment:
+ WOODPECKER_SERVER: localhost:9000
+ WOODPECKER_AGENT_SECRET: ${WOODPECKER_AGENT_SECRET:-}
+ WOODPECKER_GRPC_SECURE: "false"
+ WOODPECKER_HEALTHCHECK_ADDR: ":3333"
+ WOODPECKER_BACKEND_DOCKER_NETWORK: disinto_disinto-net
+ WOODPECKER_MAX_WORKFLOWS: 1
+ depends_on:
+ - woodpecker
+
+ agents:
+ build:
+ context: .
+ dockerfile: docker/agents/Dockerfile
+ restart: unless-stopped
+ security_opt:
+ - apparmor=unconfined
+ volumes:
+ - agent-data:/home/agent/data
+ - project-repos:/home/agent/repos
+ - ${HOME}/.claude:/home/agent/.claude
+ - ${HOME}/.claude.json:/home/agent/.claude.json:ro
+ - CLAUDE_BIN_PLACEHOLDER:/usr/local/bin/claude:ro
+ - ${HOME}/.ssh:/home/agent/.ssh:ro
+ - ${HOME}/.config/sops/age:/home/agent/.config/sops/age:ro
+ - woodpecker-data:/woodpecker-data:ro
+ environment:
+ FORGE_URL: http://forgejo:3000
+ WOODPECKER_SERVER: http://woodpecker:8000
+ DISINTO_CONTAINER: "1"
+ PROJECT_REPO_ROOT: /home/agent/repos/${PROJECT_NAME:-project}
+ WOODPECKER_DATA_DIR: /woodpecker-data
+ env_file:
+ - .env
+ # IMPORTANT: agents get .env only (forge tokens, CI tokens, config).
+ # Vault-only secrets (GITHUB_TOKEN, CLAWHUB_TOKEN, deploy keys) live in
+ # .env.vault.enc and are NEVER injected here — only the runner
+ # container receives them at fire time (AD-006, #745).
+ depends_on:
+ - forgejo
+ - woodpecker
+ networks:
+ - disinto-net
+
+ runner:
+ build:
+ context: .
+ dockerfile: docker/agents/Dockerfile
+ profiles: ["vault"]
+ security_opt:
+ - apparmor=unconfined
+ volumes:
+ - agent-data:/home/agent/data
+ environment:
+ FORGE_URL: http://forgejo:3000
+ DISINTO_CONTAINER: "1"
+ PROJECT_REPO_ROOT: /home/agent/repos/${PROJECT_NAME:-project}
+ # Vault redesign in progress (PR-based approval, see #73-#77)
+ # This container is being replaced — entrypoint will be updated in follow-up
+ networks:
+ - disinto-net
+
+ # Edge proxy — reverse proxy to Forgejo, Woodpecker, and staging
+ # Serves on ports 80/443, routes based on path
+ edge:
+ build: ./docker/edge
+ ports:
+ - "80:80"
+ - "443:443"
+ environment:
+ - DISINTO_VERSION=${DISINTO_VERSION:-main}
+ - FORGE_URL=http://forgejo:3000
+ - FORGE_REPO=${FORGE_REPO:-disinto-admin/disinto}
+ - FORGE_OPS_REPO=${FORGE_OPS_REPO:-disinto-admin/disinto-ops}
+ - FORGE_TOKEN=${FORGE_TOKEN:-}
+ - FORGE_ADMIN_USERS=${FORGE_ADMIN_USERS:-disinto-admin}
+ - FORGE_ADMIN_TOKEN=${FORGE_ADMIN_TOKEN:-}
+ - OPS_REPO_ROOT=/opt/disinto-ops
+ - PROJECT_REPO_ROOT=/opt/disinto
+ - PRIMARY_BRANCH=main
+ volumes:
+ - ./docker/Caddyfile:/etc/caddy/Caddyfile
+ - caddy_data:/data
+ - /var/run/docker.sock:/var/run/docker.sock
+ depends_on:
+ - forgejo
+ - woodpecker
+ - staging
+ networks:
+ - disinto-net
+
+ # Staging container — static file server for staging artifacts
+ # Edge proxy routes to this container for default requests
+ staging:
+ image: caddy:alpine
+ command: ["caddy", "file-server", "--root", "/srv/site"]
+ volumes:
+ - ./docker:/srv/site:ro
+ networks:
+ - disinto-net
+
+ # Staging deployment slot — activated by Woodpecker staging pipeline (#755).
+ # Profile-gated: only starts when explicitly targeted by deploy commands.
+ # Customize image/ports/volumes for your project after init.
+ staging-deploy:
+ image: alpine:3
+ profiles: ["staging"]
+ security_opt:
+ - apparmor=unconfined
+ environment:
+ DEPLOY_ENV: staging
+ networks:
+ - disinto-net
+ command: ["echo", "staging slot — replace with project image"]
+
+volumes:
+ forgejo-data:
+ woodpecker-data:
+ agent-data:
+ project-repos:
+ caddy_data:
+
+networks:
+ disinto-net:
+ driver: bridge
+COMPOSEEOF
+
+ # Patch the Claude CLI binary path — resolve from host PATH at init time.
+ local claude_bin
+ claude_bin="$(command -v claude 2>/dev/null || true)"
+ if [ -n "$claude_bin" ]; then
+ # Resolve symlinks to get the real binary path
+ claude_bin="$(readlink -f "$claude_bin")"
+ sed -i "s|CLAUDE_BIN_PLACEHOLDER|${claude_bin}|" "$compose_file"
+ else
+ echo "Warning: claude CLI not found in PATH — update docker-compose.yml volumes manually" >&2
+ sed -i "s|CLAUDE_BIN_PLACEHOLDER|/usr/local/bin/claude|" "$compose_file"
+ fi
+
+ # Patch the forgejo port mapping into the file if non-default
+ if [ "$forge_port" != "3000" ]; then
+ # Add port mapping to forgejo service so it's reachable from host during init
+ sed -i "/image: codeberg\.org\/forgejo\/forgejo:11\.0/a\\ ports:\\n - \"${forge_port}:3000\"" "$compose_file"
+ else
+ sed -i "/image: codeberg\.org\/forgejo\/forgejo:11\.0/a\\ ports:\\n - \"3000:3000\"" "$compose_file"
+ fi
+
+ echo "Created: ${compose_file}"
+}
+
+# Generate docker/agents/ files if they don't already exist.
+_generate_agent_docker_impl() {
+ local docker_dir="${FACTORY_ROOT}/docker/agents"
+ mkdir -p "$docker_dir"
+
+ if [ ! -f "${docker_dir}/Dockerfile" ]; then
+ echo "Warning: docker/agents/Dockerfile not found — expected in repo" >&2
+ fi
+ if [ ! -f "${docker_dir}/entrypoint.sh" ]; then
+ echo "Warning: docker/agents/entrypoint.sh not found — expected in repo" >&2
+ fi
+}
+
+# Generate docker/Caddyfile template for edge proxy.
+_generate_caddyfile_impl() {
+ local docker_dir="${FACTORY_ROOT}/docker"
+ local caddyfile="${docker_dir}/Caddyfile"
+
+ if [ -f "$caddyfile" ]; then
+ echo "Caddyfile: ${caddyfile} (already exists, skipping)"
+ return
+ fi
+
+ cat > "$caddyfile" <<'CADDYFILEEOF'
+# Caddyfile — edge proxy configuration
+# IP-only binding at bootstrap; domain + TLS added later via vault resource request
+
+:80 {
+ # Reverse proxy to Forgejo
+ handle /forgejo/* {
+ reverse_proxy forgejo:3000
+ }
+
+ # Reverse proxy to Woodpecker CI
+ handle /ci/* {
+ reverse_proxy woodpecker:8000
+ }
+
+ # Default: proxy to staging container
+ handle {
+ reverse_proxy staging:80
+ }
+}
+CADDYFILEEOF
+
+ echo "Created: ${caddyfile}"
+}
+
+# Generate docker/index.html default page.
+_generate_staging_index_impl() {
+ local docker_dir="${FACTORY_ROOT}/docker"
+ local index_file="${docker_dir}/index.html"
+
+ if [ -f "$index_file" ]; then
+ echo "Staging: ${index_file} (already exists, skipping)"
+ return
+ fi
+
+ cat > "$index_file" <<'INDEXEOF'
+
+
+
+
+
+ Nothing shipped yet
+
+
+
+
+
Nothing shipped yet
+
CI pipelines will update this page with your staging artifacts.
+
+
+
+INDEXEOF
+
+ echo "Created: ${index_file}"
+}
+
+# Generate template .woodpecker/ deployment pipeline configs in a project repo.
+# Creates staging.yml and production.yml alongside the project's existing CI config.
+# These pipelines trigger on Woodpecker's deployment event with environment filters.
+_generate_deploy_pipelines_impl() {
+ local repo_root="$1"
+ local project_name="$2"
+ : "${project_name// /}" # Silence SC2034 - variable used in heredoc
+ local wp_dir="${repo_root}/.woodpecker"
+
+ mkdir -p "$wp_dir"
+
+ # Skip if deploy pipelines already exist
+ if [ -f "${wp_dir}/staging.yml" ] && [ -f "${wp_dir}/production.yml" ]; then
+ echo "Deploy: .woodpecker/{staging,production}.yml (already exist)"
+ return
+ fi
+
+ if [ ! -f "${wp_dir}/staging.yml" ]; then
+ cat > "${wp_dir}/staging.yml" <<'STAGINGEOF'
+# .woodpecker/staging.yml — Staging deployment pipeline
+# Triggered by runner via Woodpecker promote API.
+# Human approves promotion in vault → runner calls promote → this runs.
+
+when:
+ event: deployment
+ environment: staging
+
+steps:
+ - name: deploy-staging
+ image: docker:27
+ commands:
+ - echo "Deploying to staging environment..."
+ - echo "Pipeline ${CI_PIPELINE_NUMBER} promoted from CI #${CI_PIPELINE_PARENT}"
+ # Pull the image built by CI and deploy to staging
+ # Customize these commands for your project:
+ # - docker compose -f docker-compose.yml --profile staging up -d
+ - echo "Staging deployment complete"
+
+ - name: verify-staging
+ image: alpine:3
+ commands:
+ - echo "Verifying staging deployment..."
+ # Add health checks, smoke tests, or integration tests here:
+ # - curl -sf http://staging:8080/health || exit 1
+ - echo "Staging verification complete"
+STAGINGEOF
+ echo "Created: ${wp_dir}/staging.yml"
+ fi
+
+ if [ ! -f "${wp_dir}/production.yml" ]; then
+ cat > "${wp_dir}/production.yml" <<'PRODUCTIONEOF'
+# .woodpecker/production.yml — Production deployment pipeline
+# Triggered by runner via Woodpecker promote API.
+# Human approves promotion in vault → runner calls promote → this runs.
+
+when:
+ event: deployment
+ environment: production
+
+steps:
+ - name: deploy-production
+ image: docker:27
+ commands:
+ - echo "Deploying to production environment..."
+ - echo "Pipeline ${CI_PIPELINE_NUMBER} promoted from staging"
+ # Pull the verified image and deploy to production
+ # Customize these commands for your project:
+ # - docker compose -f docker-compose.yml up -d
+ - echo "Production deployment complete"
+
+ - name: verify-production
+ image: alpine:3
+ commands:
+ - echo "Verifying production deployment..."
+ # Add production health checks here:
+ # - curl -sf http://production:8080/health || exit 1
+ - echo "Production verification complete"
+PRODUCTIONEOF
+ echo "Created: ${wp_dir}/production.yml"
+ fi
+}