diff --git a/.env.example b/.env.example index e63d3cb..67752a6 100644 --- a/.env.example +++ b/.env.example @@ -16,6 +16,7 @@ FLOWISE_PASSWORD= N8N_ENCRYPTION_KEY= N8N_USER_MANAGEMENT_JWT_SECRET= +N8N_RUNNERS_AUTH_TOKEN= ############ @@ -171,9 +172,20 @@ WEBUI_HOSTNAME=webui.yourdomain.com RUN_N8N_IMPORT= -# n8n worker configuration +############ +# [optional] +# n8n configuration +############ + # Number of n8n worker replicas (for the n8n-worker service). Defaults to 1 if unset. N8N_WORKER_COUNT= + +# Number of n8n task runner replicas (for Code/Python node execution). Defaults to 1 if unset. +N8N_RUNNER_COUNT= + +# Maximum number of concurrent Code node executions per task runner. Defaults to 5. +N8N_RUNNERS_MAX_CONCURRENCY=5 + N8N_BLOCK_FILE_ACCESS_TO_N8N_FILES=true EXECUTIONS_MODE=queue N8N_LOG_LEVEL=info diff --git a/CLAUDE.md b/CLAUDE.md index 4315d44..7fffed7 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -90,12 +90,20 @@ Follow this workflow when adding a new optional service (refer to `.cursor/rules ## Important Service Details -### n8n Configuration +### n8n Configuration (v2.0+) - n8n runs in `EXECUTIONS_MODE=queue` with Redis as the queue backend -- Custom JavaScript libraries are pre-installed: `cheerio`, `axios`, `moment`, `lodash` (see `NODE_FUNCTION_ALLOW_EXTERNAL`) +- **Task runners**: n8n v2.0 uses external task runners for Code node execution (JavaScript and Python) + - Runner count controlled by `N8N_RUNNER_COUNT` env var (defaults to 1) + - Runner image `n8nio/runners` must match n8n version +- **Code node libraries**: Configured on the runner container (not n8n): + - `NODE_FUNCTION_ALLOW_EXTERNAL`: JS packages (`cheerio`, `axios`, `moment`, `lodash`) + - `NODE_FUNCTION_ALLOW_BUILTIN`: Node.js built-in modules (`*` = all) + - `N8N_RUNNERS_STDLIB_ALLOW`: Python stdlib modules + - `N8N_RUNNERS_EXTERNAL_ALLOW`: Python third-party packages - Workflows can access the host filesystem via `/data/shared` (mapped to `./shared`) - Worker count is controlled by `N8N_WORKER_COUNT` env var (defaults to 1) +- `N8N_BLOCK_ENV_ACCESS_IN_NODE=false` allows Code nodes to access environment variables ### Caddy Reverse Proxy @@ -115,7 +123,7 @@ The `scripts/03_generate_secrets.sh` script: ### Service Profiles Common profiles: -- `n8n`: n8n workflow automation (includes main app, worker, and import services) +- `n8n`: n8n workflow automation (includes main app, worker, runner, and import services) - `flowise`: Flowise AI agent builder - `monitoring`: Prometheus, Grafana, cAdvisor, node-exporter - `langfuse`: Langfuse observability (includes ClickHouse, MinIO, worker, web) diff --git a/README.md b/README.md index 6d29530..ad4c9be 100644 --- a/README.md +++ b/README.md @@ -14,7 +14,7 @@ This installer helps you create your own powerful, private AI workshop. Imagine This setup provides a comprehensive suite of cutting-edge services, all pre-configured to work together. Key advantages include: - **Rich Toolset:** Get a curated collection of powerful open-source tools for AI development, automation, and monitoring, all in one place. -- **Scalable n8n Performance:** n8n runs in `queue` mode by default, leveraging Redis for task management and Postgres for data storage. You can dynamically specify the number of n8n workers during installation, allowing for robust parallel processing of your workflows to handle demanding loads. +- **Scalable n8n Performance:** n8n runs in `queue` mode by default, leveraging Redis for task management and Postgres for data storage. You can dynamically specify the number of n8n workers and task runners during installation, allowing for robust parallel processing of your workflows to handle demanding loads. - **Full Control:** All of this is hosted by you, giving you full control over your data, operations, and how resources are allocated. ### What's Included @@ -130,7 +130,8 @@ During the installation, the script will prompt you for: 3. An optional **OpenAI API key** (Not required. If provided, it can be used by Supabase AI features and Crawl4ai. Press Enter to skip). 4. Whether you want to **import ~300 ready-made n8n community workflows** (y/n, Optional. This can take 20-30 minutes, depending on your server and network speed). 5. The **number of n8n workers** you want to run (Required, e.g., 1, 2, 3, 4. This determines how many workflows can be processed in parallel. Defaults to 1 if not specified). -6. A **Service Selection Wizard** will then appear, allowing you to choose which of the available services (like Flowise, Supabase, Qdrant, Open WebUI, etc.) you want to deploy. Core services (Caddy, Postgres, Redis) will be set up to support your selections. +6. The **number of n8n task runners** you want to run (Required, e.g., 1, 2, 3. Task runners execute JavaScript and Python Code nodes. Defaults to 1 if not specified). +7. A **Service Selection Wizard** will then appear, allowing you to choose which of the available services (like Flowise, Supabase, Qdrant, Open WebUI, etc.) you want to deploy. Core services (Caddy, Postgres, Redis) will be set up to support your selections. Upon successful completion, the script will display a summary report. This report contains the access URLs and credentials for the deployed services. **Save this information in a safe place!** @@ -206,10 +207,11 @@ Cloudflare Tunnel provides zero-trust access to your services without exposing a See the Cloudflare Tunnel guide: [cloudflare-instructions.md](cloudflare-instructions.md) -### Using Pre-installed Libraries in n8n's Custom JavaScript +### Using Libraries in n8n Code Nodes (v2.0+) -This setup pre-installs useful Node.js libraries for use in n8n's Code nodes, allowing you to write custom JavaScript snippets with enhanced capabilities: +n8n v2.0 uses external task runners to execute JavaScript and Python code in Code nodes. This setup pre-configures the following libraries: +**JavaScript libraries** (via `NODE_FUNCTION_ALLOW_EXTERNAL`): - **`cheerio`**: For parsing and manipulating HTML/XML (e.g., web scraping). - **`axios`**: A promise-based HTTP client for making requests to external APIs. - **`moment`**: For parsing, validating, manipulating, and displaying dates/times. diff --git a/docker-compose.yml b/docker-compose.yml index 602d76b..9be680a 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -49,7 +49,8 @@ x-n8n: &service-n8n LANGCHAIN_API_KEY: ${LANGCHAIN_API_KEY} LANGCHAIN_ENDPOINT: ${LANGCHAIN_ENDPOINT} LANGCHAIN_TRACING_V2: ${LANGCHAIN_TRACING_V2} - N8N_BINARY_DATA_MODE: filesystem + N8N_BINARY_DATA_MODE: database + N8N_BLOCK_ENV_ACCESS_IN_NODE: false N8N_BLOCK_FILE_ACCESS_TO_N8N_FILES: ${N8N_BLOCK_FILE_ACCESS_TO_N8N_FILES:-true} N8N_COMMUNITY_PACKAGES_ALLOW_TOOL_USAGE: true N8N_DIAGNOSTICS_ENABLED: false @@ -61,7 +62,11 @@ x-n8n: &service-n8n N8N_METRICS: true N8N_PAYLOAD_SIZE_MAX: 256 N8N_PERSONALIZATION_ENABLED: false + N8N_RESTRICT_FILE_ACCESS_TO: /data/shared + N8N_RUNNERS_AUTH_TOKEN: ${N8N_RUNNERS_AUTH_TOKEN} + N8N_RUNNERS_BROKER_LISTEN_ADDRESS: 0.0.0.0 N8N_RUNNERS_ENABLED: true + N8N_RUNNERS_MODE: external N8N_SMTP_HOST: ${N8N_SMTP_HOST:-} N8N_SMTP_OAUTH_PRIVATE_KEY: ${N8N_SMTP_OAUTH_PRIVATE_KEY:-} N8N_SMTP_OAUTH_SERVICE_CLIENT: ${N8N_SMTP_OAUTH_SERVICE_CLIENT:-} @@ -74,8 +79,6 @@ x-n8n: &service-n8n N8N_TRUST_PROXY: true N8N_USER_MANAGEMENT_JWT_SECRET: ${N8N_USER_MANAGEMENT_JWT_SECRET} NODE_ENV: production - NODE_FUNCTION_ALLOW_BUILTIN: "*" - NODE_FUNCTION_ALLOW_EXTERNAL: cheerio,axios,moment,lodash QUEUE_BULL_REDIS_HOST: ${REDIS_HOST:-redis} QUEUE_BULL_REDIS_PORT: ${REDIS_PORT:-6379} QUEUE_HEALTH_CHECK_ACTIVE: true @@ -103,6 +106,18 @@ x-init-ollama: &init-ollama - "-c" - "sleep 3; OLLAMA_HOST=ollama:11434 ollama pull qwen2.5:7b-instruct-q4_K_M; OLLAMA_HOST=ollama:11434 ollama pull nomic-embed-text" +x-n8n-runner: &service-n8n-runner + image: n8nio/runners:2.0.0-rc.4 + environment: &service-n8n-runner-env + N8N_RUNNERS_AUTH_TOKEN: ${N8N_RUNNERS_AUTH_TOKEN} + N8N_RUNNERS_AUTO_SHUTDOWN_TIMEOUT: 15 + N8N_RUNNERS_EXTERNAL_ALLOW: "*" + N8N_RUNNERS_MAX_CONCURRENCY: ${N8N_RUNNERS_MAX_CONCURRENCY:-5} + N8N_RUNNERS_STDLIB_ALLOW: "*" + N8N_RUNNERS_TASK_BROKER_URI: http://n8n:5679 + NODE_FUNCTION_ALLOW_BUILTIN: "*" + NODE_FUNCTION_ALLOW_EXTERNAL: cheerio,axios,moment,lodash + services: flowise: image: flowiseai/flowise @@ -178,6 +193,23 @@ services: deploy: replicas: ${N8N_WORKER_COUNT:-1} + n8n-runner: + <<: *service-n8n-runner + profiles: ["n8n"] + restart: unless-stopped + entrypoint: ["/bin/sh", "-c", "/usr/local/bin/task-runner-launcher javascript python"] + depends_on: + n8n: + condition: service_started + healthcheck: + test: ["CMD-SHELL", "wget -qO- http://localhost:5680/healthz || exit 1"] + interval: 30s + timeout: 10s + retries: 5 + start_period: 30s + deploy: + replicas: ${N8N_RUNNER_COUNT:-1} + qdrant: image: qdrant/qdrant container_name: qdrant diff --git a/n8n/Dockerfile b/n8n/Dockerfile index 8b3d633..98aaa83 100644 --- a/n8n/Dockerfile +++ b/n8n/Dockerfile @@ -1,5 +1,5 @@ -FROM n8nio/n8n:1.123.4 +FROM n8nio/n8n:2.0.0-rc.4 USER root RUN apk add --no-cache ffmpeg -USER node \ No newline at end of file +USER node diff --git a/scripts/03_generate_secrets.sh b/scripts/03_generate_secrets.sh index e1c4e49..cdd9d84 100644 --- a/scripts/03_generate_secrets.sh +++ b/scripts/03_generate_secrets.sh @@ -21,53 +21,48 @@ DOMAIN_PLACEHOLDER="yourdomain.com" # Variables to generate: varName="type:length" # Types: password (alphanum), secret (base64), hex, base64, alphanum declare -A VARS_TO_GENERATE=( - ["FLOWISE_PASSWORD"]="password:32" - ["N8N_ENCRYPTION_KEY"]="secret:64" # base64 encoded, 48 bytes -> 64 chars - ["N8N_USER_MANAGEMENT_JWT_SECRET"]="secret:64" # base64 encoded, 48 bytes -> 64 chars - ["POSTGRES_PASSWORD"]="password:32" - ["POSTGRES_NON_ROOT_PASSWORD"]="password:32" - ["JWT_SECRET"]="base64:64" # 48 bytes -> 64 chars - ["DASHBOARD_PASSWORD"]="password:32" # Supabase Dashboard ["CLICKHOUSE_PASSWORD"]="password:32" - ["MINIO_ROOT_PASSWORD"]="password:32" - ["LANGFUSE_SALT"]="secret:64" # base64 encoded, 48 bytes -> 64 chars - ["NEXTAUTH_SECRET"]="secret:64" # base64 encoded, 48 bytes -> 64 chars + ["COMFYUI_PASSWORD"]="password:32" # Added ComfyUI basic auth password + ["DASHBOARD_PASSWORD"]="password:32" # Supabase Dashboard + ["DIFY_SECRET_KEY"]="secret:64" # Dify application secret key (maps to SECRET_KEY in Dify) + ["DOCLING_PASSWORD"]="password:32" ["ENCRYPTION_KEY"]="hex:64" # Langfuse Encryption Key (32 bytes -> 64 hex chars) + ["FLOWISE_PASSWORD"]="password:32" ["GRAFANA_ADMIN_PASSWORD"]="password:32" - # From MD file (ensure they are in template if needed) - ["SECRET_KEY_BASE"]="base64:64" # 48 bytes -> 64 chars - ["VAULT_ENC_KEY"]="alphanum:32" - ["LOGFLARE_PRIVATE_ACCESS_TOKEN"]="fixed:not-in-use" # For supabase-vector, can't be empty - ["LOGFLARE_PUBLIC_ACCESS_TOKEN"]="fixed:not-in-use" # For supabase-vector, can't be empty - ["PROMETHEUS_PASSWORD"]="password:32" # Added Prometheus password - ["SEARXNG_PASSWORD"]="password:32" # Added SearXNG admin password - ["LETTA_SERVER_PASSWORD"]="password:32" # Added Letta server password - ["LANGFUSE_INIT_USER_PASSWORD"]="password:32" + ["JWT_SECRET"]="base64:64" # 48 bytes -> 64 chars ["LANGFUSE_INIT_PROJECT_PUBLIC_KEY"]="langfuse_pk:32" ["LANGFUSE_INIT_PROJECT_SECRET_KEY"]="langfuse_sk:32" - ["WEAVIATE_API_KEY"]="secret:48" # API Key for Weaviate service (36 bytes -> 48 chars base64) - ["QDRANT_API_KEY"]="secret:48" # API Key for Qdrant service + ["LANGFUSE_INIT_USER_PASSWORD"]="password:32" + ["LANGFUSE_SALT"]="secret:64" # base64 encoded, 48 bytes -> 64 chars + ["LETTA_SERVER_PASSWORD"]="password:32" # Added Letta server password + ["LIGHTRAG_API_KEY"]="secret:48" + ["LIGHTRAG_PASSWORD"]="password:32" + ["LOGFLARE_PRIVATE_ACCESS_TOKEN"]="fixed:not-in-use" # For supabase-vector, can't be empty + ["LOGFLARE_PUBLIC_ACCESS_TOKEN"]="fixed:not-in-use" # For supabase-vector, can't be empty + ["LT_PASSWORD"]="password:32" # Added LibreTranslate basic auth password + ["MINIO_ROOT_PASSWORD"]="password:32" + ["N8N_ENCRYPTION_KEY"]="secret:64" # base64 encoded, 48 bytes -> 64 chars + ["N8N_RUNNERS_AUTH_TOKEN"]="secret:64" # Task runner auth token for n8n v2.0 + ["N8N_USER_MANAGEMENT_JWT_SECRET"]="secret:64" # base64 encoded, 48 bytes -> 64 chars ["NEO4J_AUTH_PASSWORD"]="password:32" # Added Neo4j password ["NEO4J_AUTH_USERNAME"]="fixed:neo4j" # Added Neo4j username - # Dify environment variables - ["DIFY_SECRET_KEY"]="secret:64" # Dify application secret key (maps to SECRET_KEY in Dify) - ["COMFYUI_PASSWORD"]="password:32" # Added ComfyUI basic auth password - ["RAGAPP_PASSWORD"]="password:32" # Added RAGApp basic auth password + ["NEXTAUTH_SECRET"]="secret:64" # base64 encoded, 48 bytes -> 64 chars ["PADDLEOCR_PASSWORD"]="password:32" # Added PaddleOCR basic auth password - ["LT_PASSWORD"]="password:32" # Added LibreTranslate basic auth password - # WAHA (WhatsApp HTTP API) - ["WAHA_DASHBOARD_PASSWORD"]="password:32" - ["WHATSAPP_SWAGGER_PASSWORD"]="password:32" - # RAGFlow internal credentials - ["RAGFLOW_MYSQL_ROOT_PASSWORD"]="password:32" - ["RAGFLOW_MINIO_ROOT_PASSWORD"]="password:32" - ["RAGFLOW_REDIS_PASSWORD"]="password:32" + ["POSTGRES_NON_ROOT_PASSWORD"]="password:32" + ["POSTGRES_PASSWORD"]="password:32" + ["PROMETHEUS_PASSWORD"]="password:32" # Added Prometheus password + ["QDRANT_API_KEY"]="secret:48" # API Key for Qdrant service + ["RAGAPP_PASSWORD"]="password:32" # Added RAGApp basic auth password ["RAGFLOW_ELASTICSEARCH_PASSWORD"]="password:32" - # LightRAG credentials - ["LIGHTRAG_PASSWORD"]="password:32" - ["LIGHTRAG_API_KEY"]="secret:48" - # Docling credentials - ["DOCLING_PASSWORD"]="password:32" + ["RAGFLOW_MINIO_ROOT_PASSWORD"]="password:32" + ["RAGFLOW_MYSQL_ROOT_PASSWORD"]="password:32" + ["RAGFLOW_REDIS_PASSWORD"]="password:32" + ["SEARXNG_PASSWORD"]="password:32" # Added SearXNG admin password + ["SECRET_KEY_BASE"]="base64:64" # 48 bytes -> 64 chars + ["VAULT_ENC_KEY"]="alphanum:32" + ["WAHA_DASHBOARD_PASSWORD"]="password:32" + ["WEAVIATE_API_KEY"]="secret:48" # API Key for Weaviate service (36 bytes -> 48 chars base64) + ["WHATSAPP_SWAGGER_PASSWORD"]="password:32" ) # Initialize existing_env_vars and attempt to read .env if it exists @@ -295,6 +290,7 @@ found_vars["SEARXNG_USERNAME"]=0 found_vars["OPENAI_API_KEY"]=0 found_vars["LANGFUSE_INIT_USER_EMAIL"]=0 found_vars["N8N_WORKER_COUNT"]=0 +found_vars["N8N_RUNNER_COUNT"]=0 found_vars["WEAVIATE_USERNAME"]=0 found_vars["NEO4J_AUTH_USERNAME"]=0 found_vars["COMFYUI_USERNAME"]=0 @@ -351,7 +347,7 @@ while IFS= read -r line || [[ -n "$line" ]]; do # This 'else' block is for lines from template not covered by existing values or VARS_TO_GENERATE. # Check if it is one of the user input vars - these are handled by found_vars later if not in template. is_user_input_var=0 # Reset for each line - user_input_vars=("FLOWISE_USERNAME" "DASHBOARD_USERNAME" "LETSENCRYPT_EMAIL" "RUN_N8N_IMPORT" "PROMETHEUS_USERNAME" "SEARXNG_USERNAME" "OPENAI_API_KEY" "LANGFUSE_INIT_USER_EMAIL" "N8N_WORKER_COUNT" "WEAVIATE_USERNAME" "NEO4J_AUTH_USERNAME" "COMFYUI_USERNAME" "RAGAPP_USERNAME" "PADDLEOCR_USERNAME" "LT_USERNAME" "LIGHTRAG_USERNAME" "WAHA_DASHBOARD_USERNAME" "WHATSAPP_SWAGGER_USERNAME") + user_input_vars=("FLOWISE_USERNAME" "DASHBOARD_USERNAME" "LETSENCRYPT_EMAIL" "RUN_N8N_IMPORT" "PROMETHEUS_USERNAME" "SEARXNG_USERNAME" "OPENAI_API_KEY" "LANGFUSE_INIT_USER_EMAIL" "N8N_WORKER_COUNT" "N8N_RUNNER_COUNT" "WEAVIATE_USERNAME" "NEO4J_AUTH_USERNAME" "COMFYUI_USERNAME" "RAGAPP_USERNAME" "PADDLEOCR_USERNAME" "LT_USERNAME" "LIGHTRAG_USERNAME" "WAHA_DASHBOARD_USERNAME" "WHATSAPP_SWAGGER_USERNAME") for uivar in "${user_input_vars[@]}"; do if [[ "$varName" == "$uivar" ]]; then is_user_input_var=1 @@ -433,7 +429,7 @@ if [[ -z "${generated_values[SERVICE_ROLE_KEY]}" ]]; then fi # Add any custom variables that weren't found in the template -for var in "FLOWISE_USERNAME" "DASHBOARD_USERNAME" "LETSENCRYPT_EMAIL" "RUN_N8N_IMPORT" "OPENAI_API_KEY" "PROMETHEUS_USERNAME" "SEARXNG_USERNAME" "LANGFUSE_INIT_USER_EMAIL" "N8N_WORKER_COUNT" "WEAVIATE_USERNAME" "NEO4J_AUTH_USERNAME" "COMFYUI_USERNAME" "RAGAPP_USERNAME" "PADDLEOCR_USERNAME" "LT_USERNAME" "LIGHTRAG_USERNAME" "WAHA_DASHBOARD_USERNAME" "WHATSAPP_SWAGGER_USERNAME" "DOCLING_USERNAME"; do +for var in "FLOWISE_USERNAME" "DASHBOARD_USERNAME" "LETSENCRYPT_EMAIL" "RUN_N8N_IMPORT" "OPENAI_API_KEY" "PROMETHEUS_USERNAME" "SEARXNG_USERNAME" "LANGFUSE_INIT_USER_EMAIL" "N8N_WORKER_COUNT" "N8N_RUNNER_COUNT" "WEAVIATE_USERNAME" "NEO4J_AUTH_USERNAME" "COMFYUI_USERNAME" "RAGAPP_USERNAME" "PADDLEOCR_USERNAME" "LT_USERNAME" "LIGHTRAG_USERNAME" "WAHA_DASHBOARD_USERNAME" "WHATSAPP_SWAGGER_USERNAME" "DOCLING_USERNAME"; do if [[ ${found_vars["$var"]} -eq 0 && -v generated_values["$var"] ]]; then # Before appending, check if it's already in TMP_ENV_FILE to avoid duplicates if ! grep -q -E "^${var}=" "$TMP_ENV_FILE"; then diff --git a/scripts/05_configure_services.sh b/scripts/05_configure_services.sh index bf9e294..0e2d842 100644 --- a/scripts/05_configure_services.sh +++ b/scripts/05_configure_services.sh @@ -127,6 +127,63 @@ N8N_WORKER_COUNT="${N8N_WORKER_COUNT:-1}" write_env_var "N8N_WORKER_COUNT" "$N8N_WORKER_COUNT" +# ---------------------------------------------------------------- +# Prompt for number of n8n runners (for Code/Python node execution) +# ---------------------------------------------------------------- +echo "" # Add a newline for better formatting +log_info "Configuring n8n runner count..." +EXISTING_N8N_RUNNER_COUNT="$(read_env_var N8N_RUNNER_COUNT)" +require_whiptail +if [[ -n "$EXISTING_N8N_RUNNER_COUNT" ]]; then + N8N_RUNNER_COUNT_CURRENT="$EXISTING_N8N_RUNNER_COUNT" + N8N_RUNNER_COUNT_INPUT_RAW=$(wt_input "n8n Runners (instances)" "Enter new number of n8n runners, or leave as current ($N8N_RUNNER_COUNT_CURRENT)." "") || true + if [[ -z "$N8N_RUNNER_COUNT_INPUT_RAW" ]]; then + N8N_RUNNER_COUNT="$N8N_RUNNER_COUNT_CURRENT" + else + if [[ "$N8N_RUNNER_COUNT_INPUT_RAW" =~ ^0*[1-9][0-9]*$ ]]; then + N8N_RUNNER_COUNT_TEMP="$((10#$N8N_RUNNER_COUNT_INPUT_RAW))" + if [[ "$N8N_RUNNER_COUNT_TEMP" -ge 1 ]]; then + if wt_yesno "Confirm Runners" "Update n8n runners to $N8N_RUNNER_COUNT_TEMP?" "yes"; then + N8N_RUNNER_COUNT="$N8N_RUNNER_COUNT_TEMP" + else + N8N_RUNNER_COUNT="$N8N_RUNNER_COUNT_CURRENT" + log_info "Change declined. Keeping N8N_RUNNER_COUNT at $N8N_RUNNER_COUNT." + fi + else + log_warning "Invalid input '$N8N_RUNNER_COUNT_INPUT_RAW'. Number must be positive. Keeping $N8N_RUNNER_COUNT_CURRENT." + N8N_RUNNER_COUNT="$N8N_RUNNER_COUNT_CURRENT" + fi + else + log_warning "Invalid input '$N8N_RUNNER_COUNT_INPUT_RAW'. Please enter a positive integer. Keeping $N8N_RUNNER_COUNT_CURRENT." + N8N_RUNNER_COUNT="$N8N_RUNNER_COUNT_CURRENT" + fi + fi +else + while true; do + N8N_RUNNER_COUNT_INPUT_RAW=$(wt_input "n8n Runners" "Enter number of n8n runners for Code/Python nodes (default 1)." "1") || true + N8N_RUNNER_COUNT_CANDIDATE="${N8N_RUNNER_COUNT_INPUT_RAW:-1}" + if [[ "$N8N_RUNNER_COUNT_CANDIDATE" =~ ^0*[1-9][0-9]*$ ]]; then + N8N_RUNNER_COUNT_VALIDATED="$((10#$N8N_RUNNER_COUNT_CANDIDATE))" + if [[ "$N8N_RUNNER_COUNT_VALIDATED" -ge 1 ]]; then + if wt_yesno "Confirm Runners" "Run $N8N_RUNNER_COUNT_VALIDATED n8n runner(s)?" "yes"; then + N8N_RUNNER_COUNT="$N8N_RUNNER_COUNT_VALIDATED" + break + fi + else + log_error "Number of runners must be a positive integer." >&2 + fi + else + log_error "Invalid input '$N8N_RUNNER_COUNT_CANDIDATE'. Please enter a positive integer (e.g., 1, 2)." >&2 + fi + done +fi +# Ensure N8N_RUNNER_COUNT is definitely set (should be by logic above) +N8N_RUNNER_COUNT="${N8N_RUNNER_COUNT:-1}" + +# Persist N8N_RUNNER_COUNT to .env +write_env_var "N8N_RUNNER_COUNT" "$N8N_RUNNER_COUNT" + + # ---------------------------------------------------------------- # Cloudflare Tunnel Token (if cloudflare-tunnel profile is active) # ----------------------------------------------------------------