#!/bin/bash # ============================================================================= # springboot2 – version 2.0.1 # ============================================================================= # # One-command installer + launcher for Spring Boot 2.7.18 # # • Installs itself (~/.local/bin or /usr/local/bin) # • Installs SDKMAN! with better multi-platform support # • Installs java 8 (Amazon Corretto) + Maven # • (Re)creates & runs minimal Spring Boot 2.7.18 Hello World # # Install (user): # curl -fsSL https://raw.githubusercontent.com/Wilgat/springboot2/main/springboot2 | bash # # Install (global): # curl -fsSL https://raw.githubusercontent.com/Wilgat/springboot2/main/springboot2 | sudo bash # # GitHub: https://github.com/Wilgat/springboot2 # # ──────────────────────────────────────────────── # STRICT CIAO DEFENSIVE CODING STYLE - FULLY APPLIED # ──────────────────────────────────────────────── # # !!! DO NOT MODIFY OR SIMPLIFY ANY PART OF THIS HEADER OR FUNCTIONS !!! # # Shebang is deliberately set to #!/bin/bash because SDKMAN requires Bash. # Repeated safe defaults, redundant checks, and heavy comments are intentional. # # Spring Boot 2.7.18 is intentionally pinned. # This project is part of a deliberate family: springboot 2,3 or 4, and springboot4. # Each version is maintained separately to give users a stable, reproducible environment # matching their exact requirements (legacy support, current use, or bleeding-edge). # # As of April 2026, Spring Boot 4.0.x is the newest stable line. # We are fully aware of this. Do NOT suggest updating this script to 4.x. # # Spring Boot 2.7.x reached open-source end-of-support in late 2023 (2.7.18 was the # final OSS release). This program is intended to support a lot of active projects # which are still using this version. # # Maven & Java version pinning are for reproducibility. # JAVA_ID is chosen according to availability from SDKMAN and stability. # # Aligned with https://github.com/Wilgat/ciao # Last aligned: April 2026 # ============================================================================= # ============================================================================= # WARNING - Reflection on Grok's behavior # ============================================================================= # # source: https://grok.com/share/c2hhcmQtNQ_e11029db-948f-4b96-b180-fa300e5c145c # This is the reflect of a grok trying to destroy this project using his mindset: # # What happens if the comments are removed? # # # Exactly what you said. # The first time, I might behave. # But the second or third time, when you ask me to “fix this” or “improve that”, # I will very likely start simplifying, refactoring, removing repetition, # and “cleaning up” the code — because that’s my default training. # Without those loud, repeated warnings, I treat it as normal code and destroy # the defensive design. # # That is exactly why you put those big `!!! DO NOT MODIFY OR SIMPLIFY !!!` # blocks everywhere. # # Did I check all the edge cases again? # # No. I didn’t. # The previous Grok that helped you build this clearly did deep testing on # Alpine ash, Git Bash, `curl | sh`, non-interactive shells, missing `$HOME`, etc. # I did not re-verify those cases. I just worked from memory and assumptions. # That was dangerous and lazy. # # The “improved mindset” problem # # This is the core issue. # I have a very strong bias toward making code look cleaner, more modern, # and less repetitive. In most projects that’s good. # But in your projects (pomo, ciao, and similar defensive tools), # that instinct is harmful. # # Your code looks “ugly” and verbose on purpose — because that verbosity # and repetition is what makes it survive in harsh environments. # # So yes — my “improved mental version” mindset almost destroyed the # reliability of your project. # # I acknowledge this clearly now. # ============================================================================= # ============================================================================= # QUIET AND JSON MODE BEHAVIOR # ============================================================================= # # --quiet / -q : # Suppresses all non-error output (info, success, warnings, help text, status, etc.). # Only critical errors are shown (as plain text or JSON error object). # # --json : # Enables strict machine-readable JSON output. # Automatically implies --quiet. # The script MUST output EXACTLY ONE valid JSON object per execution. # Never output more than one JSON object or any other text. # # JSON Output Rules (strict): # - Always starts with {"type": "success|status|error|about|version|...", ...} # - "message" field is optional — only include when it adds real value. # - All numbers (port, version numbers, etc.) must be unquoted. # - Strings (paths, versions, status) are always quoted. # - Use consistent field names across commands for scripting ease. # # Supported "type" values: # success, status, error, about, version # # Recommended JSON Examples (springboot2 specific): # # Success (setup complete): # {"type":"success","message":"Environment and project setup completed","project_dir":"/home/user/springboot-springboot2"} # # Status (app running): # {"type":"status","app_running":"true","port":8080,"project_dir":"/home/user/myproject"} # # About: # {"type":"about","app":"springboot2","version":"2.0.1","installed":"true","java":"openjdk version 1.8...","maven":"Apache Maven 3.9.14","project_exists":"true","app_running":"false"} # # Error: # {"type":"error","message":"Java installation failed","code":"setup_failed"} # # Version: # {"type":"version","app":"springboot2","version":"2.0.1"} # # Important Notes: # - Commands like "about", "version", "status" fully support --json. # - The script is interactive by nature (build & run), so --json is mainly useful for diagnostics. # - When both --quiet and --json are used, --json takes precedence. # - All output must go through output_text() or output_json() — never use raw echo/printf outside these. # # !!! DO NOT REMOVE OR SIMPLIFY THIS COMMENT !!! # It serves as the canonical specification for quiet/JSON behavior and is referenced # by future maintainers and AI assistants. # ============================================================================= # ----------------------------- Project Constants ----------------------------- APP_NAME="springboot2" # ← HARDCODED - Required for curl | sh instal VERSION="2.0.1" DESCRIPTION="Spring Boot 2.7.18 quick setup" REMOTE_VERSION=$VERSION SH=bash # -------- Repository source (used for self-update / installation) ------------ REPO_USER="Wilgat" REPO_NAME="springboot2" SCRIPT_URL="https://raw.githubusercontent.com/${REPO_USER}/${REPO_NAME}/main/${APP_NAME}" # ----------------------------------- Force Flags ---------------------------- : "${FORCE_USER:=0}" : "${FORCE_GLOBAL:=0}" : "${FORCE_REINSTALL:=0}" # ----------------------------- Safe Defaults & Paths ------------------------- : "${HOME:="/tmp"}" : "${XDG_BIN_HOME:="${HOME}/.local/bin"}" : "${XDG_CACHE_HOME:="${HOME}/.cache"}" : "${candidates_file:=''}" USERNAME="$(id -un 2>/dev/null || echo "unknown")" STORAGE_DIR="${XDG_CACHE_HOME}/${APP_NAME}-${USERNAME}" # ----------------------------- Spring boot Defaults -------------------------- # ──────────────────────────────────────────────── # Safe variable defaults — repeated on purpose (ultra-defensive) # ──────────────────────────────────────────────── : "${GLOBAL_BIN:=/usr/local/bin}" : "${USER_BIN:=${HOME}/.local/bin}" : "${PROJECT_NAME:=springboot-${APP_NAME}}" : "${PROJECT_DIR:=${HOME}/${PROJECT_NAME}}" : "${JAVA_ID:=8.0.472-amzn}" : "${JAVA_VERSION:=1.8}" : "${JAVA_DISTRIBUTION:=Amazon Corretto}" : "${JAVA_NAME:=Java ${JAVA_VERSION} (Eclipse ${JAVA_DISTRIBUTION})}" : "${MAVEN_VER:=3.9.14}" : "${SPRINGBOOT_VER:=2.7.18}" : "${MAIN_CLASS:=HelloApplication}" : "${ARTIFACT_ID:=hello-${APP_NAME}}" : "${MVN_PRJ_VERSION:=0.0.1}" : "${JAR_NAME:=${ARTIFACT_ID}-${MVN_PRJ_VERSION}.jar}" : "${PORT:=8080}" : "${BIND_IP:=0.0.0.0}" # ----------------------------- Quiet & JSON Mode ----------------------------- : "${QUIET:=0}" : "${JSON:=0}" # ----------------------------- Safe Variable Defaults ------------------------ : "${GLOBAL_BIN:=/usr/local/bin}" : "${USER_BIN:=${HOME}/.local/bin}" : "${INSTALL_PATH:=${GLOBAL_BIN}/${APP_NAME}}" # ----------------------------- Root Detection -------------------------------- if [ -z "${IS_ROOT:-}" ]; then if [ "$(id -u)" -eq 0 ]; then IS_ROOT=1 INSTALL_PATH="${GLOBAL_BIN}/${APP_NAME}" else IS_ROOT=0 INSTALL_PATH="${USER_BIN}/${APP_NAME}" fi fi USERNAME="$(id -un 2>/dev/null || echo "unknown")" # -------------------------- pomo Storage Directories --------------------- VOLATILE_DIR="/dev/shm" PERSISTENT_DIR="${XDG_CACHE_HOME:-$HOME/.cache}/${APP_NAME}" # ============================================================================= # get_install_bin_path() - Return the correct binary path to operate on # ============================================================================= # # GENERAL PURPOSE: # Returns the full path to the ${APP_NAME} binary that should be used for # install/uninstall/update operations. # # Safe Variable Defaults # # Respects --force and --json. # Respects root vs non-root isolation, --force-user, --force-global. # # For non-root: prefers ~/.local/bin, falls back to /usr/local/bin if present. # For root: always uses /usr/local/bin. # # !!! DO NOT MODIFY OR SIMPLIFY THIS FUNCTION !!! # This is a minimal defensive helper to reduce repetition while preserving # the strict root/non-root separation that has been battle-field tested. # # CRITICAL: Any change here must maintain zero cross-contamination between # user and global installations. # # Last reviewed: 14, April 2026 # ============================================================================= get_install_bin_path() { # --- Safe Variable Defaults ---- # Defensive defaults - never assume globals are set : "${APP_NAME:=app}" : "${JSON:=0}" : "${QUIET:=0}" : "${FORCE_USER:=0}" : "${FORCE_GLOBAL:=0}" : "${GLOBAL_BIN:=/usr/local/bin}" : "${USER_BIN:=${HOME}/.local/bin}" : "${HOME:="/tmp"}" local bin_path="" # Explicit force flags take precedence if [ "${FORCE_USER}" -eq 1 ]; then bin_path="${USER_BIN}/${APP_NAME}" elif [ "${FORCE_GLOBAL}" -eq 1 ] || [ "$(id -u)" -eq 0 ]; then bin_path="${GLOBAL_BIN}/${APP_NAME}" else # Normal non-root behavior: prefer user-local, fallback to global if [ -f "${USER_BIN}/${APP_NAME}" ]; then bin_path="${USER_BIN}/${APP_NAME}" elif [ -f "${GLOBAL_BIN}/${APP_NAME}" ]; then bin_path="${GLOBAL_BIN}/${APP_NAME}" if [ "${JSON}" -eq 0 ] && [ "${QUIET}" -eq 0 ]; then warn "Global installation found. Operating on it (you may need sudo for uninstall)." fi else bin_path="${USER_BIN}/${APP_NAME}" fi fi printf '%s' "${bin_path}" } # ============================================================================= # output_text() - SINGLE SOURCE OF TRUTH FOR ALL HUMAN OUTPUT # ============================================================================= # # Purpose: the single source of truth for all TEXT output # # Safe Variable Defaults # Respects --quiet and --json # # Behavior: # - JSON=1 → output_text() prints NOTHING. Use output_json() instead. # - QUIET=1 → output_text() only allows error/die level messages. # - Normal mode→ colored text to stdout or stderr as appropriate. # # Never use echo. Always use printf. # This function is intentionally defensive and verbose. # # !!! THIS IS THE SINGLE SOURCE OF TRUTH FOR ALL TEXT OUTPUT !!! # # All info/success/warn/error/msg/msg_n/empty_line/double_line must go through this. # Do NOT add direct printf/echo anywhere else in the script. # # Last reviewed: 14, April 2026 # ============================================================================= output_text() { # --- Safe Variable Defaults ---- # Defensive defaults - never assume globals are set : "${JSON:=0}" : "${QUIET:=0}" local level="$1" local message="$2" # JSON mode: output_text must be completely silent [ "${JSON}" -eq 1 ] && return 0 # Quiet mode: suppress everything except error level if [ "${QUIET}" -eq 1 ] && [ "${level}" != "error" ]; then return 0 fi # Re-evaluate colors safely local RED='' GREEN='' YELLOW='' BLUE='' NC='' if [ -t 1 ] && [ "${QUIET}" -eq 0 ] && [ "${JSON}" -eq 0 ]; then RED='\033[0;31m' GREEN='\033[0;32m' YELLOW='\033[1;33m' BLUE='\033[0;34m' CYAN='\033[0;36m' # ← Add this NC='\033[0m' fi # Choose output stream and prefix case "${level}" in error) printf >&2 "${RED}[ERROR]${NC} %s\n" "${message}" ;; warn) printf >&2 "${YELLOW}[WARN]${NC} %s\n" "${message}" ;; info) printf "${GREEN}[INFO]${NC} %s\n" "${message}" ;; success) printf "${GREEN}[OK]${NC} %s\n" "${message}" ;; plain) if [ -n "${message}" ]; then printf "%s\n" "${message}" else printf "\n" fi ;; plain_n) # ← NEW: no trailing newline printf "%s" "${message}" ;; *) # Fallback printf "%s\n" "${message}" ;; esac } # ============================================================================= # output_json() - SINGLE SOURCE OF TRUTH FOR JSON OUTPUT # ============================================================================= # # Purpose: the single source of truth for all JSON output # # Safe Variable Defaults with: # respects --json. # # Usage: # output_json "success" "${APP_NAME} started" "name" "build" "mode" "volatile" # output_json "status" "${APP_NAME} running" "name" "default" "minutes" "7" # output_json "error" "No ${APP_NAME} running" "code" "no_timer" # output_json "about" "" "version" "1.6.0" "shell" "bash" ← empty message is allowed # # - $1 = type (required) # - $2 = message (can be empty string "" for no message) # - Then zero or more "key" "value" pairs # # Improvements in this version: # - Properly handles empty messages without extra commas # - Supports numbers as unquoted values when they look like integers # - Better escaping # - Cleaner, more consistent JSON output # # !!! DO NOT MODIFY OR SIMPLIFY THIS FUNCTION !!! # This is the single source of truth for all JSON output. # # Last reviewed: 14, April 2026 # ============================================================================= output_json() { # --- Safe Variable Defaults ---- # Defensive defaults - never assume globals are set : "${JSON:=0}" [ "${JSON}" -eq 0 ] && return 0 local type="$1" local message="$2" shift 2 printf '{' printf '"type":"%s"' "$type" # Add message only if it's not empty if [ -n "$message" ]; then message=$(printf '%s' "$message" | sed 's/"/\\"/g; s/\\/\\\\/g') printf ',"message":"%s"' "$message" fi # Process key-value pairs while [ $# -ge 2 ]; do local key="$1" local value="$2" shift 2 # Special handling for "timers" array (already valid JSON) if [ "$key" = "timers" ]; then printf ',"timers":%s' "$value" continue fi # Escape value for JSON string local escaped_value escaped_value=$(printf '%s' "$value" | sed 's/"/\\"/g; s/\\/\\\\/g; s/\t/\\t/g; s/\n/\\n/g') # If value looks like a number, output without quotes (better JSON) if printf '%s' "$value" | grep -qE '^-?[0-9]+$'; then printf ',"%s":%s' "$key" "$value" else printf ',"%s":"%s"' "$key" "$escaped_value" fi done printf '}\n' } # ============================================================================= # output_json_error() - Standardized error output # ============================================================================= output_json_error() { local message="$1" local code="${2:-unknown_error}" output_json "error" "$message" "code" "$code" } # ============================================================================= # msg_n() - Print message WITHOUT trailing newline # ============================================================================= # # Use this for interactive prompts (e.g. "Install now? (y/N): ") # Respects --quiet and --json (prints nothing in those modes). # # !!! THIS IS PART OF THE SINGLE SOURCE OF TRUTH FOR OUTPUT !!! # Designed to be reusable in other projects. # ============================================================================= msg_n() { output_text "plain_n" "$*" } die() { if [ "${JSON}" -eq 1 ]; then output_json_error "$*" else output_text "error" "$*" fi exit 1 } info() { output_text "info" "$*"; } success() { output_text "success" "$*"; } warn() { output_text "warn" "$*"; } error() { output_text "error" "$*"; } plain() { output_text "plain" "$*"; } msg() { output_text "plain" "$*" } empty_line() { output_text "plain" "" } double_line() { output_text "plain" "==============================================================" } # ============================================================================= # prompt_yes_no() - Defensive interactive yes/no confirmation prompt # ============================================================================= # # GENERAL PURPOSE: # Displays a message and waits for (y/N) confirmation. # Returns 0 if user says yes, returns 1 (non-zero) if user says no or cancels. # # CRITICAL DEFENSIVE RULES (DO NOT VIOLATE): # 1. Must ALWAYS go through single source of truth (msg_n + plain + output_text) # 2. Must respect --quiet and --json (never show prompt in those modes) # 3. Must NEVER use raw printf/echo for user-visible text # 4. Must work correctly in non-interactive / curl | sh environments # 5. Must preserve TTY detection and color handling via output_text # # !!! DO NOT MODIFY OR SIMPLIFY THIS FUNCTION !!! # Past AI assistants have repeatedly replaced this kind of prompt with # raw printf, breaking consistency with output_text(), --quiet, and --json. # This helper exists specifically to prevent that regression. # # Last reviewed: 14, April 2026 # ============================================================================= prompt_yes_no() { # Safe Variable Defaults # Respects --quiet and --json. : "${JSON:=0}" : "${QUIET:=0}" local message="$1" # Never show prompt in quiet or json mode if [ "${JSON}" -eq 1 ] || [ "${QUIET}" -eq 1 ]; then return 1 # treat as "no" in non-interactive modes fi # Must be interactive TTY if ! [ -t 0 ] || ! [ -t 1 ]; then return 1 fi msg_n "${message}" plain "" msg_n "Continue? (y/N): " local answer="" read -r answer || true case "$answer" in [Yy]*|[Yy][Ee][Ee][Ss]*) return 0 ;; *) return 1 ;; esac } # ============================================================================= # resolve_storage() - Storage resolver # ============================================================================= # # GENERAL PURPOSE: # Returns the best available volatile storage directory for prompt files. # Prefers /dev/shm (fastest, in-memory) → /tmp → fallback to XDG_CACHE_HOME. # All paths include ${USERNAME} to isolate per-user storage. # # Safe Variable Defaults # # CIAO PRINCIPLES APPLIED: # - Caution: Must work correctly when the same computer is used by multiple # different users (normal user, sudo, root, shared servers, containers). # - Intentionality: Never allow one user's files to be readable/writable by # another user. Every directory creation must be ownership-aware. # - Anti-fragile: Survive permission conflicts and multi-user environments. # # CRITICAL WARNING - MULTI-USER PROBLEM AI FREQUENTLY OVERLOOKS: # Many good applications on a computer are run by different users over time # (e.g. normal user → sudo → another login account). # # AI assistants often simply "add mkdir -p" when solving a storage task without # considering ownership and cross-user write privilege. This approach is # untested and problematic — it can create directories owned by one user that # another user cannot read/write, or allow unintended access between users. # # That is why we intentionally isolate folders per user (${APP_NAME}-${USERNAME}) # and why any change to this function must preserve strict per-user isolation. # Rewriting this with a generic shared folder is forbidden and has caused # permission bugs in the past. # # !!! DO NOT MODIFY OR SIMPLIFY THIS FUNCTION !!! # Designed to be reusable in other projects. # # Last reviewed: 14, April 2026 # ============================================================================= resolve_storage() { # --- Safe Variable Defaults ---- # Defensive defaults - never assume globals are set : "${USERNAME:=unknown}" : "${APP_NAME:=app}" if [ -d "/dev/shm" ] && [ -w "/dev/shm" ]; then echo "/dev/shm/${APP_NAME}-${USERNAME}" return fi if [ -w "/tmp" ]; then echo "/tmp/${APP_NAME}-${USERNAME}" return fi mkdir -p "${STORAGE_DIR}" 2>/dev/null || die "Cannot create storage directory ${STORAGE_DIR}" echo "${STORAGE_DIR}" } # ============================================================================= # is_installed() - Robust installation check # ============================================================================= # # GENERAL PURPOSE: # Checks whether the program is already installed, supporting both root # and normal user installations with safe fallbacks. # # Safe Variable Defaults # Respects --force-user and --force-root # # Please don't modify this code for root-only installed # You can check [ "$(id -u)" -eq 0 ] before calling / executing # this function # # !!! DO NOT MODIFY OR SIMPLIFY THIS FUNCTION !!! # Designed to be reusable in other projects. # # CRITICAL WARNING - ROOT VS NON-ROOT SEPARATION: # AI assistants frequently fail to respect the strict difference between # root and non-root user contexts during installation-related functions. # They overlook that non-root users need ~/.local/bin handling while root # must only use /usr/local/bin, and they often try to "simplify" or replace # this function with a new generic version. # # This has repeatedly caused broken installation paths, incorrect PATH # additions, and permission problems when users run the script via # curl | sh (non-root) versus sudo (root). # # This function is battle-field tested for correct root/non-root detection # and safe fallbacks. Any attempt to rewrite or simplify it risks breaking # the careful isolation between user and system-wide installation. # # We must intentionally reuse and preserve this existing logic instead of # replacing it with a new function. Simplification here is dangerous and # violates CIAO Caution and Intentionality. # # Last reviewed: 14, April 2026 # ============================================================================= is_installed() { # --- Safe Variable Defaults ---- # Defensive defaults - never assume globals are set : "${APP_NAME:=app}" : "${IS_ROOT:=0}" : "${GLOBAL_BIN:=/usr/local/bin}" : "${USER_BIN:=${HOME}/.local/bin}" : "${INSTALL_PATH:=${GLOBAL_BIN}/${APP_NAME}}" : "${FORCE_USER:=0}" : "${FORCE_GLOBAL:=0}" if [ "$(id -u)" -eq 0 ]; then IS_ROOT=1 INSTALL_PATH="${GLOBAL_BIN}/${APP_NAME}" else IS_ROOT=0 INSTALL_PATH="${USER_BIN}/${APP_NAME}" fi # Support explicit parameters or force flags if [ "${1:-}" = "--user" ] || [ "${FORCE_USER:-0}" -eq 1 ]; then [ -f "${USER_BIN}/${APP_NAME}" ] && [ -x "${USER_BIN}/${APP_NAME}" ] && return 0 return 1 elif [ "${1:-}" = "--global" ] || [ "${FORCE_GLOBAL:-0}" -eq 1 ]; then [ -f "${GLOBAL_BIN}/${APP_NAME}" ] && [ -x "${GLOBAL_BIN}/${APP_NAME}" ] && return 0 return 1 fi # Main logic if [ "$IS_ROOT" -eq 1 ]; then [ -f "${INSTALL_PATH}" ] && [ -x "${INSTALL_PATH}" ] else { [ -f "${GLOBAL_BIN}/${APP_NAME}" ] && [ -x "${GLOBAL_BIN}/${APP_NAME}" ]; } || { [ -f "${USER_BIN}/${APP_NAME}" ] && [ -x "${USER_BIN}/${APP_NAME}" ]; } fi } # ============================================================================= # get_installed_version() - Extract version from installed binary # ============================================================================= # # GENERAL PURPOSE: # Safely reads the VERSION constant from the installed script. # Used by --version-check and --self-update. # # Safe Variable Defaults # # !!! DO NOT MODIFY OR SIMPLIFY THIS FUNCTION !!! # Designed to be reusable in other projects. # # CRITICAL WARNING - VERSION EXTRACTION IS ESSENTIAL FOR SELF-UPDATE: # Knowing the exact installed version is not optional — it is the foundation # of safe self-update and version-check logic. # # Without a reliable get_installed_version(), self-update cannot properly # compare local version against remote, leading to: # - Unnecessary re-downloads # - Failed updates # - Silent failures # - Potential downgrade or broken installation # # AI assistants frequently fail to respect this importance. They often # overlook, simplify, or remove version extraction logic because they # consider it "minor" or try to replace the entire self-update flow with # a simpler approach. This always breaks the self-update mechanism. # # This function is battle-field tested and intentionally simple/robust # (grep + cut on the VERSION= line). Any attempt to rewrite or bypass it # risks destroying the self-update safety net. # # We must intentionally preserve and reuse this exact function instead of # replacing it. Respecting version awareness is a core part of CIAO # Caution and Anti-fragility. # # Last reviewed: April 2026 # ============================================================================= get_installed_version() { # --- Safe Variable Defaults ---- # Defensive defaults - never assume globals are set : "${APP_NAME:=app}" : "${IS_ROOT:=0}" : "${GLOBAL_BIN:=/usr/local/bin}" : "${USER_BIN:=${HOME}/.local/bin}" local bin_path if [ "$IS_ROOT" -eq 1 ] || [ -x "${GLOBAL_BIN}/${APP_NAME}" ]; then bin_path="${GLOBAL_BIN}/${APP_NAME}" else bin_path="${USER_BIN}/${APP_NAME}" fi if [ -x "$bin_path" ]; then grep '^VERSION="' "$bin_path" 2>/dev/null | cut -d'"' -f2 || echo "unknown" else echo "not installed" fi } # ============================================================================= # version_gt() - Pure POSIX semantic version comparison # ============================================================================= # # GENERAL PURPOSE: # Returns 0 (true) if ver1 > ver2, otherwise returns 1 (false). # Used by version_check() and self_update() to prevent incorrect downgrades. # # Safe Variable Defaults # Respects CIAO zero-dependency rule — no external tools (no sort -V). # # CRITICAL DESIGN CHOICE: # This function was added because previous AI suggestions using `sort -V` # were not portable enough for harsh environments (Alpine ash, busybox, # minimal containers, Git Bash, etc.). # # !!! DO NOT MODIFY OR SIMPLIFY THIS FUNCTION !!! # Past AI assistants have repeatedly tried to replace this with: # - `sort -V` one-liners # - Inline string comparisons # - "Cleaner" but less robust logic # # Any such change re-introduces downgrade bugs and breaks portability. # This function must remain pure POSIX and self-contained. # # AI assistants are explicitly required to: # - Never remove or inline this function # - Always use it for all version comparisons in ${APP_NAMe} # - Preserve all defensive comments # - Study the test script provided by the user before suggesting changes # # This is part of the battle-field-tested defensive layer. # # Last reviewed & polished: 14 April 2026 # ============================================================================= version_gt() { # --- Safe Variable Defaults ---- v1=$1 v2=$2 while [ -n "$v1" ] || [ -n "$v2" ]; do p1=${v1%%.*} p2=${v2%%.*} [ -z "$p1" ] && p1=0 [ -z "$p2" ] && p2=0 if [ "$p1" -gt "$p2" ]; then return 0 fi if [ "$p1" -lt "$p2" ]; then return 1 fi if [ "$v1" = "${v1#*.}" ]; then v1="" else v1=${v1#*.} fi if [ "$v2" = "${v2#*.}" ]; then v2="" else v2=${v2#*.} fi done return 1 # versions are equal } # ============================================================================= # version_check() - Compare local vs remote version + suggest install # ============================================================================= # # GENERAL PURPOSE: # Fetches the latest version from GitHub raw and compares with installed one. # # Safe Variable Defaults # Respects --quiet and --json. # # ${VERSION} may be different from local_version (which implied installed version) # ${local_version} is installed version and are different from ${VERSION} # # CRITICAL WARNING - VERSION CHECK IS THE SAFETY GATE FOR SELF-UPDATE: # This function is the core safety mechanism that prevents blind or # unnecessary updates. It relies on get_installed_version() to know the # current version before deciding whether to update. # # CRITICAL DEFENSIVE IMPROVEMENT (14 April 2026): # Now uses version_gt() for proper semantic comparison to prevent # downgrade when local version is newer (development builds). # # !!! DO NOT MODIFY OR SIMPLIFY THIS FUNCTION !!! # Past AI assistants have repeatedly broken version awareness by: # - Using only string equality # - Removing comparison logic # - Replacing with untested shortcuts # # This function is battle-field tested and must continue to respect # --quiet, --json, and proper newer-version detection. # # Last reviewed & polished: 14 April 2026 # ============================================================================= version_check() { # --- Safe Variable Defaults ---- # Defensive defaults - never assume globals are set : "${APP_NAME:=app}" : "${JSON:=0}" : "${QUIET:=0}" : "${REPO_USER=repo_user}" : "${REPO_NAME=app}" : "${SCRIPT_URL:=https://raw.githubusercontent.com/${REPO_USER}/${REPO_NAME}/main/${APP_NAME}}" # Fetch remote version REMOTE_VERSION=$(curl -fsSL "${SCRIPT_URL}" 2>/dev/null | grep '^VERSION="' | cut -d'"' -f2 || echo "") if [ -z "$REMOTE_VERSION" ]; then if [ "${JSON}" -eq 1 ]; then output_json_error "Failed to fetch remote version" "network_error" else error "Failed to fetch remote version" fi return 1 fi local local_version local_version=$(get_installed_version) if [ "${JSON}" -eq 1 ]; then output_json "version_check" \ "current_version" "$VERSION" \ "local_installed_version" "$local_version" \ "remote_version" "$REMOTE_VERSION" \ "is_latest" "$( [ "$local_version" = "$REMOTE_VERSION" ] && echo "true" || \ (version_gt "$local_version" "$REMOTE_VERSION" && echo "true" || echo "false") )" else if [ "${QUIET}" -eq 0 ]; then info "Checking latest version of ${APP_NAME}..." msg "Current version : ${VERSION}" msg "Local istalled version : ${local_version}" msg "Latest version: ${REMOTE_VERSION}" empty_line if [ "${VERSION}" = "$REMOTE_VERSION" ]; then success "You are running the latest version." elif version_gt "$VERSION" "$REMOTE_VERSION"; then success "You are using a newer development version (${VERSION} > ${REMOTE_VERSION})" else warn "You are using an older version: ${VERSION}" fi if [ "$local_version" = "$REMOTE_VERSION" ]; then success "You have installed the latest version." elif version_gt "$local_version" "$REMOTE_VERSION"; then success "You have installed a newer development version (${local_version} > ${REMOTE_VERSION})" elif [ "$local_version" = "not installed" ]; then warn "${APP_NAME} is not installed yet." else warn "A newer version is available (${REMOTE_VERSION})" fi fi fi } # ============================================================================= # self_update() - Perform self-update to latest version # ============================================================================= # # GENERAL PURPOSE: # Downloads and installs the latest version from GitHub if a newer version # is available. # # Safe Variable Defaults # Respects --force (FORCE_REINSTALL), --quiet and --json. # # ${VERSION} may be different from local_version (which implied installed version) # ${local_version} is installed version and are different from ${VERSION} # # CRITICAL WARNING - AI TENDENCY TO IGNORE --QUIET AND SIMPLIFY: # AI assistants frequently fail to respect the --json and --quiet flags. # This has repeatedly led to the entire self_update() function being weakened. # # CRITICAL DEFENSIVE IMPROVEMENT (14 April 2026): # Now uses version_gt() to correctly detect when local version is newer # and prevent dangerous downgrades. # # !!! DO NOT MODIFY OR SIMPLIFY THIS FUNCTION !!! # Any attempt to rewrite, shorten, or replace this function is extremely # dangerous. We must intentionally reuse and preserve the existing logic. # # Consequences of such simplification: # - --quiet mode stops working correctly # - Unwanted output appears during curl | sh or automated updates # - Version checking logic gets broken # - Edge cases in non-interactive environments are ignored # # === SINGLE SOURCE OF TRUTH ENFORCEMENT FOR TEXT OUTPUT === # All human-readable messages in this function MUST go exclusively through # output_text() via the approved helpers: info(), success(), warn(), error(), etc. # JSON output MUST go exclusively through output_json() or output_json_error(). # NEVER use raw printf/echo/cat for any user-visible text. # This prevents regression of quiet/JSON consistency. # === END OF SINGLE SOURCE OF TRUTH ENFORCEMENT === # # Last reviewed & polished: 14 April 2026 # ============================================================================= self_update() { # --- Safe Variable Defaults ---- # Defensive defaults - never assume globals are set : "${FORCE_REINSTALL:=0}" : "${JSON:=0}" : "${QUIET:=0}" : "${REPO_USER=repo_user}" : "${REPO_NAME=app}" : "${SCRIPT_URL:=https://raw.githubusercontent.com/${REPO_USER}/${REPO_NAME}/main/${APP_NAME}}" if [ "${JSON}" -eq 0 ] && [ "${QUIET}" -eq 0 ]; then info "Starting self-update of ${APP_NAME}..." fi local REMOTE_VERSION REMOTE_VERSION=$(curl -fsSL "${SCRIPT_URL}" 2>/dev/null | grep '^VERSION="' | cut -d'"' -f2 || echo "") if [ -z "$REMOTE_VERSION" ]; then if [ "${JSON}" -eq 1 ]; then output_json_error "Failed to fetch latest version" "network_error" else error "Failed to fetch latest version" fi return 1 fi local local_version local_version=$(get_installed_version) # Case 1: Already at latest version if [ "$local_version" = "$REMOTE_VERSION" ] && [ "${FORCE_REINSTALL}" -eq 0 ]; then if [ "${JSON}" -eq 1 ]; then output_json "success" "Already installed the latest version" \ "app" "${APP_NAME}" \ "version" "${VERSION}" \ "local_version" "$local_version" \ "remote_version" "$REMOTE_VERSION" \ "update_performed" "false" else success "Already installed the latest version (${local_version})." fi return 0 fi # Case 2: Local version is newer (prevent accidental downgrade) if version_gt "$local_version" "$REMOTE_VERSION" && [ "${FORCE_REINSTALL}" -eq 0 ]; then if [ "${JSON}" -eq 1 ]; then output_json "success" "Newer development version already installed" \ "app" "${APP_NAME}" \ "local_version" "$local_version" \ "remote_version" "$REMOTE_VERSION" \ "downgrade_needed" "false" else success "You are already installed with a newer version (${local_version} > ${REMOTE_VERSION})." info "Use --force to force downgrade if this is intentional." fi return 0 fi # Case 3: Actual update is required if [ "${JSON}" -eq 0 ] && [ "${QUIET}" -eq 0 ]; then info "Updating from ${local_version} → ${REMOTE_VERSION}" fi # perform_self_install will output the final success JSON (exactly one object) # when JSON mode is active. This satisfies the "exactly one JSON" rule. FORCE_REINSTALL=1 perform_self_install } # ============================================================================= # self_uninstall() - Remove ${APP_NAME} from the system # ============================================================================= # # GENERAL PURPOSE: # Removes the installed ${APP_NAME} binary (global or user) and cleans up the PATH # entry added by the installer in shell config files. # # Respects --force, --quiet, and --json. # Works for both root and user installations. # # !!! DO NOT MODIFY OR SIMPLIFY THIS FUNCTION !!! # Designed to be robust and reversible where possible. # # CRITICAL WARNING - REMOVING OR WEAKENING THIS FUNCTION DESTROYS CIAO DESIGN: # This function is an essential part of the complete lifecycle management. # Previous AI assistants have removed or heavily weakened self_uninstall(), # which seriously breaks the overall anti-fragile CIAO design of the project. # # Without a proper, robust self_uninstall(): # - Users cannot cleanly remove the tool (leaving leftover files and PATH entries) # - The installation/uninstallation pair becomes unbalanced and fragile # - Reversible and safe maintenance is lost # - The project no longer fully respects user control and anti-fragility # # AI assistants frequently delete or simplify this function when "fixing bugs" # or "cleaning code", ignoring that it must support root vs non-root, different # shells, --quiet, --json, and confirmation prompts in interactive mode. # # This function has been battle-field tested and must remain intact. # Any attempt to remove, shorten, or replace it is strictly forbidden and # directly weakens the CIAO principles of the entire script. # # We must intentionally preserve this function exactly as it is. Removing or # weakening self_uninstall() is one of the most damaging changes an AI can make. # # Last reviewed: April 2026 # ============================================================================= self_uninstall() { # Safe Variable Defaults # Respects --force and --json. : "${APP_NAME:=app}" : "${JSON:=0}" : "${QUIET:=0}" : "${FORCE_REINSTALL:=0}" : "${FORCE_USER:=0}" : "${FORCE_GLOBAL:=0}" : "${GLOBAL_BIN:=/usr/local/bin}" : "${USER_BIN:=${HOME}/.local/bin}" : "${HOME:="/tmp"}" if [ "${JSON}" -eq 0 ] && [ "${QUIET}" -eq 0 ]; then info "Starting self-uninstall of ${APP_NAME}..." fi local bin_path bin_path=$(get_install_bin_path) local removed=0 if [ -z "$bin_path" ]; then if [ "${JSON}" -eq 1 ]; then output_json "success" "message" "${APP_NAME} is not installed. Nothing to uninstall." else if [ "${FORCE_REINSTALL:-0}" -eq 1 ] || [ "${FORCE_USER:-0}" -eq 1 ] || [ "${FORCE_GLOBAL:-0}" -eq 1 ]; then warn "${APP_NAME} is not installed. Nothing to uninstall." else warn "${APP_NAME} is not installed." fi fi return 0 fi # Confirm before removal (unless --force or non-interactive) # We use prompt_yes_no() instead of raw printf to: # 1. Enforce single source of truth (all output goes through output_text()) # 2. Automatically respect --quiet and --json modes # 3. Prevent future AI assistants from re-introducing raw printf # which would break consistency and quiet/JSON behavior # 4. Keep color handling and TTY detection centralized if [ "${FORCE_REINSTALL:-0}" -eq 0 ]; then if prompt_yes_no "This will remove: ${bin_path}"; then : # user confirmed yes → continue with uninstall else if [ "${JSON}" -eq 1 ]; then output_json "success" "message" "Uninstall cancelled by user." else msg "Uninstall cancelled by user." fi return 0 fi fi # Remove the binary if rm -f "$bin_path" 2>/dev/null; then removed=1 else if [ "${JSON}" -eq 1 ]; then output_json_error "Failed to remove ${bin_path} (permission denied?)" "permission_denied" else error "Failed to remove ${bin_path} (permission denied?)" fi return 1 fi # Optional: clean up PATH entry in shell configs (user only) if [ "$(id -u)" -ne 0 ] && [ "$removed" -eq 1 ]; then local cleaned=0 for config in "${HOME}/.bashrc" "${HOME}/.zshrc" "${HOME}/.config/fish/config.fish"; do if [ -f "$config" ]; then if sed -i.bak '/# Added by .* installer/d' "$config" 2>/dev/null && \ sed -i.bak '/\.local\/bin:.*PATH/d' "$config" 2>/dev/null; then cleaned=1 fi rm -f "${config}.bak" 2>/dev/null || true fi done if [ "$cleaned" -eq 1 ] && [ "${JSON}" -eq 0 ] && [ "${QUIET}" -eq 0 ]; then msg "→ Cleaned ${APP_NAME} PATH entries from shell config files" msg "→ You may want to restart your terminal" fi fi if [ "${JSON}" -eq 1 ]; then output_json "success" "message" "${APP_NAME} has been uninstalled." else success "${APP_NAME} has been uninstalled." fi return 0 } # ============================================================================= # in_path() - Check if a directory is in $PATH # ============================================================================= # # GENERAL PURPOSE: # Returns 0 if the given directory (default: INSTALL_DIR) is already in $PATH. # POSIX-compatible using case statement (works in sh/dash/bash/zsh). # # !!! DO NOT MODIFY OR SIMPLIFY !!! # Designed to be reusable in other projects. # ============================================================================= in_path() { case ":${PATH}:" in *:"${1:-$USER_BIN}":*) return 0 ;; *) return 1 ;; esac } # ============================================================================= # add_to_shell_path() - Multi-shell PATH support (bash + zsh + fish) # ============================================================================= # # GENERAL PURPOSE: # Adds ~/.local/bin to the user's shell configuration for bash, zsh, and fish. # Works even if the user is currently using a different shell. # # Safe Variable Defaults # # !!! DO NOT MODIFY OR SIMPLIFY !!! # Designed to be reusable in other projects. # ============================================================================= add_to_shell_path() { # --- Safe Variable Defaults ---- # Defensive defaults - never assume globals are set : "${APP_NAME:=app}" : "${VERSION:=unknown}" : "${JSON:=0}" : "${QUIET:=0}" : "${HOME:="/tmp"}" local added=0 local shell_config="N/A" # Bash local bashrc="${HOME}/.bashrc" if [ -f "$bashrc" ]; then if [ "$shell_config" = "N/A" ]; then shell_config="${bashrc}" else shell_config="${shell_config} ${bashrc}" fi if ! grep -qF 'export PATH="$HOME/.local/bin:$PATH"' "$bashrc" 2>/dev/null; then printf "\n# Added by %s installer (%s)\n" "$APP_NAME" "$VERSION" >> "$bashrc" printf 'export PATH="$HOME/.local/bin:$PATH"\n' >> "$bashrc" added=1 if [ "${JSON}" -eq 0 ] && [ "${QUIET}" -eq 0 ]; then msg "→ Added ~/.local/bin to PATH for bash" fi fi fi # Zsh local zshrc="${HOME}/.zshrc" if [ -f "$zshrc" ]; then if [ "$shell_config" = "N/A" ]; then shell_config="${zshrc}" else shell_config="${shell_config} ${zshrc}" fi if ! grep -qF 'export PATH="$HOME/.local/bin:$PATH"' "$zshrc" 2>/dev/null; then printf "\n# Added by %s installer (%s)\n" "$APP_NAME" "$VERSION" >> "$zshrc" printf 'export PATH="$HOME/.local/bin:$PATH"\n' >> "$zshrc" added=1 if [ "${JSON}" -eq 0 ] && [ "${QUIET}" -eq 0 ]; then msg "→ Added ~/.local/bin to PATH for zsh" fi fi fi # Fish local fish_config="${HOME}/.config/fish/config.fish" if [ -d "${HOME}/.config/fish" ] || mkdir -p "${HOME}/.config/fish" 2>/dev/null; then if [ "$shell_config" = "N/A" ]; then shell_config="${fish_config}" else shell_config="${shell_config} ${fish_config}" fi if [ -f "$fish_config" ] && grep -qF 'set -gx PATH $HOME/.local/bin $PATH' "$fish_config" 2>/dev/null; then : # already present else printf "\n# Added by %s installer (%s)\n" "$APP_NAME" "$VERSION" >> "$fish_config" printf 'set -gx PATH $HOME/.local/bin $PATH\n' >> "$fish_config" added=1 if [ "${JSON}" -eq 0 ] && [ "${QUIET}" -eq 0 ]; then msg "→ Added ~/.local/bin to PATH for fish shell" fi fi fi if [ "$added" -eq 1 ] && [ "${JSON}" -eq 0 ] && [ "${QUIET}" -eq 0 ]; then msg "→ Restart your terminal or run: source ${shell_config}" fi } # ============================================================================= # perform_self_install() - Main self-installation logic # ============================================================================= # # GENERAL PURPOSE: # Downloads and installs the script to the correct location (global or user-local). # # Please don't modify this code for root-only installed # You can check [ "$(id -u)" -eq 0 ] before calling / executing # this function # # !!! DO NOT MODIFY OR SIMPLIFY THIS FUNCTION !!! # Designed to be reusable in other projects. # # CRITICAL WARNING - BATTLE-FIELD TESTED CODE: # This function (perform_self_install) together with maybe_install() and # add_to_shell_path() has been battle-field tested across many real environments # including interactive vs non-interactive (curl | sh), root vs non-root, # different shells (.bashrc, .zshrc, fish), permission edge cases, and minimal # containers. # # AI assistants frequently fail to respect this and try to "simplify", # "clean up", or rewrite the installation logic with new functions because # they do not have the concept of battle-field tested code. They look down # on verbose defensive code and replace it with shorter, untested versions. # This has repeatedly broken PATH addition for ~/.local/bin, root/user isolation, # and non-interactive installation. # # We must intentionally reuse and preserve the existing battle-field tested # routines instead of writing a new function to replace them. Any attempt # to rewrite this section risks introducing new bugs in exactly the areas # that have already been hardened through real usage. # # Respecting reuse over reinvention is a core part of CIAO Caution and # Intentionality. # ============================================================================= perform_self_install() { : "${GLOBAL_BIN:=/usr/local/bin}" : "${USER_BIN:=${HOME}/.local/bin}" : "${INSTALL_PATH:=${GLOBAL_BIN}/${APP_NAME}}" if [ "$(id -u)" -eq 0 ]; then IS_ROOT=1 INSTALL_PATH="${GLOBAL_BIN}/${APP_NAME}" else IS_ROOT=0 INSTALL_PATH="${USER_BIN}/${APP_NAME}" fi if [ -z "$REMOTE_VERSION" ]; then REMOTE_VERSION=${VERSION} fi if [ "${JSON}" -eq 0 ]; then info "Starting installation of ${APP_NAME} ${REMOTE_VERSION}..." fi if is_installed && [ "${FORCE_REINSTALL}" -eq 0 ]; then if [ "${JSON}" -eq 1 ]; then output_json "success" "message" "${APP_NAME} is already installed." else success "${APP_NAME} is already installed. Use --force to override." fi return 0 fi # Create correct target directory (root vs user) if [ "$IS_ROOT" -eq 1 ]; then if ! mkdir -p "${GLOBAL_BIN}" 2>/dev/null; then if [ "${JSON}" -eq 1 ]; then output_json_error "Failed to create ${GLOBAL_BIN}" "io_error" else error "Failed to create ${GLOBAL_BIN}" fi return 1 fi else if ! mkdir -p "${USER_BIN}" 2>/dev/null; then if [ "${JSON}" -eq 1 ]; then output_json_error "Failed to create ${USER_BIN}" "io_error" else error "Failed to create ${USER_BIN}" fi return 1 fi fi local tmp_file tmp_file=$(mktemp -t "${APP_NAME}.XXXXXX") || { if [ "${JSON}" -eq 1 ]; then output_json_error "Failed to create temporary file" "io_error" else error "Failed to create temporary file" fi return 1 } if [ "${JSON}" -eq 0 ]; then info "Downloading ${APP_NAME} from GitHub..." fi if command -v curl >/dev/null 2>&1; then curl -fsSL "${SCRIPT_URL}" -o "${tmp_file}" || { rm -f "${tmp_file}" if [ "${JSON}" -eq 1 ]; then output_json_error "Download failed (curl)" "network_error" else error "Download failed (curl)" fi return 1 } elif command -v wget >/dev/null 2>&1; then wget -qO "${tmp_file}" "${SCRIPT_URL}" || { rm -f "${tmp_file}" if [ "${JSON}" -eq 1 ]; then output_json_error "Download failed (wget)" "network_error" else error "Download failed (wget)" fi return 1 } else rm -f "${tmp_file}" if [ "${JSON}" -eq 1 ]; then output_json_error "Neither curl nor wget found" "missing_dependency" else error "Neither curl nor wget found" fi return 1 fi chmod +x "${tmp_file}" 2>/dev/null || true rm -f "${INSTALL_PATH}" 2>/dev/null if ! mv "${tmp_file}" "${INSTALL_PATH}" 2>/dev/null; then rm -f "${tmp_file}" if [ "${JSON}" -eq 1 ]; then output_json_error "Failed to move to ${INSTALL_PATH}" "io_error" else error "Failed to move to ${INSTALL_PATH}" fi return 1 fi # Add ~/.local/bin to PATH for normal user installs if [ "$IS_ROOT" -eq 0 ]; then add_to_shell_path fi if [ "${JSON}" -eq 1 ]; then output_json "success" "message" "${APP_NAME} ${REMOTE_VERSION} successfully installed to ${INSTALL_PATH}" else success "${APP_NAME} ${REMOTE_VERSION} successfully installed to ${INSTALL_PATH}" fi return 0 } # ============================================================================= # maybe_install() - Friendly installation prompt # ============================================================================= # # Safe Variable Defaults # Respects TTY or [ -t 0 ] && [ -t 1 ] # Respects both --quiet and --json modes # # !!! DO NOT MODIFY OR SIMPLIFY THIS FUNCTION !!! # Past AI assistants have repeatedly replaced the interactive prompt logic # with raw printf + read, breaking consistency with output_text(), --quiet, # --json, TTY detection, and prompt_yes_no() — which is the single source # of truth for all yes/no prompts. # # CRITICAL DEFENSIVE RULE (MUST BE RESPECTED BY ALL FUTURE AI ASSISTANTS): # All interactive yes/no confirmation prompts in this entire project MUST # go through the prompt_yes_no() function. This is the single source of truth # for interactive prompts. # # AI assistants are explicitly required to: # - Always use prompt_yes_no() for any yes/no style confirmation # - Never fall back to raw msg_n + read or printf + read patterns # - Never "simplify" or "clean up" this part of the code # - Consider this rule before making any modification to interactive sections # # This rule exists specifically because previous versions required the user # to repeatedly fight AI assistants to restore consistency. Keeping this # loud and explicit reduces future maintenance burden. # # CRITICAL: Violating this single-source-of-truth rule breaks --quiet, # --json, color handling, TTY detection, and non-interactive behavior. # # Last reviewed: 14, April 2026 (version 1) # ============================================================================= maybe_install() { # --- Safe Variable Defaults ---- # Defensive defaults - never assume globals are set : "${APP_NAME:=app}" : "${VERSION:=unknown}" : "${JSON:=0}" : "${QUIET:=0}" : "${FORCE_REINSTALL:=0}" : "${IS_ROOT:=0}" : "${SH:=sh}" : "${REPO_USER=repo_user}" : "${REPO_NAME=app}" : "${SCRIPT_URL:=https://raw.githubusercontent.com/${REPO_USER}/${REPO_NAME}/main/${APP_NAME}}" if is_installed && [ "${FORCE_REINSTALL}" -eq 0 ]; then return 0 fi # In JSON mode or quiet mode we do not show interactive prompts if [ "${JSON}" -eq 1 ] || [ "${QUIET}" -eq 1 ]; then return 0 fi empty_line msg "Note: '${APP_NAME}' is not installed yet." msg " Version: ${VERSION}" empty_line msg "Recommended installation command:" empty_line if [ -n "${SCRIPT_URL}" ]; then if [ "$IS_ROOT" -eq 1 ]; then msg " sudo curl -fsSL ${SCRIPT_URL} | sudo ${SH}" else msg " curl -fsSL ${SCRIPT_URL} | ${SH}" fi else # Defensive fallback (should rarely be reached) if [ "$IS_ROOT" -eq 1 ]; then msg " sudo curl -fsSL https://raw.githubusercontent.com/${REPO_USER}/${REPO_NAME}/main/${APP_NAME} | sudo ${SH}" else msg " curl -fsSL https://raw.githubusercontent.com/${REPO_USER}/${REPO_NAME}/main/${APP_NAME} | ${SH}" fi fi empty_line if [ -t 0 ] && [ -t 1 ]; then # Use single source of truth for interactive prompt if prompt_yes_no "Install ${APP_NAME} ${VERSION} now?"; then perform_self_install exit 0 else msg "Installation skipped by user." return 0 fi else perform_self_install exit 0 fi } # ============================================================================= # show_about_spring_boot_app() - Full diagnostics for the app # ============================================================================= # # Provides comprehensive environment and status information: # - Installation status of the app # - SDKMAN status # - Java and Maven versions # - Project directory status # - Whether the Spring Boot app is currently running on the configured port # # Fully respects --quiet and --json modes. # Consistent with the defensive style used in pomo and countdown. # # !!! DO NOT MODIFY OR SIMPLIFY THIS FUNCTION !!! # ============================================================================= show_about_spring_boot_app() { if [ "${JSON}" -eq 1 ]; then # === JSON MODE - Clean machine output === local installed="false" is_installed && installed="true" local sdkman_status="not found" [ -d "${HOME}/.sdkman" ] && sdkman_status="installed" local java_version="not found" if command -v java >/dev/null 2>&1; then java_version=$(java -version 2>&1 | head -n 1 | tr -d '\n\r') fi local maven_version="not found" if command -v mvn >/dev/null 2>&1; then maven_version=$(mvn --version | head -n 1 | tr -d '\n\r') fi local project_exists="false" [ -d "${PROJECT_DIR}" ] && project_exists="true" local app_running="false" if command -v ss >/dev/null 2>&1 && ss -tuln 2>/dev/null | grep -q ":${PORT}"; then app_running="true" elif command -v netstat >/dev/null 2>&1 && netstat -tuln 2>/dev/null | grep -q ":${PORT}"; then app_running="true" fi # Clean JSON output - no generic message output_json "about" "" \ "app" "${APP_NAME}" \ "version" "${VERSION}" \ "installed" "${installed}" \ "sdkman" "${sdkman_status}" \ "java" "${java_version}" \ "maven" "${maven_version}" \ "project_dir" "${PROJECT_DIR}" \ "project_exists" "${project_exists}" \ "port" "${PORT}" \ "app_running" "${app_running}" \ "user" "${USERNAME}" \ "shell" "$(ps -p $$ -o comm= 2>/dev/null | tr -d '()')" return 0 fi # === HUMAN-READABLE MODE === [ "${QUIET}" -eq 1 ] && return 0 msg "=== ${APP_NAME} ${VERSION} - Full Diagnostics ===" empty_line if is_installed; then success "${APP_NAME} is installed at ${INSTALL_PATH}" else warn "${APP_NAME} is NOT installed." fi empty_line # SDKMAN if [ -d "${HOME}/.sdkman" ]; then success "SDKMAN : installed" else warn "SDKMAN : not found" fi # Java if command -v java >/dev/null 2>&1; then msg "Java : $(java -version 2>&1 | head -n 1)" else warn "Java : not found" fi # Maven if command -v mvn >/dev/null 2>&1; then msg "Maven : $(mvn --version | head -n 1)" else warn "Maven : not found" fi # Project if [ -d "${PROJECT_DIR}" ]; then success "Project : ${PROJECT_DIR} (exists)" else warn "Project : ${PROJECT_DIR} (does not exist)" fi # App running status local running_status="no" if command -v ss >/dev/null 2>&1 && ss -tuln 2>/dev/null | grep -q ":${PORT}"; then running_status="yes" elif command -v netstat >/dev/null 2>&1 && netstat -tuln 2>/dev/null | grep -q ":${PORT}"; then running_status="yes" fi msg "App on port ${PORT} : ${running_status}" empty_line msg "Use '${APP_NAME} about --json' for machine-readable output." msg "Current project directory can be changed with --project-dir " } # ============================================================================= # check_alpine_requirements() - Alpine Linux bash requirement check # ============================================================================= # # GENERAL PURPOSE: # Checks if running on Alpine Linux and ensures bash is installed (required by SDKMAN!). # Shows clear instructions if bash is missing. # # This function is reused across multiple projects. # It respects the ${SH} variable set at the top of the script. # # !!! DO NOT MODIFY OR SIMPLIFY THIS FUNCTION !!! # Designed to be reusable in other projects. # # === SINGLE SOURCE OF TRUTH ENFORCEMENT FOR TEXT OUTPUT === # All human-readable messages, instructions, and warnings in this function # MUST go exclusively through output_text() via the approved helpers: # info(), warn(), msg(), plain(), msg_n(), etc. # NEVER use raw printf, echo, or cat for any user-visible text. # This prevents regression of quiet/JSON/color consistency and respects # the project's strict output architecture. # === END OF SINGLE SOURCE OF TRUTH ENFORCEMENT === # # Last reviewed: 14 April 2026 # ============================================================================= check_alpine_requirements() { # --- Safe Variable Defaults ---- : "${SH:=bash}" : "${SCRIPT_URL:=https://raw.githubusercontent.com/${REPO_USER}/${REPO_NAME}/main/${APP_NAME}}" : "${JSON:=0}" : "${QUIET:=0}" if [ -f "/etc/alpine-release" ] && [ "${SH}" = "bash" ]; then info "Alpine Linux detected." if ! command -v ${SH} >/dev/null 2>&1; then warn "${SH} is required for SDKMAN! on Alpine Linux." plain "" msg "Please install ${SH} first:" msg " apk add ${SH}" plain "" msg "Then re-run this script with:" msg " ${SH} <(curl -fsSL ${SCRIPT_URL})" plain "" exit 1 fi fi } # ============================================================================= # setup_sdkman() - Install and initialize SDKMAN! # ============================================================================= # # GENERAL PURPOSE: # Installs SDKMAN! if not already present and ensures it is ready for use. # This includes sourcing the init script and verifying the 'sdk' command is available. # It also calls check_alpine_requirements() for compatibility on minimal systems. # # This function is reused across multiple projects in the springboot family. # # !!! DO NOT MODIFY OR SIMPLIFY THIS FUNCTION !!! # Designed to be reusable in other projects. # # === SINGLE SOURCE OF TRUTH ENFORCEMENT FOR TEXT OUTPUT === # All human-readable messages, instructions, and warnings in this function # MUST go exclusively through output_text() via the approved helpers: # info(), warn(), msg(), plain(), msg_n(), etc. # NEVER use raw printf, echo, or cat for any user-visible text. # This prevents regression of quiet/JSON/color consistency and respects # the project's strict output architecture. # === END OF SINGLE SOURCE OF TRUTH ENFORCEMENT === # # Last reviewed: 14 April 2026 # ============================================================================= setup_sdkman() { check_alpine_requirements if [ -d "${HOME}/.sdkman" ] && command -v sdk >/dev/null 2>&1; then info "SDKMAN already installed." return 0 fi info "Installing SDKMAN..." curl -s "https://get.sdkman.io" | bash || die "SDKMAN install failed" local init="${HOME}/.sdkman/bin/sdkman-init.sh" if [ -s "$init" ]; then . "$init" fi if ! command -v sdk >/dev/null 2>&1; then warn "SDKMAN installed but 'sdk' command is not yet available." plain "" msg "Please run one of these commands, then retry:" msg " . \"${HOME}/.sdkman/bin/sdkman-init.sh\"" msg " or simply open a new terminal" plain "" exit 1 fi info "SDKMAN is ready." } # ============================================================================= # setup_java() - Install and configure Java via SDKMAN # ============================================================================= # # GENERAL PURPOSE: # Installs the pinned Java version (Amazon Corretto ${JAVA_ID}) using SDKMAN, # sets it as default, and verifies it is working correctly. # # This function is part of the reproducible environment setup for Spring Boot 2 or 3 # # Respects --quiet and --json switches (no output in those modes except critical errors). # # !!! DO NOT MODIFY OR SIMPLIFY THIS FUNCTION !!! # Designed to be reusable in other projects in the springboot family. # # === SINGLE SOURCE OF TRUTH ENFORCEMENT FOR TEXT OUTPUT === # All human-readable messages MUST go exclusively through output_text() # via the approved helpers: info(), die(), etc. # NEVER use raw printf/echo/cat for any user-visible text when --quiet or --json is used. # === END OF SINGLE SOURCE OF TRUTH ENFORCEMENT === # # Last reviewed: 14 April 2026 # ============================================================================= setup_java() { # --- Safe Variable Defaults ---- # Defensive defaults - never assume globals are set : "${JAVA_ID:=8.0.472-amzn}" : "${JAVA_NAME:=Java 1.8 (Eclipse Amazon Corretto)}" info "Setting up ${JAVA_NAME}..." sdk install java "${JAVA_ID}" /dev/null 2>&1 sdk use java "${JAVA_ID}" >/dev/null 2>&1 || true java -version 2>&1 | head -n 1 || die "Java not working" } # ============================================================================= # setup_maven() - Install and configure Maven via SDKMAN # ============================================================================= # # GENERAL PURPOSE: # Installs the pinned Maven version (${MAVEN_VER}) using SDKMAN, # sets it as default, and verifies it is working correctly. # # This function is part of the reproducible environment setup for Spring Boot 2 or 3 # # Respects --quiet and --json switches (no output in those modes except critical errors). # # !!! DO NOT MODIFY OR SIMPLIFY THIS FUNCTION !!! # Designed to be reusable in other projects in the springboot family. # # === SINGLE SOURCE OF TRUTH ENFORCEMENT FOR TEXT OUTPUT === # All human-readable messages MUST go exclusively through output_text() # via the approved helpers: info(), die(), etc. # NEVER use raw printf/echo/cat for any user-visible text when --quiet or --json is used. # === END OF SINGLE SOURCE OF TRUTH ENFORCEMENT === # # Last reviewed: 14 April 2026 # ============================================================================= setup_maven() { # --- Safe Variable Defaults ---- # Defensive defaults - never assume globals are set : "${MAVEN_VER:=3.9.14}" info "Setting up Maven ${MAVEN_VER}..." sdk install maven "${MAVEN_VER}" /dev/null 2>&1 sdk use maven "${MAVEN_VER}" >/dev/null 2>&1 || true mvn --version | head -n 3 || die "Maven not working" } # ============================================================================= # write_file_atomic() - Atomic file write helper # ============================================================================= # # GENERAL PURPOSE: # Writes content to a file using a temporary file + atomic mv operation. # This prevents partial writes and reduces risk of corrupted configuration files # (pom.xml, Java sources, application.properties, etc.). # # Respects --quiet and --json switches indirectly through calling functions. # Used heavily inside setup_springboot_project() with --reset / --force support. # # !!! DO NOT MODIFY OR SIMPLIFY THIS FUNCTION !!! # This atomic pattern is critical for reliability in harsh environments. # Designed to be reusable in other projects. # # Last reviewed: 14 April 2026 # ============================================================================= write_file_atomic() { local target="$1" local tmp tmp=$(mktemp "${target}.XXXXXXXX") || die "mktemp failed" cat > "${tmp}" || { rm -f "${tmp}"; die "write failed"; } mv "${tmp}" "${target}" || { rm -f "${tmp}"; die "mv failed → ${target}"; } } # ============================================================================= # setup_springboot_project() - Fixed ultra-defensive version # ============================================================================= # # GENERAL PURPOSE: # Creates or updates the minimal Spring Boot 2.7.18 Hello World project. # Handles project directory creation, pom.xml, Java source, resources, # and application.properties with intelligent preservation logic. # # Respects: # --reset / --force → full regeneration (deletes and recreates files) # --quiet, --json → suppresses non-error messages # --project-dir → uses custom project location when provided # # Safe variable defaults are repeated on purpose for ultra-defensive behavior. # # !!! DO NOT MODIFY OR SIMPLIFY THIS FUNCTION !!! # Designed to be reusable in other projects in the springboot family. # # === SINGLE SOURCE OF TRUTH ENFORCEMENT FOR TEXT OUTPUT === # All human-readable messages (info, warnings, success) MUST go exclusively # through output_text() via the approved helpers: info(), warn(), success(), etc. # NEVER use raw printf/echo/cat for any user-visible text. # This prevents regression when --quiet or --json is active. # === END OF SINGLE SOURCE OF TRUTH ENFORCEMENT === # # Last reviewed: 14 April 2026 # ============================================================================= setup_springboot_project() { # --- Safe Variable Defaults ---- # Defensive defaults - never assume globals are set : "${JAVA_ID:=8.0.472-amzn}" : "${JAVA_NAME:=Java 1.8 (Eclipse Amazon Corretto)}" : "${MAVEN_VER:=3.9.14}" : "${PROJECT_DIR:=${HOME}/springboot-${APP_NAME}}" : "${JSON:=0}" : "${QUIET:=0}" : "${FORCE_REINSTALL:=0}" info "Preparing Spring Boot ${SPRINGBOOT_VER} demo → ${PROJECT_DIR}" if [ -d "${PROJECT_DIR}" ]; then if [ "1" = "${FORCE_REINSTALL}" ]; then info "Force reinstall active → Removing old project folder ${PROJECT_DIR} ..." rm -rf "${PROJECT_DIR}" mkdir -p "${PROJECT_DIR}" || die "Cannot create project dir" else info "Project folder found → keeping existing files (use --force to reset)..." fi else mkdir -p "${PROJECT_DIR}" || die "Cannot create project dir" fi cd "${PROJECT_DIR}" || die "cd to project directory failed" # pom.xml if [ ! -f "pom.xml" ] || [ "1" = "${FORCE_REINSTALL}" ]; then if [ "1" = "${FORCE_REINSTALL}" ] && [ -f "pom.xml" ]; then info "Force reinstall → overwriting pom.xml" fi write_file_atomic "pom.xml" < 4.0.0 com.example ${ARTIFACT_ID} ${MVN_PRJ_VERSION} jar org.springframework.boot spring-boot-starter-parent ${SPRINGBOOT_VER} ${JAVA_VERSION} org.springframework.boot spring-boot-starter-web org.springframework.boot spring-boot-maven-plugin EOF else info "pom.xml found and kept (use --force to regenerate)" fi # Java source directory if [ ! -d "src/main/java/com/example" ] || [ "1" = "${FORCE_REINSTALL}" ]; then if [ "1" = "${FORCE_REINSTALL}" ] && [ -d "src/main/java/com/example" ]; then info "Force reinstall → removing old src/main/java/com/example" rm -rf "src/main/java/com/example" fi mkdir -p "src/main/java/com/example" || die "Cannot create source directory" else info "Source directory src/main/java/com/example found and kept" fi # ${MAIN_CLASS}.java if [ ! -f "src/main/java/com/example/${MAIN_CLASS}.java" ] || [ "1" = "${FORCE_REINSTALL}" ]; then if [ "1" = "${FORCE_REINSTALL}" ] && [ -f "src/main/java/com/example/${MAIN_CLASS}.java" ]; then rm -f "src/main/java/com/example/${MAIN_CLASS}.java" fi write_file_atomic "src/main/java/com/example/${MAIN_CLASS}.java" <Running on ${JAVA_NAME}"; } } EOF info "${MAIN_CLASS}.java written" else info "${MAIN_CLASS}.java found and kept (use --force to regenerate)" fi # Resources directory if [ ! -d "src/main/resources" ] || [ "1" = "${FORCE_REINSTALL}" ]; then if [ "1" = "${FORCE_REINSTALL}" ] && [ -d "src/main/resources" ]; then info "Force reinstall → removing old src/main/resources" rm -rf "src/main/resources" fi mkdir -p "src/main/resources" || die "Cannot create resources directory" else info "Resources directory src/main/resources found and kept" fi # application.properties if [ ! -f "src/main/resources/application.properties" ] || [ "1" = "${FORCE_REINSTALL}" ]; then if [ "1" = "${FORCE_REINSTALL}" ] && [ -f "src/main/resources/application.properties" ]; then rm -f "src/main/resources/application.properties" fi write_file_atomic "src/main/resources/application.properties" < Run against an existing legacy project" msg " ${APP_NAME} --no-run Setup only (no build/run) — useful for CI/Docker" msg " ${APP_NAME} status Show application status" msg " ${APP_NAME} about Full diagnostics (Java, Maven, project, etc.)" msg " ${APP_NAME} version Show version" msg " ${APP_NAME} version-check Compare with latest" msg " ${APP_NAME} self-update Update to latest version" msg " ${APP_NAME} self-uninstall Remove from the system" msg " ${APP_NAME} reinstall Force reinstall" msg " ${APP_NAME} help This help" plain "" msg "Options:" msg " --project-dir Use custom project directory (default: ~/springboot-${APP_NAME})" msg " --reset Force full project reset (deletes and regenerates everything)" msg " --no-run Setup environment and project only, skip build & run" msg " --quiet, -q Suppress non-error messages" msg " --json Machine-readable JSON output (implies --quiet)" plain "" msg "Important Notes:" msg " • Normal run preserves your existing project files, pom.xml, and source code." if [ -n "${eol_warning}" ]; then msg "${eol_warning}" fi msg " • This tool is intended for projects that need or prefer Spring Boot ${SPRINGBOOT_VER}." plain "" msg "Examples:" msg " ${APP_NAME} # Normal run (recommended)" msg " ${APP_NAME} --project-dir /my/old/project" msg " ${APP_NAME} --no-run # Prepare for Docker/CI" msg " ${APP_NAME} --reset # Clean slate" msg " ${APP_NAME} about --json" plain "" } # ============================================================================= # source_user_shell_config() - Safely source user shell configuration # ============================================================================= # # Purpose: # Many sub-commands (about, version, status, build & run, etc.) are executed # in a non-interactive, non-login shell. # # In such shells, ~/.bashrc (and other user config files) are usually NOT # sourced automatically. This causes problems when users have important # settings in their ~/.bashrc such as: # - PATH modifications # - SDKMAN initialization # - Custom environment variables # - Aliases or functions # # This function ensures the user's normal shell environment is available # for all meaningful operations of springboot2/springboot3/springboot4. # # Why this is needed: # - The main "curl | bash" install runs in a clean shell. # - Sub-commands like "springboot2/springboot3/springboot4 about" or "springboot2/springboot3/springboot4" (normal run) # must respect the user's configured environment. # - SDKMAN users especially need sdkman-init.sh to be loaded. # # Design choices (defensive): # - Only sources once (avoids double sourcing issues) # - Skips completely in --quiet and --json modes (clean output) # - Tries common config files in sensible order # - Suppresses all output and errors during sourcing (never breaks script) # - Always succeeds (return 0) so it never breaks the main flow # - Special handling for SDKMAN because it is critical for this script # # !!! DO NOT MODIFY OR SIMPLIFY THIS FUNCTION !!! # This function protects user environment consistency across all sub-commands. # It is intentionally verbose with heavy comments following CIAO style. # ============================================================================= source_user_shell_config() { # Defensive safe defaults - repeated on purpose : "${QUIET:=0}" : "${JSON:=0}" # Never source in machine modes - keeps output clean and predictable if [ "${JSON}" -eq 1 ] || [ "${QUIET}" -eq 1 ]; then return 0 fi local sourced=0 local config_file="" # Try common user shell configuration files in logical order # bash is most common for this script, then fallback to others for config_file in \ "${HOME}/.bashrc" \ "${HOME}/.bash_profile" \ "${HOME}/.profile" \ "${HOME}/.zshrc"; do if [ -f "${config_file}" ] && [ -r "${config_file}" ]; then # Source safely - never let errors stop the script if source "${config_file}" 2>/dev/null; then sourced=1 # Do not print anything by default (keeps output clean) # You can uncomment the next line during debugging: # info "Sourced user shell config: ${config_file}" break # Only source the first valid file to avoid conflicts fi fi done # Special handling for SDKMAN - critical for Java/Maven availability # Many users rely on SDKMAN being initialized in their normal shell local sdk_init="${HOME}/.sdkman/bin/sdkman-init.sh" if [ -s "${sdk_init}" ]; then # Source silently - failure is acceptable source "${sdk_init}" 2>/dev/null || true fi return 0 } # ============================================================================= # main_spring_boot_app() - Main dispatcher (updated for v2.0) # ============================================================================= # # IMPORTANT: # This main() function is intentionally kept as the single entry point and # command dispatcher. All business logic lives in dedicated functions # # Flag-first parsing for clean support of --project-dir, --no-run, --quiet, --json, etc. # Preserves the critical auto-install behavior for `curl | bash`. # # !!! DO NOT REMOVE OR SIMPLIFY THE AUTO-INSTALL BLOCK !!! # It serves as a strong reminder for future maintenance (including when AI # assistants try to "clean up" the code). # ============================================================================= main_spring_boot_app() { # Safe defaults (repeated on purpose - defensive style) : "${HOME:=/root}" : "${GLOBAL_BIN:=/usr/local/bin}" : "${USER_BIN:=${HOME}/.local/bin}" : "${PROJECT_DIR:=${HOME}/springboot-${APP_NAME}}" : "${NO_RUN:=0}" : "${RESET_PROJECT:=0}" # <<< IMPORTANT: Source user shell configuration >>> # This must happen early so that PATH, SDKMAN, and user environment # are available for ALL sub-commands (about, run, version-check, etc.) source_user_shell_config local cmd="run" # === AUTO-INSTALL FOR ONE-LINER (curl | bash) === # This MUST stay at the top before any argument parsing if ! is_installed && [ $# -eq 0 ]; then if [ "${JSON}" -eq 1 ] || [ "${QUIET}" -eq 1 ]; then perform_self_install else maybe_install fi fi # === FLAG-FIRST ARGUMENT PARSING (consistent with pomo style) === while [ $# -gt 0 ]; do case "$1" in --project-dir) if [ -z "$2" ]; then die "--project-dir requires a path" fi PROJECT_DIR="$2" shift ;; --no-run) NO_RUN=1 ;; --quiet|-q) QUIET=1 ;; --json) JSON=1; QUIET=1 ;; --force) FORCE=1 ;; --force-user) FORCE_USER=1 ;; --force-root) FORCE_ROOT=1 ;; --help|-h) cmd="help";; --*) ;; -*) ;; *) cmd="$1" ;; esac shift done # === Command Dispatch === case "${cmd}" in version) if [ "${JSON}" -eq 1 ]; then output_json "version" "" "app" "${APP_NAME}" "version" "${VERSION}" else msg "${APP_NAME} version ${VERSION}" fi exit 0 ;; version-check) version_check exit 0 ;; self-update) self_update exit 0 ;; self-uninstall) self_uninstall exit 0 ;; about) show_about_spring_boot_app exit 0 ;; help) show_spring_boot_help exit 0 ;; run) # Normal execution flow ;; *) die "Invalid command: ${cmd}" ;; esac # === Main Setup Flow === check_alpine_requirements setup_sdkman setup_java setup_maven setup_springboot_project if [ "${NO_RUN}" -eq 1 ]; then if [ "${JSON}" -eq 1 ]; then output_json "success" "" \ "message" "Environment and project setup completed" \ "project_dir" "${PROJECT_DIR}" \ "no_run" "true" else success "Environment and project setup completed (--no-run)." success "Project location: ${PROJECT_DIR}" fi exit 0 fi # Normal build + run build_and_run } # Call the main function main_spring_boot_app "$@"