#!/bin/sh # shellcheck shell=sh # # docker-compose-manager.sh - Manage Docker Compose projects across directories # POSIX-compliant utility for up/down/restart/status/pull/logs operations # set -eu # Try to set pipefail if available (bash/zsh), ignore if strictly POSIX sh (dash) # shellcheck disable=SC3040 (set -o pipefail 2>/dev/null) && set -o pipefail # Ensure standard sorting and character handling export LC_ALL=C SCRIPT_NAME=$(basename "$0") VERSION="0.3.3" UPDATE_URL="https://raw.githubusercontent.com/buildplan/dcm/refs/heads/main/docker-compose-manager.sh" # --- Terminal color support detection --- if [ -t 1 ]; then RED='\033[31m' GREEN='\033[32m' YELLOW='\033[33m' BLUE='\033[34m' MAGENTA='\033[35m' CYAN='\033[36m' BOLD='\033[1m' RESET='\033[0m' else RED='' GREEN='' YELLOW='' BLUE='' MAGENTA='' CYAN='' BOLD='' RESET='' fi # --- Global state --- found_any=0 exit_code=0 DRY_RUN=0 FAILED_DIRS="" SUCCESS_DIRS="" SKIP_CONFIRM=0 # --- Signal handling for clean exit --- # shellcheck disable=SC2329 cleanup() { exit_status=$? if [ "$exit_status" -eq 130 ]; then printf '\n%bInterrupted. Exiting.%b\n' "${YELLOW}" "${RESET}" fi exit "$exit_status" } trap cleanup INT TERM # --- Dependency check --- check_dependency() { if ! command -v docker >/dev/null 2>&1; then printf '%bError:%b docker is not installed or not in PATH.\n' "${RED}" "${RESET}" >&2 exit 1 fi if ! docker info >/dev/null 2>&1; then printf '%bError:%b Docker daemon is not running.\n' "${RED}" "${RESET}" >&2 exit 1 fi if ! docker compose version >/dev/null 2>&1; then printf '%bError:%b docker compose not available. Install Docker Compose v2 (plugin).\n' \ "${RED}" "${RESET}" >&2 exit 1 fi } # --- Self-Update function --- update_script() { printf '%bInfo:%b Checking for updates...\n' "${BLUE}" "${RESET}" script_path=$(command -v "$0" 2>/dev/null || echo "$0") if [ ! -w "$script_path" ]; then printf '%bError:%b No write permission to %b%s%b.\n' \ "${RED}" "${RESET}" "${CYAN}" "$script_path" "${RESET}" >&2 printf 'Try running with sudo: %b%s%b\n' "${YELLOW}" "sudo $SCRIPT_NAME update" "${RESET}" >&2 exit 1 fi tmp_file="/tmp/dcm_update_$$" # Download using curl or wget if command -v curl >/dev/null 2>&1; then if ! curl -sSL "$UPDATE_URL" -o "$tmp_file"; then printf '%bError:%b Failed to download update via curl.\n' "${RED}" "${RESET}" >&2 exit 1 fi elif command -v wget >/dev/null 2>&1; then if ! wget -qO "$tmp_file" "$UPDATE_URL"; then printf '%bError:%b Failed to download update via wget.\n' "${RED}" "${RESET}" >&2 exit 1 fi else printf '%bError:%b curl or wget is required to update.\n' "${RED}" "${RESET}" >&2 exit 1 fi # Validate if ! head -n 1 "$tmp_file" | grep -q "^#!/bin/sh"; then printf '%bError:%b Downloaded file is invalid. Update aborted.\n' "${RED}" "${RESET}" >&2 rm -f "$tmp_file" exit 1 fi # Overwrite the current script using and make executable cat "$tmp_file" > "$script_path" chmod +x "$script_path" rm -f "$tmp_file" printf '%bSuccess:%b Script updated successfully!\n' "${GREEN}" "${RESET}" exit 0 } # --- Help and version --- print_help() { printf '%b%bUsage:%b\n' "${BOLD}" "${CYAN}" "${RESET}" printf ' ./%s [OPTIONS] [ACTION] [DIR1 DIR2 ...]\n' "$SCRIPT_NAME" printf ' ./%s -h | --help\n' "$SCRIPT_NAME" printf ' ./%s -v | --version\n\n' "$SCRIPT_NAME" printf '%b%bDescription:%b\n' "${BOLD}" "${CYAN}" "${RESET}" cat <<'EOF' Run 'docker compose' (up/down/restart/status/pull/logs) in one or more directories. Detects and merges compose files in deterministic order: 1. Standard files: compose.yml, docker-compose.yml 2. Pattern files (Sorted Alphabetically): - compose-*.yml - docker-compose-*.yml - *-compose.yml (e.g., myapp-compose.yml) - *_compose.yml (e.g., db_compose.yml) EOF printf '\n%b%bOptions:%b\n' "${BOLD}" "${CYAN}" "${RESET}" cat <&2 return 1 fi # --- Phase 1: Collect standard priority files (in specific order) --- for f in "compose.yml" "compose.yaml" "docker-compose.yml" "docker-compose.yaml"; do if [ -f "$dir/$f" ]; then compose_files="${compose_files}${dir}/${f}|" fi done # --- Phase 2: Collect pattern-based files for sorting --- temp_file_list="" for pattern in "compose-*.yml" "compose-*.yaml" \ "docker-compose-*.yml" "docker-compose-*.yaml" \ "*-compose.yml" "*-compose.yaml" \ "*_compose.yml" "*_compose.yaml"; do for f in "$dir"/$pattern; do [ -f "$f" ] || continue case "$compose_files" in *"|${f}|"*) continue ;; *) temp_file_list="${temp_file_list}${f} " ;; esac done done if [ -n "$temp_file_list" ]; then while IFS= read -r f; do [ -n "$f" ] && compose_files="${compose_files}${f}|" done <&2 FAILED_DIRS="${FAILED_DIRS} ${folder_name}" exit_code=1 fi } # --- Confirmation prompt for destructive operations --- confirm_action() { if [ "$SKIP_CONFIRM" -eq 1 ] || [ ! -t 0 ]; then return 0 fi case "$ACTION" in down|restart) printf '%b%bWarning:%b This will %s all discovered Docker Compose projects.\n' \ "${BOLD}" "${YELLOW}" "${RESET}" "$ACTION" printf 'Continue? (y/N): ' IFS= read -r confirm || return 1 case "$confirm" in y|Y|yes|YES) return 0 ;; *) printf 'Operation cancelled.\n' exit 0 ;; esac ;; esac } # --- Argument parsing --- check_dependency ACTION="" EXCLUDES_INPUT="" while [ "$#" -gt 0 ]; do case "$1" in -h|--help) print_help; exit 0 ;; -v|--version) print_version; exit 0 ;; -n|--dry-run) DRY_RUN=1; shift ;; -y|--yes) SKIP_CONFIRM=1; shift ;; -u|--update) update_script ;; -*) printf '%bError:%b unknown option %b%s%b\n' \ "${RED}" "${RESET}" "${CYAN}" "$1" "${RESET}" >&2 print_help exit 1 ;; *) if [ -z "$ACTION" ]; then ACTION="$1" else break fi shift ;; esac done # Interactive action prompt if not provided if [ -z "$ACTION" ]; then printf 'Select action (up/down/restart/pull/logs/status/update): ' IFS= read -r ACTION || exit 1 [ -z "$ACTION" ] && { print_help; exit 1; } fi # Validate action case "$ACTION" in up|down|restart|status|pull|logs|update) if [ "$ACTION" = "update" ]; then update_script fi ;; *) printf '%bError:%b invalid action %b%s%b\n' \ "${RED}" "${RESET}" "${CYAN}" "$ACTION" "${RESET}" >&2 print_help exit 1 ;; esac # --- Execution --- if [ "$#" -gt 0 ]; then # Direct directory arguments provided for name in "$@"; do run_compose_in_dir "$name" || true done else # Interactive Directory Scan Mode if [ -t 1 ]; then printf '%b%bInteractive mode%b\n' "${BOLD}" "${CYAN}" "${RESET}" printf 'Base directory to scan [%s]: ' "$(pwd)" fi # Read base dir, default to pwd if empty IFS= read -r BASE_DIR || BASE_DIR="" if [ -z "$BASE_DIR" ]; then BASE_DIR="$(pwd)" fi if [ ! -d "$BASE_DIR" ]; then printf '%bError:%b %b%s%b is not a directory.\n' \ "${RED}" "${RESET}" "${CYAN}" "$BASE_DIR" "${RESET}" >&2 exit 1 fi # Load config file exclusions if exists load_config "$BASE_DIR" # Interactive exclusion input if [ -t 0 ]; then if [ -n "$EXCLUDES_INPUT" ]; then printf 'Current exclusions from config: %b%s%b\n' \ "${CYAN}" "$EXCLUDES_INPUT" "${RESET}" printf 'Additional folders to exclude (space-separated, or press Enter): ' else printf 'Folders to exclude (space-separated names, or press Enter): ' fi IFS= read -r extra_excludes || true if [ -n "$extra_excludes" ]; then EXCLUDES_INPUT="${EXCLUDES_INPUT} ${extra_excludes}" fi fi # Confirmation prompt before executing on multiple directories confirm_action # Scan and execute for dir in "$BASE_DIR"/*/; do [ -d "$dir" ] || continue dir="${dir%/}" folder_name=$(basename "$dir") if is_excluded "$folder_name"; then printf '%b------------------------------------------------%b\n' "${MAGENTA}" "${RESET}" printf '%bSkipping excluded folder:%b %b%s%b\n' \ "${YELLOW}" "${RESET}" "${CYAN}" "$folder_name" "${RESET}" continue fi run_compose_in_dir "$dir" || true done fi # --- Summary --- if [ "$found_any" -eq 0 ]; then printf '\n%bNo subdirectories with compose files found.%b\n' "${YELLOW}" "${RESET}" else if [ "$ACTION" != "logs" ]; then printf '\n%b%b=== Summary ===%b\n' "${BOLD}" "${MAGENTA}" "${RESET}" if [ -n "$SUCCESS_DIRS" ]; then SUCCESS_DIRS="${SUCCESS_DIRS# }" printf '%b [OK] Success:%b %s\n' "${GREEN}" "${RESET}" "$SUCCESS_DIRS" fi if [ -n "$FAILED_DIRS" ]; then FAILED_DIRS="${FAILED_DIRS# }" printf '%b [!!] Failed:%b %s\n' "${RED}" "${RESET}" "$FAILED_DIRS" fi printf '\n' fi fi exit "$exit_code"