- Add one-line installer (scripts/install.sh) with platform detection - Add doctor command (scripts/commands/doctor.sh) for environment diagnostics - Add shared libraries: dependencies, docker, platform, validation - Update README with quick-start installer instructions - Add AGENTS.md with codebase patterns for AI agent context Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
553 lines
18 KiB
Bash
Executable File
553 lines
18 KiB
Bash
Executable File
#!/bin/bash
|
||
set -euo pipefail
|
||
|
||
# ============================================================================
|
||
# Mosaic Stack Doctor
|
||
# ============================================================================
|
||
# Diagnostic and repair tool for Mosaic Stack installations.
|
||
# Run without arguments for interactive mode, or use flags for CI/CD.
|
||
|
||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||
PROJECT_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)"
|
||
|
||
# Source library files
|
||
# shellcheck source=../lib/platform.sh
|
||
source "$SCRIPT_DIR/../lib/platform.sh"
|
||
# shellcheck source=../lib/dependencies.sh
|
||
source "$SCRIPT_DIR/../lib/dependencies.sh"
|
||
# shellcheck source=../lib/docker.sh
|
||
source "$SCRIPT_DIR/../lib/docker.sh"
|
||
# shellcheck source=../lib/validation.sh
|
||
source "$SCRIPT_DIR/../lib/validation.sh"
|
||
|
||
# ============================================================================
|
||
# Configuration
|
||
# ============================================================================
|
||
|
||
FIX_MODE=false
|
||
JSON_OUTPUT=false
|
||
VERBOSE=false
|
||
ENV_FILE="$PROJECT_ROOT/.env"
|
||
COMPOSE_FILE="$PROJECT_ROOT/docker-compose.yml"
|
||
MODE=""
|
||
|
||
# ============================================================================
|
||
# Help
|
||
# ============================================================================
|
||
|
||
print_usage() {
|
||
cat << EOF
|
||
Mosaic Stack Doctor - Diagnostic and repair tool
|
||
|
||
USAGE:
|
||
./scripts/commands/doctor.sh [OPTIONS]
|
||
|
||
OPTIONS:
|
||
-h, --help Show this help message
|
||
--fix Attempt to automatically fix issues
|
||
--json Output results in JSON format
|
||
--verbose Show detailed output
|
||
--env FILE Path to .env file (default: .env)
|
||
--compose FILE Path to docker-compose.yml (default: docker-compose.yml)
|
||
--mode MODE Deployment mode: docker or native (auto-detected)
|
||
|
||
EXIT CODES:
|
||
0 All checks passed
|
||
1 Some checks failed
|
||
2 Critical failure
|
||
|
||
EXAMPLES:
|
||
# Run all checks
|
||
./scripts/commands/doctor.sh
|
||
|
||
# Attempt automatic fixes
|
||
./scripts/commands/doctor.sh --fix
|
||
|
||
# JSON output for CI/CD
|
||
./scripts/commands/doctor.sh --json
|
||
|
||
# Verbose output
|
||
./scripts/commands/doctor.sh --verbose
|
||
|
||
EOF
|
||
}
|
||
|
||
# ============================================================================
|
||
# Argument Parsing
|
||
# ============================================================================
|
||
|
||
parse_arguments() {
|
||
while [[ $# -gt 0 ]]; do
|
||
case "$1" in
|
||
-h|--help)
|
||
print_usage
|
||
exit 0
|
||
;;
|
||
--fix)
|
||
FIX_MODE=true
|
||
shift
|
||
;;
|
||
--json)
|
||
JSON_OUTPUT=true
|
||
shift
|
||
;;
|
||
--verbose)
|
||
VERBOSE=true
|
||
shift
|
||
;;
|
||
--env)
|
||
ENV_FILE="$2"
|
||
shift 2
|
||
;;
|
||
--compose)
|
||
COMPOSE_FILE="$2"
|
||
shift 2
|
||
;;
|
||
--mode)
|
||
MODE="$2"
|
||
shift 2
|
||
;;
|
||
*)
|
||
echo "Unknown option: $1"
|
||
exit 1
|
||
;;
|
||
esac
|
||
done
|
||
|
||
# Auto-detect mode if not specified
|
||
if [[ -z "$MODE" ]]; then
|
||
if [[ -f "$COMPOSE_FILE" ]] && command -v docker &>/dev/null && docker info &>/dev/null; then
|
||
MODE="docker"
|
||
else
|
||
MODE="native"
|
||
fi
|
||
fi
|
||
}
|
||
|
||
# ============================================================================
|
||
# JSON Output Helpers
|
||
# ============================================================================
|
||
|
||
json_start() {
|
||
if [[ "$JSON_OUTPUT" == true ]]; then
|
||
echo "{"
|
||
echo ' "timestamp": "'$(date -u +"%Y-%m-%dT%H:%M:%SZ")'",'
|
||
echo ' "version": "1.0.0",'
|
||
echo ' "mode": "'$MODE'",'
|
||
echo ' "checks": ['
|
||
fi
|
||
}
|
||
|
||
json_end() {
|
||
local errors="$1"
|
||
local warnings="$2"
|
||
|
||
if [[ "$JSON_OUTPUT" == true ]]; then
|
||
echo ""
|
||
echo " ],"
|
||
echo ' "summary": {'
|
||
echo ' "errors": '$errors','
|
||
echo ' "warnings": '$warnings','
|
||
echo ' "status": "'$([ "$errors" -gt 0 ] && echo "failed" || ([ "$warnings" -gt 0 ] && echo "warning" || echo "passed"))'"'
|
||
echo ' }'
|
||
echo "}"
|
||
fi
|
||
}
|
||
|
||
json_check() {
|
||
local name="$1"
|
||
local status="$2"
|
||
local message="$3"
|
||
local first="${4:-true}"
|
||
|
||
if [[ "$JSON_OUTPUT" == true ]]; then
|
||
[[ "$first" != "true" ]] && echo ","
|
||
echo -n ' {"name": "'$name'", "status": "'$status'", "message": "'$message'"}'
|
||
fi
|
||
}
|
||
|
||
# ============================================================================
|
||
# Fix Functions
|
||
# ============================================================================
|
||
|
||
fix_env_permissions() {
|
||
echo -e "${WARN}→${NC} Fixing .env permissions..."
|
||
chmod 600 "$ENV_FILE"
|
||
echo -e "${SUCCESS}✓${NC} Fixed"
|
||
}
|
||
|
||
fix_docker_permissions() {
|
||
echo -e "${WARN}→${NC} Adding user to docker group..."
|
||
maybe_sudo usermod -aG docker "$USER"
|
||
echo -e "${SUCCESS}✓${NC} User added to docker group"
|
||
echo -e "${INFO}ℹ${NC} Run 'newgrp docker' or log out/in for changes to take effect"
|
||
}
|
||
|
||
start_docker_daemon() {
|
||
echo -e "${WARN}→${NC} Starting Docker daemon..."
|
||
maybe_sudo systemctl start docker
|
||
sleep 3
|
||
if docker info &>/dev/null; then
|
||
echo -e "${SUCCESS}✓${NC} Docker started"
|
||
else
|
||
echo -e "${ERROR}✗${NC} Failed to start Docker"
|
||
return 1
|
||
fi
|
||
}
|
||
|
||
restart_containers() {
|
||
echo -e "${WARN}→${NC} Restarting containers..."
|
||
docker_compose_down "$COMPOSE_FILE" "$ENV_FILE"
|
||
docker_compose_up "$COMPOSE_FILE" "$ENV_FILE"
|
||
echo -e "${SUCCESS}✓${NC} Containers restarted"
|
||
}
|
||
|
||
generate_missing_secrets() {
|
||
echo -e "${WARN}→${NC} Generating missing secrets..."
|
||
|
||
# Load existing env
|
||
if [[ -f "$ENV_FILE" ]]; then
|
||
set -a
|
||
# shellcheck source=/dev/null
|
||
source "$ENV_FILE" 2>/dev/null || true
|
||
set +a
|
||
fi
|
||
|
||
local updated=false
|
||
|
||
# Check each secret
|
||
if [[ -z "${JWT_SECRET:-}" ]] || is_placeholder "${JWT_SECRET:-}"; then
|
||
JWT_SECRET=$(openssl rand -base64 32)
|
||
echo "JWT_SECRET=$JWT_SECRET" >> "$ENV_FILE"
|
||
updated=true
|
||
fi
|
||
|
||
if [[ -z "${BETTER_AUTH_SECRET:-}" ]] || is_placeholder "${BETTER_AUTH_SECRET:-}"; then
|
||
BETTER_AUTH_SECRET=$(openssl rand -base64 32)
|
||
echo "BETTER_AUTH_SECRET=$BETTER_AUTH_SECRET" >> "$ENV_FILE"
|
||
updated=true
|
||
fi
|
||
|
||
if [[ -z "${ENCRYPTION_KEY:-}" ]] || is_placeholder "${ENCRYPTION_KEY:-}"; then
|
||
ENCRYPTION_KEY=$(openssl rand -hex 32)
|
||
echo "ENCRYPTION_KEY=$ENCRYPTION_KEY" >> "$ENV_FILE"
|
||
updated=true
|
||
fi
|
||
|
||
if [[ -z "${POSTGRES_PASSWORD:-}" ]] || is_placeholder "${POSTGRES_PASSWORD:-}"; then
|
||
POSTGRES_PASSWORD=$(openssl rand -base64 24 | tr -d '/+=' | head -c 32)
|
||
# Update DATABASE_URL too
|
||
DATABASE_URL="postgresql://mosaic:${POSTGRES_PASSWORD}@postgres:5432/mosaic"
|
||
sed -i "s|^POSTGRES_PASSWORD=.*|POSTGRES_PASSWORD=$POSTGRES_PASSWORD|" "$ENV_FILE" 2>/dev/null || \
|
||
echo "POSTGRES_PASSWORD=$POSTGRES_PASSWORD" >> "$ENV_FILE"
|
||
sed -i "s|^DATABASE_URL=.*|DATABASE_URL=$DATABASE_URL|" "$ENV_FILE" 2>/dev/null || \
|
||
echo "DATABASE_URL=$DATABASE_URL" >> "$ENV_FILE"
|
||
updated=true
|
||
fi
|
||
|
||
if [[ "$updated" == true ]]; then
|
||
echo -e "${SUCCESS}✓${NC} Secrets generated"
|
||
else
|
||
echo -e "${INFO}ℹ${NC} All secrets already set"
|
||
fi
|
||
}
|
||
|
||
# ============================================================================
|
||
# Check Functions
|
||
# ============================================================================
|
||
|
||
run_checks() {
|
||
local errors=0
|
||
local warnings=0
|
||
local first=true
|
||
|
||
json_start
|
||
|
||
# System requirements
|
||
echo -e "${BOLD}━━━ System Requirements ━━━${NC}"
|
||
check_system_requirements 2048 10
|
||
local result=$?
|
||
[[ $result -eq $CHECK_FAIL ]] && ((errors++))
|
||
[[ $result -eq $CHECK_WARN ]] && ((warnings++))
|
||
json_check "system_requirements" "$( [[ $result -eq 0 ]] && echo "pass" || ([[ $result -eq 1 ]] && echo "warn" || echo "fail") )" "RAM and disk check" "$first"
|
||
first=false
|
||
echo ""
|
||
|
||
# Environment file
|
||
echo -e "${BOLD}━━━ Environment File ━━━${NC}"
|
||
check_env_file "$ENV_FILE"
|
||
result=$?
|
||
[[ $result -eq $CHECK_FAIL ]] && ((errors++))
|
||
[[ $result -eq $CHECK_WARN ]] && ((warnings++))
|
||
json_check "env_file" "$( [[ $result -eq 0 ]] && echo "pass" || ([[ $result -eq 1 ]] && echo "warn" || echo "fail") )" ".env file exists" "$first"
|
||
echo ""
|
||
|
||
# Required variables
|
||
check_required_env "$ENV_FILE"
|
||
result=$?
|
||
[[ $result -eq $CHECK_FAIL ]] && ((errors++))
|
||
[[ $result -eq $CHECK_WARN ]] && ((warnings++))
|
||
json_check "required_env" "$( [[ $result -eq 0 ]] && echo "pass" || ([[ $result -eq 1 ]] && echo "warn" || echo "fail") )" "Required environment variables" "$first"
|
||
echo ""
|
||
|
||
# Secret strength
|
||
check_secrets "$ENV_FILE"
|
||
result=$?
|
||
[[ $result -eq $CHECK_FAIL ]] && ((errors++))
|
||
[[ $result -eq $CHECK_WARN ]] && ((warnings++))
|
||
json_check "secrets" "$( [[ $result -eq 0 ]] && echo "pass" || ([[ $result -eq 1 ]] && echo "warn" || echo "fail") ")" "Secret strength and validity" "$first"
|
||
echo ""
|
||
|
||
# File permissions
|
||
check_env_permissions "$ENV_FILE"
|
||
result=$?
|
||
[[ $result -eq $CHECK_FAIL ]] && ((errors++))
|
||
[[ $result -eq $CHECK_WARN ]] && ((warnings++))
|
||
json_check "env_permissions" "$( [[ $result -eq 0 ]] && echo "pass" || ([[ $result -eq 1 ]] && echo "warn" || echo "fail") ")" ".env file permissions" "$first"
|
||
echo ""
|
||
|
||
if [[ "$MODE" == "docker" ]]; then
|
||
# Docker checks
|
||
echo -e "${BOLD}━━━ Docker ━━━${NC}"
|
||
|
||
check_docker
|
||
result=$?
|
||
[[ $result -ne 0 ]] && ((errors++))
|
||
json_check "docker" "$( [[ $result -eq 0 ]] && echo "pass" || "fail")" "Docker availability" "$first"
|
||
echo ""
|
||
|
||
if [[ $result -eq 0 ]]; then
|
||
check_docker_compose
|
||
result=$?
|
||
[[ $result -ne 0 ]] && ((errors++))
|
||
json_check "docker_compose" "$( [[ $result -eq 0 ]] && echo "pass" || "fail")" "Docker Compose availability" "$first"
|
||
echo ""
|
||
|
||
# Container status
|
||
check_docker_containers "$COMPOSE_FILE"
|
||
result=$?
|
||
[[ $result -eq $CHECK_FAIL ]] && ((errors++))
|
||
[[ $result -eq $CHECK_WARN ]] && ((warnings++))
|
||
json_check "containers" "$( [[ $result -eq 0 ]] && echo "pass" || ([[ $result -eq 1 ]] && echo "warn" || echo "fail") ")" "Container status" "$first"
|
||
echo ""
|
||
|
||
# Container health
|
||
check_container_health
|
||
result=$?
|
||
[[ $result -eq $CHECK_FAIL ]] && ((errors++))
|
||
[[ $result -eq $CHECK_WARN ]] && ((warnings++))
|
||
json_check "container_health" "$( [[ $result -eq 0 ]] && echo "pass" || ([[ $result -eq 1 ]] && echo "warn" || echo "fail") ")" "Container health checks" "$first"
|
||
echo ""
|
||
|
||
# Database
|
||
check_database_connection
|
||
result=$?
|
||
[[ $result -eq $CHECK_FAIL ]] && ((errors++))
|
||
[[ $result -eq $CHECK_WARN ]] && ((warnings++))
|
||
json_check "database" "$( [[ $result -eq 0 ]] && echo "pass" || ([[ $result -eq 1 ]] && echo "warn" || echo "fail") ")" "Database connectivity" "$first"
|
||
echo ""
|
||
|
||
# Valkey
|
||
check_valkey_connection
|
||
result=$?
|
||
[[ $result -eq $CHECK_FAIL ]] && ((errors++))
|
||
[[ $result -eq $CHECK_WARN ]] && ((warnings++))
|
||
json_check "cache" "$( [[ $result -eq 0 ]] && echo "pass" || ([[ $result -eq 1 ]] && echo "warn" || echo "fail") ")" "Valkey/Redis connectivity" "$first"
|
||
echo ""
|
||
|
||
# API
|
||
check_api_health
|
||
result=$?
|
||
[[ $result -eq $CHECK_FAIL ]] && ((errors++))
|
||
[[ $result -eq $CHECK_WARN ]] && ((warnings++))
|
||
json_check "api" "$( [[ $result -eq 0 ]] && echo "pass" || ([[ $result -eq 1 ]] && echo "warn" || echo "fail") ")" "API health endpoint" "$first"
|
||
echo ""
|
||
|
||
# Web
|
||
check_web_health
|
||
result=$?
|
||
[[ $result -eq $CHECK_FAIL ]] && ((errors++))
|
||
[[ $result -eq $CHECK_WARN ]] && ((warnings++))
|
||
json_check "web" "$( [[ $result -eq 0 ]] && echo "pass" || ([[ $result -eq 1 ]] && echo "warn" || echo "fail") ")" "Web frontend" "$first"
|
||
echo ""
|
||
fi
|
||
else
|
||
# Native mode checks
|
||
echo -e "${BOLD}━━━ Native Dependencies ━━━${NC}"
|
||
|
||
check_node
|
||
result=$?
|
||
[[ $result -ne 0 ]] && ((errors++))
|
||
json_check "nodejs" "$( [[ $result -eq 0 ]] && echo "pass" || "fail")" "Node.js" "$first"
|
||
echo ""
|
||
|
||
check_pnpm
|
||
result=$?
|
||
[[ $result -ne 0 ]] && ((errors++))
|
||
json_check "pnpm" "$( [[ $result -eq 0 ]] && echo "pass" || "fail")" "pnpm" "$first"
|
||
echo ""
|
||
|
||
check_postgres
|
||
result=$?
|
||
[[ $result -ne 0 ]] && ((warnings++))
|
||
json_check "postgresql" "$( [[ $result -eq 0 ]] && echo "pass" || "warn")" "PostgreSQL" "$first"
|
||
echo ""
|
||
fi
|
||
|
||
json_end $errors $warnings
|
||
|
||
return $([ $errors -eq 0 ] && echo 0 || echo 1)
|
||
}
|
||
|
||
# ============================================================================
|
||
# Interactive Fix Mode
|
||
# ============================================================================
|
||
|
||
interactive_fix() {
|
||
local issues=()
|
||
|
||
# Collect issues
|
||
if [[ ! -f "$ENV_FILE" ]]; then
|
||
issues+=("env_missing:Missing .env file")
|
||
fi
|
||
|
||
if [[ -f "$ENV_FILE" ]]; then
|
||
# Check permissions
|
||
local perms
|
||
perms=$(stat -c "%a" "$ENV_FILE" 2>/dev/null || stat -f "%OLp" "$ENV_FILE" 2>/dev/null)
|
||
if [[ "$perms" =~ [0-7][0-7][4-7]$ ]]; then
|
||
issues+=("env_perms:.env is world-readable")
|
||
fi
|
||
|
||
# Check secrets
|
||
set -a
|
||
# shellcheck source=/dev/null
|
||
source "$ENV_FILE" 2>/dev/null || true
|
||
set +a
|
||
|
||
if [[ -z "${JWT_SECRET:-}" ]] || is_placeholder "${JWT_SECRET:-}"; then
|
||
issues+=("secrets:Missing or invalid secrets")
|
||
fi
|
||
fi
|
||
|
||
if [[ "$MODE" == "docker" ]]; then
|
||
if ! docker info &>/dev/null; then
|
||
local docker_result
|
||
docker_result=$(docker info 2>&1)
|
||
if [[ "$docker_result" =~ "permission denied" ]]; then
|
||
issues+=("docker_perms:Docker permission denied")
|
||
elif [[ "$docker_result" =~ "Cannot connect to the Docker daemon" ]]; then
|
||
issues+=("docker_daemon:Docker daemon not running")
|
||
fi
|
||
fi
|
||
fi
|
||
|
||
if [[ ${#issues[@]} -eq 0 ]]; then
|
||
echo -e "${SUCCESS}✓${NC} No fixable issues found"
|
||
return 0
|
||
fi
|
||
|
||
echo -e "${BOLD}Found ${#issues[@]} fixable issue(s):${NC}"
|
||
echo ""
|
||
|
||
for issue in "${issues[@]}"; do
|
||
local code="${issue%%:*}"
|
||
local desc="${issue#*:}"
|
||
echo " - $desc"
|
||
done
|
||
|
||
echo ""
|
||
read -r -p "Fix these issues? [Y/n]: " fix_them
|
||
case "$fix_them" in
|
||
n|N)
|
||
echo -e "${INFO}ℹ${NC} Skipping fixes"
|
||
return 0
|
||
;;
|
||
esac
|
||
|
||
# Apply fixes
|
||
for issue in "${issues[@]}"; do
|
||
local code="${issue%%:*}"
|
||
|
||
case "$code" in
|
||
env_perms)
|
||
fix_env_permissions
|
||
;;
|
||
docker_perms)
|
||
fix_docker_permissions
|
||
;;
|
||
docker_daemon)
|
||
start_docker_daemon
|
||
;;
|
||
secrets)
|
||
generate_missing_secrets
|
||
;;
|
||
*)
|
||
echo -e "${WARN}⚠${NC} Unknown issue: $code"
|
||
;;
|
||
esac
|
||
done
|
||
|
||
echo ""
|
||
echo -e "${SUCCESS}✓${NC} Fixes applied"
|
||
}
|
||
|
||
# ============================================================================
|
||
# Main
|
||
# ============================================================================
|
||
|
||
main() {
|
||
parse_arguments "$@"
|
||
|
||
# Configure verbose mode
|
||
if [[ "$VERBOSE" == true ]]; then
|
||
set -x
|
||
fi
|
||
|
||
# Show banner (unless JSON mode)
|
||
if [[ "$JSON_OUTPUT" != true ]]; then
|
||
echo ""
|
||
echo -e "${BOLD}════════════════════════════════════════════════════════════${NC}"
|
||
echo -e "${BOLD} Mosaic Stack Doctor${NC}"
|
||
echo -e "${BOLD}════════════════════════════════════════════════════════════${NC}"
|
||
echo ""
|
||
echo -e " Mode: ${INFO}$MODE${NC}"
|
||
echo -e " Env: ${INFO}$ENV_FILE${NC}"
|
||
echo -e " Compose: ${INFO}$COMPOSE_FILE${NC}"
|
||
echo ""
|
||
fi
|
||
|
||
# Run checks
|
||
run_checks
|
||
local check_result=$?
|
||
|
||
# Fix mode
|
||
if [[ "$FIX_MODE" == true && "$JSON_OUTPUT" != true && $check_result -ne 0 ]]; then
|
||
echo ""
|
||
echo -e "${BOLD}━━━ Fix Mode ━━━${NC}"
|
||
echo ""
|
||
interactive_fix
|
||
fi
|
||
|
||
# Summary (unless JSON mode)
|
||
if [[ "$JSON_OUTPUT" != true ]]; then
|
||
echo ""
|
||
echo -e "${BOLD}════════════════════════════════════════════════════════════${NC}"
|
||
|
||
if [[ $check_result -eq 0 ]]; then
|
||
echo -e "${SUCCESS}✓ All checks passed${NC}"
|
||
else
|
||
echo -e "${WARN}⚠ Some checks failed${NC}"
|
||
echo ""
|
||
echo "Run with --fix to attempt automatic repairs"
|
||
fi
|
||
|
||
echo -e "${BOLD}════════════════════════════════════════════════════════════${NC}"
|
||
fi
|
||
|
||
exit $([ $check_result -eq 0 ] && echo 0 || echo 1)
|
||
}
|
||
|
||
# Run
|
||
main "$@"
|