#!/usr/bin/env bash set -euo pipefail # ============================================================================= # setup/configure_runners.sh — Interactive runners.conf configuration wizard # Prompts for each runner's fields with validation and progress tracking. # Persists answers to runners.conf as each prompt is completed. # Defaults are pulled from .env — nothing is hardcoded. # ============================================================================= SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" PROJECT_ROOT="${SCRIPT_DIR}/.." RUNNERS_CONF="${PROJECT_ROOT}/runners.conf" # shellcheck source=../lib/common.sh # shellcheck disable=SC1091 source "${PROJECT_ROOT}/lib/common.sh" # Load .env for defaults (RUNNER_DEFAULT_IMAGE, etc.) load_env # Colors — only emit ANSI escapes when stdout is a terminal (not piped/redirected) if [[ -t 1 ]]; then C_RESET='\033[0m'; C_BOLD='\033[1m'; C_GREEN='\033[0;32m' C_YELLOW='\033[0;33m'; C_RED='\033[0;31m'; C_DIM='\033[2m' else C_RESET=''; C_BOLD=''; C_GREEN=''; C_YELLOW=''; C_RED=''; C_DIM='' fi # --------------------------------------------------------------------------- # Load existing runner entries as defaults (idempotent re-runs). # If runners.conf already exists and is INI format, we read existing values # to pre-fill prompts when re-running the wizard. # --------------------------------------------------------------------------- DEFAULTS_CONF="" cleanup_defaults_conf() { if [[ -n "$DEFAULTS_CONF" ]] && [[ -f "$DEFAULTS_CONF" ]]; then rm -f "$DEFAULTS_CONF" fi } trap cleanup_defaults_conf EXIT EXISTING_SECTIONS=() if [[ -f "$RUNNERS_CONF" ]]; then # Keep a snapshot for defaults so we can rewrite runners.conf incrementally # without losing pre-filled values when the wizard is re-run. DEFAULTS_CONF=$(mktemp) cp "$RUNNERS_CONF" "$DEFAULTS_CONF" while IFS= read -r sec; do [[ -z "$sec" ]] && continue EXISTING_SECTIONS+=("$sec") done < <(ini_list_sections "$DEFAULTS_CONF") fi EXISTING_COUNT=${#EXISTING_SECTIONS[@]} ini_default_get() { local section="$1" key="$2" default="${3:-}" if [[ -z "$DEFAULTS_CONF" ]]; then printf '%s' "$default" return 0 fi ini_get "$DEFAULTS_CONF" "$section" "$key" "$default" } # --------------------------------------------------------------------------- # Validation helpers # --------------------------------------------------------------------------- validate_runner_name() { [[ "$1" =~ ^[a-zA-Z0-9][a-zA-Z0-9_-]*$ ]] } validate_runner_type() { [[ "$1" == "docker" || "$1" == "native" ]] } validate_runner_host() { [[ "$1" == "unraid" || "$1" == "fedora" || "$1" == "local" || "$1" == "custom" ]] } validate_runner_host_type_combo() { local host="$1" runner_type="$2" if [[ "$host" == "local" ]]; then [[ "$runner_type" == "native" ]] else [[ "$runner_type" == "docker" ]] fi } validate_runner_path() { # shellcheck disable=SC2088 # tilde intentionally stored as literal string [[ "$1" == /* || "$1" == "~/"* || "$1" == "~" ]] } validate_runner_repos() { if [[ "$1" == "all" ]]; then return 0; fi # Accept comma-separated list of repo names (e.g. "augur,periodvault") local _input _rn _found _item IFS=',' read -ra _input <<< "$1" for _item in "${_input[@]}"; do _item="${_item## }"; _item="${_item%% }" # trim spaces [[ -z "$_item" ]] && return 1 _found=0 for _rn in ${REPO_NAMES:-}; do if [[ "$_item" == "$_rn" ]]; then _found=1; break; fi done [[ $_found -eq 0 ]] && return 1 done return 0 } validate_capacity() { [[ "$1" =~ ^[1-9][0-9]*$ ]] } validate_docker_cpu() { [[ -z "$1" ]] || [[ "$1" =~ ^[0-9]+(\.[0-9]+)?$ ]] } validate_docker_memory() { [[ -z "$1" ]] || [[ "$1" =~ ^[0-9]+[kmgKMG]?$ ]] } # --------------------------------------------------------------------------- # Prompt function — matches configure_env.sh UX (progress counter, default # values in yellow brackets, validation loop with red error hints). # Sets the global PROMPT_RESULT to the validated user input. # --------------------------------------------------------------------------- CURRENT_PROMPT=0 TOTAL_PROMPTS=0 prompt_field() { local field_name="$1" # e.g. "name", "ssh_host" local description="$2" # human-readable hint shown in parentheses local validation="$3" # validator key local default="${4:-}" # pre-filled value shown in [brackets]; Enter accepts it local allow_empty="${5:-false}" # true if empty is a valid answer CURRENT_PROMPT=$((CURRENT_PROMPT + 1)) local progress progress=$(printf '[%d/%d]' "$CURRENT_PROMPT" "$TOTAL_PROMPTS") local prompt_text if [[ -n "$default" ]]; then prompt_text=$(printf '%b%s%b %s (%s) %b[%s]%b: ' "$C_DIM" "$progress" "$C_RESET" "$field_name" "$description" "$C_YELLOW" "$default" "$C_RESET") else prompt_text=$(printf '%b%s%b %s (%s): ' "$C_DIM" "$progress" "$C_RESET" "$field_name" "$description") fi local value while true; do printf '%b' "$prompt_text" read -r value if [[ -z "$value" ]]; then value="$default" fi # Allow empty when explicitly permitted (optional fields) if [[ -z "$value" ]] && [[ "$allow_empty" == "true" ]]; then break fi if [[ -z "$value" ]]; then printf '%b Invalid: value cannot be empty%b\n' "$C_RED" "$C_RESET" continue fi case "$validation" in runner_name) if validate_runner_name "$value"; then break; fi printf '%b Invalid: alphanumeric, hyphens, and underscores only%b\n' "$C_RED" "$C_RESET" ;; runner_type) if validate_runner_type "$value"; then break; fi printf '%b Invalid: must be "docker" or "native"%b\n' "$C_RED" "$C_RESET" ;; runner_host) if validate_runner_host "$value"; then break; fi printf '%b Invalid: must be unraid, fedora, local, or custom%b\n' "$C_RED" "$C_RESET" ;; runner_path) if validate_runner_path "$value"; then break; fi printf '%b Invalid path (must start with / or ~/)%b\n' "$C_RED" "$C_RESET" ;; runner_repos) if validate_runner_repos "$value"; then break; fi printf '%b Invalid: must be "all" or comma-separated repo names from: %s%b\n' "$C_RED" "${REPO_NAMES:-}" "$C_RESET" ;; capacity) if validate_capacity "$value"; then break; fi printf '%b Invalid: must be a positive integer (>= 1)%b\n' "$C_RED" "$C_RESET" ;; docker_cpu) if validate_docker_cpu "$value"; then break; fi printf '%b Invalid: must be a positive number (e.g. 2.0, 0.5) or empty%b\n' "$C_RED" "$C_RESET" ;; docker_memory) if validate_docker_memory "$value"; then break; fi printf '%b Invalid: must be Docker memory format (e.g. 2g, 512m) or empty%b\n' "$C_RED" "$C_RESET" ;; bool) if [[ "$value" == "true" || "$value" == "false" ]]; then break; fi printf '%b Invalid: must be "true" or "false"%b\n' "$C_RED" "$C_RESET" ;; ip) if validate_ip "$value"; then break; fi printf '%b Invalid IP address format (expected: x.x.x.x)%b\n' "$C_RED" "$C_RESET" ;; port) if validate_port "$value"; then break; fi printf '%b Invalid port (expected: 1-65535)%b\n' "$C_RED" "$C_RESET" ;; optional_path) if validate_optional_path "$value"; then break; fi printf '%b Invalid path (must start with / or ~/ or be empty)%b\n' "$C_RED" "$C_RESET" ;; nonempty|*) if validate_nonempty "$value"; then break; fi printf '%b Invalid: value cannot be empty%b\n' "$C_RED" "$C_RESET" ;; esac done PROMPT_RESULT="$value" } init_runners_conf() { cat > "$RUNNERS_CONF" <<'HEADER' # ============================================================================= # runners.conf — Gitea Actions Runner Definitions (INI format) # Generated by setup/configure_runners.sh — edit manually or re-run wizard. # Use manage_runner.sh to add/remove runners dynamically. # See runners.conf.example for field reference. # ============================================================================= HEADER } ensure_runner_section() { local section="$1" if ! grep -Fqx "[$section]" "$RUNNERS_CONF" 2>/dev/null; then printf '[%s]\n\n' "$section" >> "$RUNNERS_CONF" fi } save_runner_field() { local section="$1" key="$2" value="$3" ini_set "$RUNNERS_CONF" "$section" "$key" "$value" _cdata_set "${section}:${key}" "$value" } # =========================================================================== # Main # =========================================================================== printf '\n%b╔══════════════════════════════════════════════════════════╗%b\n' "$C_BOLD" "$C_RESET" printf '%b║ Gitea Migration — Runner Setup ║%b\n' "$C_BOLD" "$C_RESET" printf '%b╚══════════════════════════════════════════════════════════╝%b\n\n' "$C_BOLD" "$C_RESET" printf 'Press Enter to keep the current value shown in [brackets].\n' printf 'Defaults are pulled from .env variables.\n' # --------------------------------------------------------------------------- # Ask how many runners to configure. # Default is 3 (unraid + fedora + macbook) or the count from existing config. # --------------------------------------------------------------------------- local_default=3 if [[ $EXISTING_COUNT -gt 0 ]]; then local_default=$EXISTING_COUNT fi printf '\n%b── RUNNER COUNT ──────────────────────────────────────────%b\n' "$C_BOLD" "$C_RESET" printf 'How many runners to configure? %b[%s]%b: ' "$C_YELLOW" "$local_default" "$C_RESET" read -r runner_count if [[ -z "$runner_count" ]]; then runner_count=$local_default fi if ! [[ "$runner_count" =~ ^[0-9]+$ ]] || [[ "$runner_count" -lt 1 ]]; then printf '%b Invalid: must be a positive number. Using %d.%b\n' "$C_RED" "$local_default" "$C_RESET" runner_count=$local_default fi # Prompt count per runner: # name(1) + host(1) + type(1) + data_path(1) + labels(1) + default_image(1) + # repos(1) + capacity(1) + cpu(1) + memory(1) + boot(1) = 11 # Custom host adds: ssh_host(1) + ssh_user(1) + ssh_port(1) + ssh_key(1) = 4 # We estimate max 11 per runner for progress display TOTAL_PROMPTS=$((runner_count * 11)) # --------------------------------------------------------------------------- # Configure runner definitions # --------------------------------------------------------------------------- # Keep arrays for end-of-run summary; values are persisted immediately. COLLECTED_NAMES=() # Bash 3.2 compatible key-value store (no associative arrays) _CDATA_KEYS=() _CDATA_VALS=() _cdata_set() { local k="$1" v="$2" i for i in "${!_CDATA_KEYS[@]}"; do if [[ "${_CDATA_KEYS[$i]}" == "$k" ]]; then _CDATA_VALS[i]="$v" return 0 fi done _CDATA_KEYS+=("$k") _CDATA_VALS+=("$v") } _cdata_get() { local k="$1" default="${2:-}" i for i in "${!_CDATA_KEYS[@]}"; do if [[ "${_CDATA_KEYS[$i]}" == "$k" ]]; then printf '%s' "${_CDATA_VALS[$i]}" return 0 fi done printf '%s' "$default" } init_runners_conf for ((i = 0; i < runner_count; i++)); do runner_num=$((i + 1)) printf '\n%b── RUNNER %d OF %d ──────────────────────────────────────────%b\n' "$C_BOLD" "$runner_num" "$runner_count" "$C_RESET" # Existing defaults from previous runners.conf (if available) ex_name="${EXISTING_SECTIONS[$i]:-}" # --- name --- name_default="$ex_name" if [[ -z "$name_default" ]]; then case $i in 0) name_default="unraid-runner" ;; 1) name_default="fedora-runner" ;; 2) name_default="macbook-runner" ;; *) name_default="runner-${runner_num}" ;; esac fi prompt_field "name" "runner display name in Gitea" "runner_name" "$name_default" r_name="$PROMPT_RESULT" COLLECTED_NAMES+=("$r_name") ensure_runner_section "$r_name" # --- host --- host_default="" if [[ -n "$ex_name" ]]; then host_default=$(ini_default_get "$ex_name" "host" "") fi if [[ -z "$host_default" ]]; then case $i in 0) host_default="unraid" ;; 1) host_default="fedora" ;; 2) host_default="local" ;; *) host_default="unraid" ;; esac fi prompt_field "host" "unraid, fedora, local, or custom" "runner_host" "$host_default" r_host="$PROMPT_RESULT" save_runner_field "$r_name" "host" "$r_host" # Show resolved SSH details for known hosts case "$r_host" in unraid) printf '%b → SSH: %s@%s:%s%b\n' "$C_DIM" "${UNRAID_SSH_USER:-}" "${UNRAID_IP:-}" "${UNRAID_SSH_PORT:-22}" "$C_RESET" if [[ -n "${UNRAID_SSH_KEY:-}" ]]; then printf '%b → SSH key: %s%b\n' "$C_DIM" "${UNRAID_SSH_KEY}" "$C_RESET" fi ;; fedora) printf '%b → SSH: %s@%s:%s%b\n' "$C_DIM" "${FEDORA_SSH_USER:-}" "${FEDORA_IP:-}" "${FEDORA_SSH_PORT:-22}" "$C_RESET" if [[ -n "${FEDORA_SSH_KEY:-}" ]]; then printf '%b → SSH key: %s%b\n' "$C_DIM" "${FEDORA_SSH_KEY}" "$C_RESET" fi ;; local) printf '%b → Runs on this machine (no SSH)%b\n' "$C_DIM" "$C_RESET" ;; custom) # Prompt for custom SSH details ex_ssh_host="" ex_ssh_user="" ex_ssh_port="" ex_ssh_key="" if [[ -n "$ex_name" ]]; then ex_ssh_host=$(ini_default_get "$ex_name" "ssh_host" "") ex_ssh_user=$(ini_default_get "$ex_name" "ssh_user" "") ex_ssh_port=$(ini_default_get "$ex_name" "ssh_port" "22") ex_ssh_key=$(ini_default_get "$ex_name" "ssh_key" "") fi prompt_field "ssh_host" "IP address of remote host" "ip" "$ex_ssh_host" save_runner_field "$r_name" "ssh_host" "$PROMPT_RESULT" prompt_field "ssh_user" "SSH username" "nonempty" "${ex_ssh_user:-root}" save_runner_field "$r_name" "ssh_user" "$PROMPT_RESULT" prompt_field "ssh_port" "SSH port" "port" "${ex_ssh_port:-22}" save_runner_field "$r_name" "ssh_port" "$PROMPT_RESULT" prompt_field "ssh_key" "path to SSH key (empty = use default keys)" "optional_path" "$ex_ssh_key" "true" save_runner_field "$r_name" "ssh_key" "$PROMPT_RESULT" # Adjust total prompts (added 4 custom fields, but we already allocated 10) ;; esac # --- type --- type_default="" if [[ -n "$ex_name" ]]; then type_default=$(ini_default_get "$ex_name" "type" "") fi if [[ -z "$type_default" ]]; then if [[ "$r_host" == "local" ]]; then type_default="native"; else type_default="docker"; fi fi if [[ "$r_host" == "local" ]]; then type_hint="native only for host=local" else type_hint="docker only for host=unraid/fedora/custom" fi prompt_field "type" "$type_hint" "runner_type" "$type_default" r_type="$PROMPT_RESULT" while ! validate_runner_host_type_combo "$r_host" "$r_type"; do if [[ "$r_host" == "local" ]]; then printf '%b Invalid combo: host=local requires type=native%b\n' "$C_RED" "$C_RESET" prompt_field "type" "native only for host=local" "runner_type" "native" else printf '%b Invalid combo: host=%s requires type=docker%b\n' "$C_RED" "$r_host" "$C_RESET" prompt_field "type" "docker only for host=unraid/fedora/custom" "runner_type" "docker" fi r_type="$PROMPT_RESULT" done save_runner_field "$r_name" "type" "$r_type" # --- data_path --- path_default="" if [[ -n "$ex_name" ]]; then path_default=$(ini_default_get "$ex_name" "data_path" "") fi if [[ -z "$path_default" ]]; then if [[ "$r_type" == "native" ]]; then # shellcheck disable=SC2088 # tilde intentionally stored as literal (expanded at runtime) path_default="~/gitea-runner/${r_name}" elif [[ "$r_host" == "fedora" ]]; then path_default="${FEDORA_GITEA_DATA_PATH:-/mnt/nvme/gitea}/runner/${r_name}" else path_default="${UNRAID_GITEA_DATA_PATH:-/mnt/nvme/gitea}/runner/${r_name}" fi fi prompt_field "data_path" "absolute path for runner data" "runner_path" "$path_default" save_runner_field "$r_name" "data_path" "$PROMPT_RESULT" # --- labels --- labels_default="" if [[ -n "$ex_name" ]]; then labels_default=$(ini_default_get "$ex_name" "labels" "") fi if [[ -z "$labels_default" ]]; then if [[ "$r_type" == "native" ]]; then labels_default="macos"; else labels_default="linux"; fi fi prompt_field "labels" "workflow runs-on value" "nonempty" "$labels_default" save_runner_field "$r_name" "labels" "$PROMPT_RESULT" # --- default_image (skip for native) --- if [[ "$r_type" == "docker" ]]; then image_default="" if [[ -n "$ex_name" ]]; then image_default=$(ini_default_get "$ex_name" "default_image" "") fi if [[ -z "$image_default" ]]; then image_default="${RUNNER_DEFAULT_IMAGE:-catthehacker/ubuntu:act-latest}" fi prompt_field "default_image" "Docker image for job execution" "nonempty" "$image_default" save_runner_field "$r_name" "default_image" "$PROMPT_RESULT" else save_runner_field "$r_name" "default_image" "" CURRENT_PROMPT=$((CURRENT_PROMPT + 1)) # skip but count for progress fi # --- repos --- repos_default="" if [[ -n "$ex_name" ]]; then repos_default=$(ini_default_get "$ex_name" "repos" "all") fi if [[ -z "$repos_default" ]]; then repos_default="all"; fi # "all" = instance-level. Comma-separated repo names = one runner entry per repo. prompt_field "repos" "\"all\" or comma-separated repo names (e.g. augur,periodvault)" "runner_repos" "$repos_default" save_runner_field "$r_name" "repos" "$PROMPT_RESULT" # --- capacity --- cap_default="" if [[ -n "$ex_name" ]]; then cap_default=$(ini_default_get "$ex_name" "capacity" "") fi if [[ -z "$cap_default" ]]; then # Smart defaults per host type case "$r_host" in unraid) cap_default=2 ;; fedora) cap_default=2 ;; *) cap_default=1 ;; # macOS / native esac fi prompt_field "capacity" "max concurrent jobs (>= 1)" "capacity" "$cap_default" save_runner_field "$r_name" "capacity" "$PROMPT_RESULT" # --- cpu (skip for native) --- if [[ "$r_type" == "docker" ]]; then cpu_default="" if [[ -n "$ex_name" ]]; then cpu_default=$(ini_default_get "$ex_name" "cpu" "") fi prompt_field "cpu" "Docker CPU limit (e.g. 2.0, empty = no limit)" "docker_cpu" "$cpu_default" "true" save_runner_field "$r_name" "cpu" "$PROMPT_RESULT" else save_runner_field "$r_name" "cpu" "" CURRENT_PROMPT=$((CURRENT_PROMPT + 1)) fi # --- memory (skip for native) --- if [[ "$r_type" == "docker" ]]; then mem_default="" if [[ -n "$ex_name" ]]; then mem_default=$(ini_default_get "$ex_name" "memory" "") fi prompt_field "memory" "Docker memory limit (e.g. 2g, empty = no limit)" "docker_memory" "$mem_default" "true" save_runner_field "$r_name" "memory" "$PROMPT_RESULT" else save_runner_field "$r_name" "memory" "" CURRENT_PROMPT=$((CURRENT_PROMPT + 1)) fi # --- boot (skip for docker — only applies to native macOS runners) --- # boot=true installs the launchd plist to /Library/LaunchDaemons/ (starts at # boot before login, requires sudo). boot=false installs to ~/Library/LaunchAgents/ # (starts at login, no sudo needed). if [[ "$r_type" == "native" ]]; then boot_default="" if [[ -n "$ex_name" ]]; then boot_default=$(ini_default_get "$ex_name" "boot" "false") fi if [[ -z "$boot_default" ]]; then boot_default="false"; fi prompt_field "boot" "start at boot (before login)? requires sudo [true/false]" "bool" "$boot_default" save_runner_field "$r_name" "boot" "$PROMPT_RESULT" else save_runner_field "$r_name" "boot" "" CURRENT_PROMPT=$((CURRENT_PROMPT + 1)) fi done # =========================================================================== # Expand multi-repo runners into one section per repo. # A single act_runner process can only register to one repo, so "repos=a,b" # becomes two INI sections: [name-a] repos=a and [name-b] repos=b. # =========================================================================== EXPANDED_NAMES=() _orig_path="" for r_name in "${COLLECTED_NAMES[@]}"; do r_repos="$(_cdata_get "${r_name}:repos")" if [[ "$r_repos" == "all" ]] || [[ "$r_repos" != *,* ]]; then EXPANDED_NAMES+=("$r_name") continue fi # Multi-repo: split and create per-repo sections IFS=',' read -ra _repo_list <<< "$r_repos" for _repo in "${_repo_list[@]}"; do _repo="${_repo## }"; _repo="${_repo%% }" # trim spaces _expanded_name="${r_name}-${_repo}" # Copy all fields from the original section, override repos and name ini_copy_section "$RUNNERS_CONF" "$r_name" "$_expanded_name" ini_set "$RUNNERS_CONF" "$_expanded_name" "repos" "$_repo" # Update data_path to avoid collisions _orig_path=$(ini_get "$RUNNERS_CONF" "$r_name" "data_path" "") if [[ -n "$_orig_path" ]]; then ini_set "$RUNNERS_CONF" "$_expanded_name" "data_path" "${_orig_path%/}-${_repo}" fi # Track for summary _cdata_set "${_expanded_name}:host" "$(_cdata_get "${r_name}:host")" _cdata_set "${_expanded_name}:type" "$(_cdata_get "${r_name}:type")" _cdata_set "${_expanded_name}:boot" "$(_cdata_get "${r_name}:boot")" _cdata_set "${_expanded_name}:labels" "$(_cdata_get "${r_name}:labels")" _cdata_set "${_expanded_name}:capacity" "$(_cdata_get "${r_name}:capacity")" _cdata_set "${_expanded_name}:repos" "$_repo" _cdata_set "${_expanded_name}:data_path" "${_orig_path%/}-${_repo}" EXPANDED_NAMES+=("$_expanded_name") done # Remove the original multi-repo section ini_remove_section "$RUNNERS_CONF" "$r_name" done COLLECTED_NAMES=("${EXPANDED_NAMES[@]}") # =========================================================================== # Summary — green border box + table # =========================================================================== printf '\n%b╔══════════════════════════════════════════════════════════╗%b\n' "$C_GREEN" "$C_RESET" printf '%b║ Runner Configuration Complete ║%b\n' "$C_GREEN" "$C_RESET" printf '%b╚══════════════════════════════════════════════════════════╝%b\n\n' "$C_GREEN" "$C_RESET" printf '%bRunners configured:%b %d\n\n' "$C_BOLD" "$C_RESET" "${#COLLECTED_NAMES[@]}" printf '%-20s %-10s %-8s %-6s %-10s %-6s %-10s %-24s\n' "NAME" "HOST" "TYPE" "BOOT" "LABELS" "CAP" "REPOS" "DATA PATH" printf '%-20s %-10s %-8s %-6s %-10s %-6s %-10s %-24s\n' "----" "----" "----" "----" "------" "---" "-----" "---------" for r_name in "${COLLECTED_NAMES[@]}"; do printf '%-20s %-10s %-8s %-6s %-10s %-6s %-10s %-24s\n' \ "$r_name" \ "$(_cdata_get "${r_name}:host")" \ "$(_cdata_get "${r_name}:type")" \ "$(_cdata_get "${r_name}:boot" "—")" \ "$(_cdata_get "${r_name}:labels")" \ "$(_cdata_get "${r_name}:capacity")" \ "$(_cdata_get "${r_name}:repos")" \ "$(_cdata_get "${r_name}:data_path")" done printf '\n Saved to: %s\n\n' "$RUNNERS_CONF" printf 'Next step: run %bsetup/macbook.sh%b to install local prerequisites.\n' "$C_BOLD" "$C_RESET"