Requested: separate, non-interactive "installed" (x) and "#" (instance count) columns ahead of the actual selectable checkbox, with the description no longer carrying any install-status text at all. whiptail's checklist only has one interactive element per row — the checkbox — so there's no such thing as a real extra column, tabbable or not; the tag and item fields are always just inert display text regardless of what's in them. The closest real equivalent: bake a fixed-width "x" (installed) + count prefix into the tag field itself. whiptail pads every row's tag field to the same width, so it lines up visually like columns even though it's one string underneath. Extract the plain name back out before dispatch by taking the last whitespace-separated token, since service names never contain spaces — robust regardless of the exact prefix width. Description field is back to plain SERVICE_DESC text now that install status lives in the tag prefix instead.
482 lines
23 KiB
Bash
Executable File
482 lines
23 KiB
Bash
Executable File
#!/bin/bash
|
|
# setup.sh — modular post-install dispatcher.
|
|
#
|
|
# One source of truth, multiple ways to run it:
|
|
# sudo ./setup.sh guided install: required packages, then a
|
|
# category menu you loop through
|
|
# sudo ./setup.sh <service> ... install one or more services directly
|
|
# ./setup.sh --list list available services (grouped)
|
|
# ./setup.sh --status list services with install status (no whiptail)
|
|
# ./setup.sh --version print version
|
|
#
|
|
# Flags:
|
|
# --dry-run preview actions without making changes
|
|
# --unattended use defaults, no prompts (pair with explicit service names)
|
|
#
|
|
# Every service lives in services/<name>.sh, registers itself with
|
|
# register_service, and defines install_<name>. Adding a service = adding one
|
|
# file; it appears in the menu automatically. Nothing is generated.
|
|
set -uo pipefail
|
|
|
|
HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
|
|
# whiptail requires a valid TERM; when piped through bash (curl | bash) TERM
|
|
# may be unset, causing raw-mode to fail and arrow keys to leak to the shell.
|
|
export TERM="${TERM:-xterm-256color}"
|
|
|
|
# Category display order (groups not listed here are appended alphabetically).
|
|
CATEGORY_ORDER=(base homelab utilities media cameras gaming extras backup)
|
|
# Service ordering hint within a category (lower = earlier). Default 50.
|
|
declare -A SERVICE_PRIORITY=( [caddy]=1 [crowdsec]=2 [authelia]=3 )
|
|
# Retired service names that now resolve to another service. Keeps a name
|
|
# that used to work on the command line (and in docs/muscle memory) working
|
|
# after a merge, without giving it a second menu entry of its own.
|
|
declare -A SERVICE_ALIAS=( [asterisk-digital-ocean]=asterisk )
|
|
|
|
# ── Parse flags / collect service names ──────────────────────────────────────
|
|
DRY_RUN=false; UNATTENDED=false; DO_LIST=false; DO_STATUS=false
|
|
REQUESTED=()
|
|
for arg in "$@"; do
|
|
case "$arg" in
|
|
--dry-run) DRY_RUN=true ;;
|
|
--unattended) UNATTENDED=true ;;
|
|
--list|-l) DO_LIST=true ;;
|
|
--status) DO_STATUS=true ;;
|
|
--version|-V) cat "$HERE/VERSION" 2>/dev/null || echo "unknown"; exit 0 ;;
|
|
-h|--help) sed -n '2,18p' "${BASH_SOURCE[0]}" | sed 's/^# \{0,1\}//'; exit 0 ;;
|
|
-*) echo "Unknown flag: $arg" >&2; exit 1 ;;
|
|
*) REQUESTED+=("$arg") ;;
|
|
esac
|
|
done
|
|
export DRY_RUN UNATTENDED
|
|
|
|
# ── Load helpers + all service modules (they self-register) ──────────────────
|
|
# shellcheck source=lib/common.sh
|
|
source "$HERE/lib/common.sh"
|
|
shopt -s nullglob
|
|
for _mod in "$HERE"/services/*.sh; do source "$_mod"; done
|
|
shopt -u nullglob
|
|
|
|
# ── Helpers over the registry ────────────────────────────────────────────────
|
|
# Groups present, in CATEGORY_ORDER first, then any extras alphabetically.
|
|
groups_present() {
|
|
local g present=() seen=" "
|
|
for name in "${SERVICE_ORDER[@]}"; do
|
|
g="${SERVICE_GROUP[$name]}"
|
|
case "$seen" in *" $g "*) : ;; *) present+=("$g"); seen="$seen$g " ;; esac
|
|
done
|
|
local out=()
|
|
for g in "${CATEGORY_ORDER[@]}"; do
|
|
printf '%s\n' "${present[@]}" | grep -qx "$g" && out+=("$g")
|
|
done
|
|
for g in "${present[@]}"; do
|
|
printf '%s\n' "${CATEGORY_ORDER[@]}" | grep -qx "$g" || out+=("$g")
|
|
done
|
|
printf '%s\n' "${out[@]}"
|
|
}
|
|
|
|
# Services in a group, ordered by SERVICE_PRIORITY then name.
|
|
services_in_group() {
|
|
local group="$1" name
|
|
for name in "${SERVICE_ORDER[@]}"; do
|
|
[ "${SERVICE_GROUP[$name]}" = "$group" ] && echo "${SERVICE_PRIORITY[$name]:-50} $name"
|
|
done | sort -n -k1 | awk '{print $2}'
|
|
}
|
|
|
|
# Best-effort "is it already installed?" for the [installed] marker.
|
|
is_installed() {
|
|
case "$1" in
|
|
base) command -v ncdu >/dev/null 2>&1 ;;
|
|
glow) command -v glow >/dev/null 2>&1 ;;
|
|
crowdsec) command -v cscli >/dev/null 2>&1 ;;
|
|
security-dashboard) [ -f /opt/security-dashboard/app.py ] ;;
|
|
kdeconnect) command -v kdeconnect >/dev/null 2>&1 ;;
|
|
silent-send) [ -d "$ACTUAL_HOME/silent-send/.git" ] ;;
|
|
sync-cc) [ -f "$ACTUAL_HOME/sync-cc/sync_cc.py" ] ;;
|
|
sky-cam) [ -d "$ACTUAL_HOME/sky-cam/.git" ] ;;
|
|
sky-cam-frigate) [ -d "$ACTUAL_HOME/sky-cam/.git" ] && [ -f "$ACTUAL_HOME/sky-cam/frigate-retime.sh" ] ;;
|
|
# Either directory counts: boxes set up before the droplet edition was
|
|
# merged back into `asterisk` still run out of ~/docker/asterisk-digital-ocean.
|
|
asterisk) [ -e "$DOCKER_DIR/asterisk" ] || [ -e "$DOCKER_DIR/asterisk-digital-ocean" ] ;;
|
|
pstn-trunk) [ -f "$DOCKER_DIR/asterisk-digital-ocean/config/asterisk/pstn-trunk-pjsip.conf" ] || [ -f "$DOCKER_DIR/asterisk/config/asterisk/pstn-trunk-pjsip.conf" ] ;;
|
|
sms-inbound) [ -f /opt/sms-inbound/settings.env ] ;;
|
|
ssh-config) false ;; # repeatable management tool, never shows [installed]
|
|
# Every WordPress site is named from the first one on (no plain
|
|
# $DOCKER_DIR/wordpress dir the default case below could match) —
|
|
# [installed] means "at least one site exists", not any specific one.
|
|
wordpress) compgen -G "$DOCKER_DIR/wordpress-*" >/dev/null 2>&1 ;;
|
|
*) [ -e "$DOCKER_DIR/$1" ] ;;
|
|
esac
|
|
}
|
|
|
|
# How many instances of a service are installed — most services here are
|
|
# single-instance, but several support the multi-instance pattern from
|
|
# CLAUDE.md (a base install plus any number of "<name>-<suffix>" siblings,
|
|
# e.g. mattermost + mattermost-team-b). Only the default case knows that
|
|
# naming convention; the specially-cased services above aren't part of the
|
|
# multi-instance pattern (wordpress is the one exception and already counts
|
|
# sites directly), so for those this just mirrors is_installed() as 0 or 1.
|
|
install_count() {
|
|
case "$1" in
|
|
base|glow|crowdsec|security-dashboard|kdeconnect|silent-send|sync-cc|sky-cam|sky-cam-frigate|asterisk|pstn-trunk|sms-inbound|ssh-config)
|
|
is_installed "$1" && echo 1 || echo 0 ;;
|
|
wordpress)
|
|
find "$DOCKER_DIR" -mindepth 1 -maxdepth 1 -name 'wordpress-*' -type d 2>/dev/null | wc -l ;;
|
|
*)
|
|
local c=0
|
|
[ -e "$DOCKER_DIR/$1" ] && c=1
|
|
c=$((c + $(find "$DOCKER_DIR" -mindepth 1 -maxdepth 1 -name "$1-*" -type d 2>/dev/null | wc -l)))
|
|
echo "$c"
|
|
;;
|
|
esac
|
|
}
|
|
|
|
run_service() {
|
|
local name="$1"
|
|
if [ -n "${SERVICE_ALIAS[$name]:-}" ]; then
|
|
log_info "'$name' is now part of '${SERVICE_ALIAS[$name]}' — running that instead."
|
|
name="${SERVICE_ALIAS[$name]}"
|
|
fi
|
|
if [ -z "${SERVICE_GROUP[$name]:-}" ]; then log_error "Unknown service: $name (try --list)"; return 1; fi
|
|
declare -F "install_${name}" >/dev/null || { log_error "Service '$name' has no install_${name}"; return 1; }
|
|
log_info "=== ${name} (${SERVICE_DESC[$name]}) ==="
|
|
"install_${name}"
|
|
}
|
|
|
|
list_services() {
|
|
local g name
|
|
while IFS= read -r g; do
|
|
echo ""; echo "── ${g^^} ──"
|
|
while IFS= read -r name; do
|
|
printf " %-16s %s\n" "$name" "${SERVICE_DESC[$name]}"
|
|
done < <(services_in_group "$g")
|
|
done < <(groups_present)
|
|
echo ""
|
|
}
|
|
|
|
# Plain-text version of the whiptail checklist's [installed] marker — same
|
|
# is_installed() calls, no whiptail involved. Exists so "is X actually
|
|
# installed" can be answered by reading terminal output directly instead of
|
|
# a checklist screen, where a narrow/resized terminal can truncate or wrap
|
|
# the "[installed]" suffix off-screen without it being obvious that's what
|
|
# happened.
|
|
print_status() {
|
|
local g name marker count
|
|
while IFS= read -r g; do
|
|
echo ""; echo "── ${g^^} ──"
|
|
while IFS= read -r name; do
|
|
count="$(install_count "$name")"
|
|
marker="not installed"
|
|
[ "$count" -gt 0 ] && marker="INSTALLED (x$count)"
|
|
printf " %-20s %-16s %s\n" "$name" "$marker" "${SERVICE_DESC[$name]}"
|
|
done < <(services_in_group "$g")
|
|
done < <(groups_present)
|
|
echo ""
|
|
}
|
|
|
|
# ── Site defaults wizard ──────────────────────────────────────────────────────
|
|
# Prompts for timezone, base domain, and Caddy network name; saves to .config.
|
|
# Run directly: sudo ./setup.sh configure
|
|
# Asks only "where does Caddy run?" and saves it. Always runs unconditionally
|
|
# (not gated behind a y/n) since every service's Caddy prompt depends on this.
|
|
ask_caddy_location() {
|
|
echo ""
|
|
echo "╔══════════════════════════════════════════════════════════════╗"
|
|
echo "║ Where does Caddy run? ║"
|
|
echo "╚══════════════════════════════════════════════════════════════╝"
|
|
echo ""
|
|
# Resolve current Caddy mode for display — handle legacy CADDY_REMOTE_HOST
|
|
local _cur_mode="${CADDY_MODE:-}"
|
|
[ -z "$_cur_mode" ] && [ -n "${CADDY_REMOTE_HOST:-}" ] && _cur_mode="remote"
|
|
[ -z "$_cur_mode" ] && _cur_mode="local"
|
|
|
|
echo " [1] This machine — Caddy installed here (default)"
|
|
echo " [2] Remote machine — different server, VPN node, or Netbird peer"
|
|
echo " (service installers save snippet files to ~/docker/caddy-snippets/)"
|
|
echo " [3] None / skip — configure Caddy later"
|
|
echo ""
|
|
local _caddy_default="1"
|
|
case "$_cur_mode" in remote) _caddy_default="2" ;; none) _caddy_default="3" ;; esac
|
|
local _caddy_choice=""
|
|
prompt_text " Caddy location [${_caddy_default}]:" "$_caddy_default" _caddy_choice
|
|
case "${_caddy_choice:-$_caddy_default}" in
|
|
2) CADDY_MODE="remote" ;;
|
|
3) CADDY_MODE="none" ;;
|
|
*) CADDY_MODE="local" ;;
|
|
esac
|
|
CADDY_REMOTE_HOST="" # clear legacy value; CADDY_MODE is authoritative now
|
|
echo ""
|
|
|
|
export CADDY_MODE CADDY_REMOTE_HOST
|
|
mkdir -p "$DOCKER_DIR"
|
|
save_site_config
|
|
log_success "Caddy mode saved: $CADDY_MODE"
|
|
}
|
|
|
|
# ── Site defaults wizard ──────────────────────────────────────────────────────
|
|
# Prompts for timezone, base domain, and Caddy network name; saves to .config.
|
|
# Run directly: sudo ./setup.sh configure
|
|
run_site_configure() {
|
|
local _sys_tz; _sys_tz=$(cat /etc/timezone 2>/dev/null || echo "UTC")
|
|
echo ""
|
|
echo "╔══════════════════════════════════════════════════════════════╗"
|
|
echo "║ Site defaults · pre-filled into every service prompt ║"
|
|
echo "╚══════════════════════════════════════════════════════════════╝"
|
|
echo ""
|
|
echo " These become the default answer each time a service asks for"
|
|
echo " timezone, domain, etc. Press Enter to keep the shown value."
|
|
echo ""
|
|
local _cur_tz="${SITE_TZ:-$_sys_tz}"
|
|
local _cur_dom="${SITE_DOMAIN:-}"
|
|
local _cur_net="${SITE_CADDY_NET:-caddy_net}"
|
|
|
|
prompt_text " Timezone [${_cur_tz}]:" "$_cur_tz" SITE_TZ
|
|
prompt_text " Base domain (e.g., example.com) [${_cur_dom:-<not set>}]:" "$_cur_dom" SITE_DOMAIN
|
|
|
|
# Caddy Docker network only matters when Caddy runs locally — it's the
|
|
# shared bridge network services join to reach a local Caddy container
|
|
# by name. Remote/none Caddy proxies via localhost:PORT instead.
|
|
if [ "$CADDY_MODE" = "local" ]; then
|
|
prompt_text " Caddy Docker network [${_cur_net}]:" "$_cur_net" SITE_CADDY_NET
|
|
fi
|
|
|
|
export SITE_TZ SITE_DOMAIN SITE_CADDY_NET CADDY_MODE CADDY_REMOTE_HOST
|
|
mkdir -p "$DOCKER_DIR"
|
|
save_site_config
|
|
log_success "Saved to $DOCKER_DIR/.config"
|
|
echo ""
|
|
}
|
|
|
|
# ── --list ───────────────────────────────────────────────────────────────────
|
|
if [ "$DO_LIST" = true ]; then list_services; exit 0; fi
|
|
|
|
# ── --status ─────────────────────────────────────────────────────────────────
|
|
if [ "$DO_STATUS" = true ]; then print_status; exit 0; fi
|
|
|
|
# ── configure: show/update site-wide defaults ────────────────────────────────
|
|
if [ "${REQUESTED[*]:-}" = "configure" ]; then
|
|
require_root
|
|
run_site_configure
|
|
exit 0
|
|
fi
|
|
|
|
# ── Direct install: ./setup.sh caddy homeassistant ──────────────────────────
|
|
if [ "${#REQUESTED[@]}" -gt 0 ]; then
|
|
require_root
|
|
rc=0; for name in "${REQUESTED[@]}"; do run_service "$name" || rc=1; done
|
|
exit "$rc"
|
|
fi
|
|
|
|
# ── Guided interactive flow ──────────────────────────────────────────────────
|
|
require_root
|
|
|
|
_VER="$(cat "$HERE/VERSION" 2>/dev/null || echo '?')"
|
|
_OS_LINE="${OS_DISTRO^} ${OS_VERSION} (${OS_CODENAME})"
|
|
|
|
if is_installed base; then
|
|
# ── Re-run: base already present — skip required step ────────────────────
|
|
echo ""
|
|
echo "╔══════════════════════════════════════════════════════════════╗"
|
|
echo "║ Ubuntu Post-Install · v${_VER} · ${_OS_LINE}"
|
|
echo "╚══════════════════════════════════════════════════════════════╝"
|
|
echo ""
|
|
echo " Base packages already installed — skipping required setup."
|
|
echo " Use 'sudo ./setup.sh base' to force a reinstall."
|
|
echo ""
|
|
else
|
|
# ── First run: show required banner, confirm, install ────────────────────
|
|
echo ""
|
|
echo "╔══════════════════════════════════════════════════════════════╗"
|
|
echo "║ Ubuntu Post-Install · v${_VER} · ${_OS_LINE}"
|
|
echo "╚══════════════════════════════════════════════════════════════╝"
|
|
echo ""
|
|
if [ "$OS_DISTRO" != "ubuntu" ]; then
|
|
log_warning "Detected OS: ${_OS_LINE} — this script targets Ubuntu. Proceed with caution."
|
|
echo ""
|
|
elif ! ubuntu_version_ge "24.04"; then
|
|
log_warning "Ubuntu ${OS_VERSION} detected — tested on 24.04+. Some packages may differ."
|
|
echo ""
|
|
fi
|
|
echo "REQUIRED (installed/verified first):"
|
|
echo " • Essential CLI packages: net-tools, git, curl, wget, htop, tree,"
|
|
echo " ncdu, zip/unzip, jq, rsync, and glow (markdown reader)"
|
|
echo " • Docker presence check (needed by all containerized services)"
|
|
echo ""
|
|
echo "Then you'll get a category menu to pick optional services."
|
|
echo ""
|
|
PROCEED=""
|
|
prompt_yn "Proceed with the required setup? (y/n):" "y" PROCEED
|
|
if [ "$PROCEED" != "y" ] && [ "$PROCEED" != "Y" ]; then
|
|
echo "Cancelled. Nothing was changed."
|
|
exit 0
|
|
fi
|
|
|
|
run_service base
|
|
fi
|
|
|
|
# Always ensure Docker is present — base may have been installed before Docker
|
|
# was added to it, or a previous install may have failed.
|
|
if ! command -v docker &>/dev/null && ! [ -x /usr/bin/docker ]; then
|
|
log_info "Docker not found — installing now..."
|
|
require_docker
|
|
fi
|
|
|
|
# 3) Ask where Caddy runs (unconditional — every service's Caddy prompt
|
|
# depends on this), then only offer the timezone/domain/network wizard
|
|
# if Caddy is local to this box.
|
|
if ! grep -q '^CADDY_MODE=' "$DOCKER_DIR/.config" 2>/dev/null; then
|
|
ask_caddy_location
|
|
load_site_config # reload so subsequent prompts see CADDY_MODE
|
|
|
|
if [ "$CADDY_MODE" = "local" ]; then
|
|
echo " Setting timezone/domain now pre-fills them for every service —"
|
|
echo " you type them once, not every time."
|
|
OFFER_CONFIG=""
|
|
prompt_yn "Configure site defaults (timezone, domain, network) now? (y/n):" "y" OFFER_CONFIG
|
|
if [ "$OFFER_CONFIG" = "y" ] || [ "$OFFER_CONFIG" = "Y" ]; then
|
|
run_site_configure
|
|
load_site_config # reload so subsequent service prompts see the new values
|
|
fi
|
|
fi
|
|
fi
|
|
|
|
# 4) Offer Caddy first (most services proxy through it) — only when Caddy is
|
|
# (or will be) local to this box. Remote/none mode means Caddy lives
|
|
# elsewhere, so installing it here would be wrong.
|
|
if [ -n "${SERVICE_GROUP[caddy]:-}" ] && ! is_installed caddy \
|
|
&& [ "${CADDY_MODE:-local}" = "local" ]; then
|
|
echo ""
|
|
OFFER_CADDY=""
|
|
prompt_yn "Install Caddy now? It's the reverse proxy most services use. (y/n):" "y" OFFER_CADDY
|
|
[ "$OFFER_CADDY" = "y" ] || [ "$OFFER_CADDY" = "Y" ] && run_service caddy
|
|
fi
|
|
|
|
# 5) Installed-service summary — show status before every menu session.
|
|
echo ""
|
|
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
|
echo " INSTALLED SERVICES"
|
|
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
|
_any_installed=false
|
|
while IFS= read -r _g; do
|
|
_group_header_printed=false
|
|
while IFS= read -r _svc; do
|
|
if is_installed "$_svc"; then
|
|
if [ "$_group_header_printed" = false ]; then
|
|
printf "\n %-12s\n" "${_g^^}"
|
|
_group_header_printed=true
|
|
fi
|
|
printf " ✓ %-20s %s\n" "$_svc" "${SERVICE_DESC[$_svc]}"
|
|
_any_installed=true
|
|
fi
|
|
done < <(services_in_group "$_g")
|
|
done < <(groups_present)
|
|
[ "$_any_installed" = false ] && echo " (none yet)"
|
|
echo ""
|
|
|
|
# 6) Category menu loop: pick a category → checklist → install → back to menu.
|
|
have_whiptail=false
|
|
command -v whiptail >/dev/null 2>&1 && have_whiptail=true
|
|
# Ensure the terminal is in a clean state before handing control to whiptail.
|
|
stty sane </dev/tty 2>/dev/null || true
|
|
|
|
while true; do
|
|
mapfile -t CATS < <(groups_present)
|
|
|
|
if [ "$have_whiptail" = true ]; then
|
|
cat_items=()
|
|
for g in "${CATS[@]}"; do
|
|
n=$(services_in_group "$g" | wc -l)
|
|
cat_items+=("$g" "$n service(s)")
|
|
done
|
|
cat_items+=("DONE" "Finish and exit")
|
|
CHOSEN_CAT=$(whiptail --title "Service Categories" --menu \
|
|
"Pick a category (services you install come back here):" 22 70 14 \
|
|
"${cat_items[@]}" 3>&1 1>&2 2>&3 </dev/tty) || break
|
|
else
|
|
echo ""; echo "Categories:"; i=1
|
|
for g in "${CATS[@]}"; do echo " $i) $g"; i=$((i+1)); done
|
|
echo " d) Done"
|
|
read -rp "Pick a category [d]: " pick
|
|
[ "$pick" = "d" ] || [ -z "$pick" ] && break
|
|
CHOSEN_CAT="${CATS[$((pick-1))]:-}"
|
|
[ -z "$CHOSEN_CAT" ] && { echo "Invalid."; continue; }
|
|
fi
|
|
[ "$CHOSEN_CAT" = "DONE" ] && break
|
|
|
|
mapfile -t SVCS < <(services_in_group "$CHOSEN_CAT")
|
|
SELECTED=()
|
|
if [ "$have_whiptail" = true ]; then
|
|
# listheight was a flat 14 regardless of category size — utilities
|
|
# alone has 35+ services, so anything past row 14 only reachable by
|
|
# scrolling, with no on-screen hint that more rows exist below the
|
|
# fold. Size it to the category instead, capped to what the actual
|
|
# terminal can show (tput lines, falling back to a conservative 24
|
|
# for a non-terminal/unknown size) so this can't request a dialog
|
|
# taller than the screen.
|
|
_term_lines="$(tput lines 2>/dev/null </dev/tty || echo 24)"
|
|
_list_h=${#SVCS[@]}
|
|
[ "$_list_h" -gt 20 ] && _list_h=20
|
|
_term_cap=$((_term_lines - 10))
|
|
[ "$_term_cap" -lt 6 ] && _term_cap=6
|
|
[ "$_list_h" -gt "$_term_cap" ] && _list_h="$_term_cap"
|
|
_box_h=$((_list_h + 8))
|
|
[ "$_box_h" -gt "$((_term_lines - 2))" ] && _box_h=$((_term_lines - 2))
|
|
|
|
# whiptail's checklist has exactly one interactive element per row —
|
|
# the checkbox itself. The "tag" field (what's displayed right after
|
|
# it) and the "item" field (description) are both just inert display
|
|
# text; there is no such thing as a separately tabbable/selectable
|
|
# sub-field within a row. So a fixed-width "installed" (x) and "#"
|
|
# (instance count) prefix baked into the tag field is the closest
|
|
# equivalent to real extra columns whiptail can render — it lines up
|
|
# visually because whiptail pads every row's tag field to the same
|
|
# width, but it's still one string under the hood. Extracted back
|
|
# into just the plain service name below before dispatch.
|
|
svc_items=()
|
|
for name in "${SVCS[@]}"; do
|
|
_inst_mark=" "
|
|
_count_str=" "
|
|
_count="$(install_count "$name")"
|
|
if [ "$_count" -gt 0 ]; then
|
|
_inst_mark="x"
|
|
_count_str="$(printf "%-2d" "$_count")"
|
|
fi
|
|
svc_tag="$(printf "%s %s %s" "$_inst_mark" "$_count_str" "$name")"
|
|
svc_items+=("$svc_tag" "${SERVICE_DESC[$name]}" "OFF")
|
|
done
|
|
CHOICE=$(whiptail --title "${CHOSEN_CAT^^}" --checklist \
|
|
"installed(x) #instances name Space=select to install, Enter=go:" "$_box_h" 78 "$_list_h" \
|
|
"${svc_items[@]}" 3>&1 1>&2 2>&3 </dev/tty) || continue
|
|
eval "SELECTED=($CHOICE)"
|
|
# Undo the display-only "x N " prefix — name is always the last
|
|
# whitespace-separated token, since service names never contain spaces.
|
|
for i in "${!SELECTED[@]}"; do SELECTED[$i]="${SELECTED[$i]##* }"; done
|
|
else
|
|
echo ""; echo "${CHOSEN_CAT^^}:"
|
|
for name in "${SVCS[@]}"; do
|
|
m=""; _count="$(install_count "$name")"
|
|
[ "$_count" -gt 0 ] && m="[$_count] "
|
|
printf " %-16s %s%s\n" "$name" "$m" "${SERVICE_DESC[$name]}"
|
|
done
|
|
read -rp "Enter service names to install (space-separated, blank to go back): " -a SELECTED
|
|
fi
|
|
|
|
for name in "${SELECTED[@]}"; do run_service "$name"; done
|
|
done
|
|
|
|
echo ""
|
|
log_success "Done. Re-run 'sudo ./setup.sh' any time to add more."
|
|
|
|
# If Docker was installed this session (or already was), the invoking user
|
|
# was added to the docker group — but group membership only takes effect in
|
|
# a new login shell, not the one that ran sudo. Drop into a fresh login
|
|
# shell as that user so 'docker' works immediately without reconnecting SSH.
|
|
if [ -n "${SUDO_USER:-}" ] && [ "$UNATTENDED" != true ] \
|
|
&& getent group docker >/dev/null 2>&1 \
|
|
&& id -nG "$SUDO_USER" 2>/dev/null | grep -qw docker \
|
|
&& [ -t 0 ]; then
|
|
echo ""
|
|
log_info "Refreshing shell as $SUDO_USER so the docker group takes effect..."
|
|
exec su - "$SUDO_USER"
|
|
fi
|