automated-setup/script/env_setup.sh

535 lines
22 KiB
Bash
Raw Normal View History

#!/bin/bash
#
# LetsBe Cloud Environment Setup Script
# Non-interactive version for Orchestrator/SysAdmin Agent integration
#
# Usage:
# ./env_setup.sh --customer "acme" --domain "acme.com" --company "Acme Corp"
# ./env_setup.sh --json '{"customer":"acme","domain":"acme.com","company_name":"Acme Corp"}'
# ./env_setup.sh --config /path/to/config.json
#
set -euo pipefail
# ============================================================================
# CONFIGURATION
# ============================================================================
LETSBE_BASE="/opt/letsbe"
STACKS_DIR="${LETSBE_BASE}/stacks"
NGINX_DIR="${LETSBE_BASE}/nginx"
ENV_DIR="${LETSBE_BASE}/env"
SCRIPTS_DIR="${LETSBE_BASE}/scripts"
# ============================================================================
# HELPER FUNCTIONS
# ============================================================================
usage() {
cat <<EOF
Usage: $0 [OPTIONS]
Required (one of):
--customer NAME Customer name (lowercase, no spaces/hyphens/numbers)
--domain DOMAIN Main domain without subdomains (lowercase)
--company NAME Company name (can include spaces)
Or provide all via JSON:
--json JSON_STRING JSON object with customer, domain, company_name
--config FILE Path to JSON config file
Example:
$0 --customer acme --domain acme.com --company "Acme Corporation"
$0 --json '{"customer":"acme","domain":"acme.com","company_name":"Acme Corp"}'
$0 --config /opt/letsbe/config/setup.json
EOF
exit 1
}
log_info() {
echo "[INFO] $*"
}
log_error() {
echo "[ERROR] $*" >&2
}
die() {
log_error "$*"
exit 1
}
# Generate random string of specified length
generate_random_string() {
local length=$1
tr -dc A-Za-z0-9 </dev/urandom | head -c "${length}"
echo ''
}
# Validate required variables are set
validate_required() {
local var_name=$1
local var_value=$2
if [[ -z "${var_value}" ]]; then
die "Required variable '${var_name}' is not set"
fi
}
# Parse JSON input using jq
parse_json() {
local json_input=$1
if ! command -v jq &> /dev/null; then
die "jq is required for JSON parsing. Install with: apt-get install jq"
fi
customer=$(echo "${json_input}" | jq -r '.customer // empty')
domain=$(echo "${json_input}" | jq -r '.domain // empty')
company_name=$(echo "${json_input}" | jq -r '.company_name // empty')
}
# ============================================================================
# ARGUMENT PARSING
# ============================================================================
customer=""
domain=""
company_name=""
docker_user=""
while [[ $# -gt 0 ]]; do
case $1 in
--customer)
customer="$2"
shift 2
;;
--domain)
domain="$2"
shift 2
;;
--company)
company_name="$2"
shift 2
;;
--docker-user)
docker_user="$2"
shift 2
;;
--json)
parse_json "$2"
shift 2
;;
--config)
if [[ ! -f "$2" ]]; then
die "Config file not found: $2"
fi
parse_json "$(cat "$2")"
shift 2
;;
--help|-h)
usage
;;
*)
log_error "Unknown option: $1"
usage
;;
esac
done
# ============================================================================
# VALIDATION
# ============================================================================
validate_required "customer" "${customer}"
validate_required "domain" "${domain}"
validate_required "company_name" "${company_name}"
# Validate customer format (lowercase, no spaces/hyphens/numbers)
if [[ ! "${customer}" =~ ^[a-z]+$ ]]; then
die "Customer name must be lowercase letters only, no spaces/hyphens/numbers: ${customer}"
fi
# Validate domain format
if [[ ! "${domain}" =~ ^[a-z0-9.-]+\.[a-z]{2,}$ ]]; then
die "Invalid domain format: ${domain}"
fi
log_info "Configuration validated"
log_info " Customer: ${customer}"
log_info " Domain: ${domain}"
log_info " Company: ${company_name}"
# ============================================================================
# DERIVED VARIABLES
# ============================================================================
# Email for Let's Encrypt
letsencrypt_email="postmaster@${domain}"
# Subdomains per tool
domain_html="html.${domain}"
domain_wordpress="${domain}"
domain_squidex="contenthub.${domain}"
domain_chatwoot="support.${domain}"
domain_chatwoot_helpdesk="helpdesk.${domain}"
domain_gitea="code.${domain}"
domain_gitea_drone="ci.${domain}"
domain_glitchtip="debug.${domain}"
domain_listmonk="newsletters.${domain}"
domain_n8n="n8n.${domain}"
domain_nextcloud="cloud.${domain}"
domain_penpot="design.${domain}"
domain_poste="mail.${domain}"
domain_umami="analytics.${domain}"
domain_uptime_kuma="uptime.${domain}"
domain_windmill="flows.${domain}"
domain_calcom="bookings.${domain}"
domain_odoo="crm.${domain}"
domain_collabora="collabora.${domain}"
domain_whiteboard="whiteboard.${domain}"
domain_activepieces="automation.${domain}"
domain_minio="minio.${domain}"
domain_s3="s3.${domain}"
domain_librechat="ai.${domain}"
domain_bot_viewer="bots.${domain}"
domain_botlab="botlab.${domain}"
domain_nocodb="database.${domain}"
domain_redash="data.${domain}"
domain_documenso="signatures.${domain}"
domain_keycloak="auth.${domain}"
domain_pdf="pdf.${domain}"
domain_ghost="${domain}"
# ============================================================================
# GENERATED SECRETS
# ============================================================================
log_info "Generating secrets and credentials..."
# WordPress
wordpresss_mariadb_root_password=$(generate_random_string 20)
wordpress_db_user=$(generate_random_string 10 | tr '[:upper:]' '[:lower:]')
wordpress_db_password=$(generate_random_string 20)
# Squidex
squidex_adminemail="postmaster@${domain}"
squidex_adminpassword=$(generate_random_string 20)
# Listmonk
listmonk_admin_username=$(generate_random_string 10 | tr '[:upper:]' '[:lower:]')
listmonk_admin_password=$(generate_random_string 20)
listmonk_db_user=$(generate_random_string 10 | tr '[:upper:]' '[:lower:]')
listmonk_db_password=$(generate_random_string 20)
# Gitea
gitea_postgres_user=$(generate_random_string 10 | tr '[:upper:]' '[:lower:]')
gitea_postgres_password=$(generate_random_string 20)
# Umami
umami_app_secret=$(generate_random_string 32)
umami_postgres_user=$(generate_random_string 10 | tr '[:upper:]' '[:lower:]')
umami_postgres_password=$(generate_random_string 20)
# Drone/Gitea
drone_gitea_rpc_secret=$(generate_random_string 32)
# Windmill
windmill_database_password=$(generate_random_string 20)
# Glitchtip
glitchtip_database_password=$(generate_random_string 20)
glitchtip_secret_key=$(generate_random_string 32)
# Penpot
penpot_secret_key=$(generate_random_string 32)
penpot_db_user=$(generate_random_string 10 | tr '[:upper:]' '[:lower:]')
penpot_db_password=$(generate_random_string 20)
# Nextcloud
nextcloud_postgres_user=$(generate_random_string 10 | tr '[:upper:]' '[:lower:]')
nextcloud_postgres_password=$(generate_random_string 20)
nextcloud_jwt_secret=$(generate_random_string 64 | tr '[:upper:]' '[:lower:]')
nextcloud_admin_password=$(generate_random_string 20)
# Collabora
collabora_password=$(generate_random_string 20)
collabora_user=$(generate_random_string 10 | tr '[:upper:]' '[:lower:]')
# Chatwoot
chatwoot_secret_key_base=$(generate_random_string 32)
chatwoot_redis_password=$(generate_random_string 20)
chatwoot_postgres_username=$(generate_random_string 10 | tr '[:upper:]' '[:lower:]')
chatwoot_postgres_password=$(generate_random_string 20)
chatwoot_rails_inbound_email_password=$(generate_random_string 20)
# N8N
n8n_postgres_user=$(generate_random_string 10 | tr '[:upper:]' '[:lower:]')
n8n_postgres_password=$(generate_random_string 20)
# Cal.com
calcom_nextauth_secret=$(generate_random_string 32)
calcom_postgres_user=$(generate_random_string 10 | tr '[:upper:]' '[:lower:]')
calcom_postgres_password=$(generate_random_string 20)
# Odoo
odoo_postgres_user=$(generate_random_string 10 | tr '[:upper:]' '[:lower:]')
odoo_postgres_password=$(generate_random_string 20)
# Activepieces
activepieces_api_key=$(generate_random_string 32)
activepieces_encryption_key=$(generate_random_string 32 | tr '[:upper:]' '[:lower:]')
activepieces_jwt_secret=$(generate_random_string 64 | tr '[:upper:]' '[:lower:]')
activepieces_postgres_password=$(generate_random_string 32)
# MinIO
minio_root_user=$(generate_random_string 16)
minio_root_password=$(generate_random_string 32)
# Typebot
typebot_encryption_secret=$(generate_random_string 32)
typebot_postgres_password=$(generate_random_string 20)
# NocoDB
nocodb_postgres_password=$(generate_random_string 32)
# LibreChat
librechat_postgres_password=$(generate_random_string 20)
librechat_postgres_user=$(generate_random_string 10 | tr '[:upper:]' '[:lower:]')
librechat_jwt_secret=$(generate_random_string 64 | tr '[:upper:]' '[:lower:]')
librechat_jwt_refresh_secret=$(generate_random_string 64 | tr '[:upper:]' '[:lower:]')
# Redash
redash_secret_key=$(generate_random_string 32)
redash_cookie_secret=$(generate_random_string 32)
redash_postgres_password=$(generate_random_string 20)
redash_postgres_user=$(generate_random_string 10 | tr '[:upper:]' '[:lower:]')
# Documenso
documenso_postgres_user=$(generate_random_string 10 | tr '[:upper:]' '[:lower:]')
documenso_postgres_password=$(generate_random_string 40)
documenso_nextauth_secret=$(generate_random_string 32)
documenso_encryption_key=$(generate_random_string 64 | tr '[:upper:]' '[:lower:]')
documenso_encryption_secondary_key=$(generate_random_string 64 | tr '[:upper:]' '[:lower:]')
# Ghost
ghost_mysql_password=$(generate_random_string 40)
ghost_s3_access_key=$(generate_random_string 20)
ghost_s3_secret_key=$(generate_random_string 40)
# Keycloak
keycloak_postgres_password=$(generate_random_string 40)
keycloak_admin_password=$(generate_random_string 40)
keycloak_grafana_password=$(generate_random_string 40)
# StirlingPDF
stirlingpdf_postgres_user=$(generate_random_string 10 | tr '[:upper:]' '[:lower:]')
stirlingpdf_postgres_password=$(generate_random_string 40)
stirlingpdf_api_key=$(generate_random_string 40)
# Sysadmin Agent
# Registration token must be obtained from orchestrator API:
# POST /api/v1/tenants/{tenant_id}/registration-tokens
# The returned token is passed via SYSADMIN_REGISTRATION_TOKEN env var
sysadmin_registration_token="${SYSADMIN_REGISTRATION_TOKEN:-}"
if [[ -z "${sysadmin_registration_token}" ]]; then
log_error "SYSADMIN_REGISTRATION_TOKEN environment variable is required"
log_error "Obtain a registration token from the orchestrator:"
log_error " curl -X POST https://orchestrator.letsbe.biz/api/v1/tenants/{tenant_id}/registration-tokens \\"
log_error " -H 'X-Admin-Api-Key: YOUR_ADMIN_KEY' \\"
log_error " -H 'Content-Type: application/json' \\"
log_error " -d '{\"description\": \"Agent for ${customer}\"}'"
die "Missing SYSADMIN_REGISTRATION_TOKEN"
fi
# Legacy token (deprecated, kept for backward compatibility)
sysadmin_agent_token=$(generate_random_string 64)
# ============================================================================
# TEMPLATE REPLACEMENT
# ============================================================================
log_info "Replacing placeholders in template files..."
# Process all template files
for file in "${STACKS_DIR}"/*/* "${STACKS_DIR}"/*/.* "${NGINX_DIR}"/* "${SCRIPTS_DIR}"/backups.sh; do
if [[ -f "${file}" ]]; then
# Core variables
sed -i "s/{{ customer }}/${customer}/g" "${file}"
sed -i "s/{{ domain }}/${domain}/g" "${file}"
sed -i "s/{{ company_name }}/${company_name}/g" "${file}"
sed -i "s/{{ letsencrypt_email }}/${letsencrypt_email}/g" "${file}"
# Domain variables
sed -i "s/{{ domain_html }}/${domain_html}/g" "${file}"
sed -i "s/{{ domain_wordpress }}/${domain_wordpress}/g" "${file}"
sed -i "s/{{ domain_squidex }}/${domain_squidex}/g" "${file}"
sed -i "s/{{ domain_chatwoot }}/${domain_chatwoot}/g" "${file}"
sed -i "s/{{ domain_chatwoot_helpdesk }}/${domain_chatwoot_helpdesk}/g" "${file}"
sed -i "s/{{ domain_gitea }}/${domain_gitea}/g" "${file}"
sed -i "s/{{ domain_gitea_drone }}/${domain_gitea_drone}/g" "${file}"
sed -i "s/{{ domain_glitchtip }}/${domain_glitchtip}/g" "${file}"
sed -i "s/{{ domain_listmonk }}/${domain_listmonk}/g" "${file}"
sed -i "s/{{ domain_librechat }}/${domain_librechat}/g" "${file}"
sed -i "s/{{ domain_n8n }}/${domain_n8n}/g" "${file}"
sed -i "s/{{ domain_nextcloud }}/${domain_nextcloud}/g" "${file}"
sed -i "s/{{ domain_penpot }}/${domain_penpot}/g" "${file}"
sed -i "s/{{ domain_poste }}/${domain_poste}/g" "${file}"
sed -i "s/{{ domain_umami }}/${domain_umami}/g" "${file}"
sed -i "s/{{ domain_uptime_kuma }}/${domain_uptime_kuma}/g" "${file}"
sed -i "s/{{ domain_windmill }}/${domain_windmill}/g" "${file}"
sed -i "s/{{ domain_calcom }}/${domain_calcom}/g" "${file}"
sed -i "s/{{ domain_odoo }}/${domain_odoo}/g" "${file}"
sed -i "s/{{ domain_collabora }}/${domain_collabora}/g" "${file}"
sed -i "s/{{ domain_activepieces }}/${domain_activepieces}/g" "${file}"
sed -i "s/{{ domain_bot_viewer }}/${domain_bot_viewer}/g" "${file}"
sed -i "s/{{ domain_botlab }}/${domain_botlab}/g" "${file}"
sed -i "s/{{ domain_minio }}/${domain_minio}/g" "${file}"
sed -i "s/{{ domain_s3 }}/${domain_s3}/g" "${file}"
sed -i "s/{{ domain_nocodb }}/${domain_nocodb}/g" "${file}"
sed -i "s/{{ domain_whiteboard }}/${domain_whiteboard}/g" "${file}"
sed -i "s/{{ domain_redash }}/${domain_redash}/g" "${file}"
sed -i "s/{{ domain_documenso }}/${domain_documenso}/g" "${file}"
sed -i "s/{{ domain_keycloak }}/${domain_keycloak}/g" "${file}"
sed -i "s/{{ domain_pdf }}/${domain_pdf}/g" "${file}"
sed -i "s/{{ domain_ghost }}/${domain_ghost}/g" "${file}"
# Credential variables
sed -i "s/{{ wordpresss_mariadb_root_password }}/${wordpresss_mariadb_root_password}/g" "${file}"
sed -i "s/{{ wordpress_db_user }}/${wordpress_db_user}/g" "${file}"
sed -i "s/{{ wordpress_db_password }}/${wordpress_db_password}/g" "${file}"
sed -i "s/{{ squidex_adminemail }}/${squidex_adminemail}/g" "${file}"
sed -i "s/{{ squidex_adminpassword }}/${squidex_adminpassword}/g" "${file}"
sed -i "s/{{ listmonk_admin_username }}/${listmonk_admin_username}/g" "${file}"
sed -i "s/{{ listmonk_admin_password }}/${listmonk_admin_password}/g" "${file}"
sed -i "s/{{ listmonk_db_user }}/${listmonk_db_user}/g" "${file}"
sed -i "s/{{ listmonk_db_password }}/${listmonk_db_password}/g" "${file}"
sed -i "s/{{ gitea_postgres_user }}/${gitea_postgres_user}/g" "${file}"
sed -i "s/{{ gitea_postgres_password }}/${gitea_postgres_password}/g" "${file}"
sed -i "s/{{ umami_app_secret }}/${umami_app_secret}/g" "${file}"
sed -i "s/{{ umami_postgres_user }}/${umami_postgres_user}/g" "${file}"
sed -i "s/{{ umami_postgres_password }}/${umami_postgres_password}/g" "${file}"
sed -i "s/{{ drone_gitea_rpc_secret }}/${drone_gitea_rpc_secret}/g" "${file}"
sed -i "s/{{ windmill_database_password }}/${windmill_database_password}/g" "${file}"
sed -i "s/{{ glitchtip_database_password }}/${glitchtip_database_password}/g" "${file}"
sed -i "s/{{ glitchtip_secret_key }}/${glitchtip_secret_key}/g" "${file}"
sed -i "s/{{ penpot_secret_key }}/${penpot_secret_key}/g" "${file}"
sed -i "s/{{ penpot_db_user }}/${penpot_db_user}/g" "${file}"
sed -i "s/{{ penpot_db_password }}/${penpot_db_password}/g" "${file}"
sed -i "s/{{ nextcloud_postgres_user }}/${nextcloud_postgres_user}/g" "${file}"
sed -i "s/{{ nextcloud_postgres_password }}/${nextcloud_postgres_password}/g" "${file}"
sed -i "s/{{ nextcloud_admin_password }}/${nextcloud_admin_password}/g" "${file}"
sed -i "s/{{ nextcloud_jwt_secret }}/${nextcloud_jwt_secret}/g" "${file}"
sed -i "s/{{ collabora_password }}/${collabora_password}/g" "${file}"
sed -i "s/{{ collabora_user }}/${collabora_user}/g" "${file}"
sed -i "s/{{ chatwoot_secret_key_base }}/${chatwoot_secret_key_base}/g" "${file}"
sed -i "s/{{ chatwoot_redis_password }}/${chatwoot_redis_password}/g" "${file}"
sed -i "s/{{ chatwoot_postgres_username }}/${chatwoot_postgres_username}/g" "${file}"
sed -i "s/{{ chatwoot_postgres_password }}/${chatwoot_postgres_password}/g" "${file}"
sed -i "s/{{ chatwoot_rails_inbound_email_password }}/${chatwoot_rails_inbound_email_password}/g" "${file}"
sed -i "s/{{ n8n_postgres_user }}/${n8n_postgres_user}/g" "${file}"
sed -i "s/{{ n8n_postgres_password }}/${n8n_postgres_password}/g" "${file}"
sed -i "s/{{ calcom_nextauth_secret }}/${calcom_nextauth_secret}/g" "${file}"
sed -i "s/{{ calcom_postgres_user }}/${calcom_postgres_user}/g" "${file}"
sed -i "s/{{ calcom_postgres_password }}/${calcom_postgres_password}/g" "${file}"
sed -i "s/{{ odoo_postgres_user }}/${odoo_postgres_user}/g" "${file}"
sed -i "s/{{ odoo_postgres_password }}/${odoo_postgres_password}/g" "${file}"
sed -i "s/{{ activepieces_api_key }}/${activepieces_api_key}/g" "${file}"
sed -i "s/{{ activepieces_encryption_key }}/${activepieces_encryption_key}/g" "${file}"
sed -i "s/{{ activepieces_jwt_secret }}/${activepieces_jwt_secret}/g" "${file}"
sed -i "s/{{ activepieces_postgres_password }}/${activepieces_postgres_password}/g" "${file}"
sed -i "s/{{ minio_root_user }}/${minio_root_user}/g" "${file}"
sed -i "s/{{ minio_root_password }}/${minio_root_password}/g" "${file}"
sed -i "s/{{ typebot_encryption_secret }}/${typebot_encryption_secret}/g" "${file}"
sed -i "s/{{ nocodb_postgres_password }}/${nocodb_postgres_password}/g" "${file}"
sed -i "s/{{ typebot_postgres_password }}/${typebot_postgres_password}/g" "${file}"
sed -i "s/{{ redash_secret_key }}/${redash_secret_key}/g" "${file}"
sed -i "s/{{ redash_cookie_secret }}/${redash_cookie_secret}/g" "${file}"
sed -i "s/{{ redash_postgres_user }}/${redash_postgres_user}/g" "${file}"
sed -i "s/{{ redash_postgres_password }}/${redash_postgres_password}/g" "${file}"
sed -i "s/{{ librechat_postgres_password }}/${librechat_postgres_password}/g" "${file}"
sed -i "s/{{ librechat_postgres_user }}/${librechat_postgres_user}/g" "${file}"
sed -i "s/{{ librechat_jwt_secret }}/${librechat_jwt_secret}/g" "${file}"
sed -i "s/{{ librechat_jwt_refresh_secret }}/${librechat_jwt_refresh_secret}/g" "${file}"
sed -i "s/{{ documenso_postgres_user }}/${documenso_postgres_user}/g" "${file}"
sed -i "s/{{ documenso_postgres_password }}/${documenso_postgres_password}/g" "${file}"
sed -i "s/{{ documenso_nextauth_secret }}/${documenso_nextauth_secret}/g" "${file}"
sed -i "s/{{ documenso_encryption_key }}/${documenso_encryption_key}/g" "${file}"
sed -i "s/{{ documenso_encryption_secondary_key }}/${documenso_encryption_secondary_key}/g" "${file}"
sed -i "s/{{ ghost_mysql_password }}/${ghost_mysql_password}/g" "${file}"
sed -i "s/{{ ghost_s3_access_key }}/${ghost_s3_access_key}/g" "${file}"
sed -i "s/{{ ghost_s3_secret_key }}/${ghost_s3_secret_key}/g" "${file}"
sed -i "s/{{ keycloak_postgres_password }}/${keycloak_postgres_password}/g" "${file}"
sed -i "s/{{ keycloak_admin_password }}/${keycloak_admin_password}/g" "${file}"
sed -i "s/{{ keycloak_grafana_password }}/${keycloak_grafana_password}/g" "${file}"
sed -i "s/{{ stirlingpdf_postgres_user }}/${stirlingpdf_postgres_user}/g" "${file}"
sed -i "s/{{ stirlingpdf_postgres_password }}/${stirlingpdf_postgres_password}/g" "${file}"
sed -i "s/{{ stirlingpdf_api_key }}/${stirlingpdf_api_key}/g" "${file}"
sed -i "s/{{ sysadmin_agent_token }}/${sysadmin_agent_token}/g" "${file}"
sed -i "s/{{ sysadmin_registration_token }}/${sysadmin_registration_token}/g" "${file}"
fi
done
log_info "All placeholders replaced successfully."
# ============================================================================
# GENERATE ENV FILES
# ============================================================================
log_info "Generating centralized environment files..."
mkdir -p "${ENV_DIR}"
# Write master credentials file for reference
cat > "${ENV_DIR}/credentials.env" <<EOF
# LetsBe Cloud Credentials - Generated $(date -Iseconds)
# Customer: ${customer}
# Domain: ${domain}
# Company: ${company_name}
#
# KEEP THIS FILE SECURE - Contains all generated passwords
#
# WordPress
WORDPRESS_DB_USER=${wordpress_db_user}
WORDPRESS_DB_PASSWORD=${wordpress_db_password}
WORDPRESS_MARIADB_ROOT_PASSWORD=${wordpresss_mariadb_root_password}
# Nextcloud
NEXTCLOUD_ADMIN_PASSWORD=${nextcloud_admin_password}
NEXTCLOUD_POSTGRES_USER=${nextcloud_postgres_user}
NEXTCLOUD_POSTGRES_PASSWORD=${nextcloud_postgres_password}
# Listmonk
LISTMONK_ADMIN_USER=${listmonk_admin_username}
LISTMONK_ADMIN_PASSWORD=${listmonk_admin_password}
# MinIO
MINIO_ROOT_USER=${minio_root_user}
MINIO_ROOT_PASSWORD=${minio_root_password}
# Keycloak
KEYCLOAK_ADMIN_PASSWORD=${keycloak_admin_password}
# Sysadmin Agent
# Note: Registration token is one-time use. After initial registration,
# agent credentials are persisted to ~/.letsbe-agent/credentials.json
# and the agent can restart without needing the registration token again.
SYSADMIN_REGISTRATION_TOKEN=${sysadmin_registration_token}
# SYSADMIN_AGENT_TOKEN=${sysadmin_agent_token} # Deprecated
EOF
# Add Docker Hub section if docker_user was provided
if [[ -n "${docker_user}" ]]; then
cat >> "${ENV_DIR}/credentials.env" <<EOF
# Docker Hub
DOCKER_HUB_USER=${docker_user}
# Note: Token not stored for security - regenerate from Docker Hub if needed
EOF
fi
chmod 640 "${ENV_DIR}/credentials.env"
log_info "Environment setup complete."
log_info "Credentials saved to: ${ENV_DIR}/credentials.env"