Add modular setup framework (lib + services + dispatcher) and glow
Introduce the modular post-install structure chosen for reconciling 'one source of truth' with 'run just the service I want': - lib/common.sh: shared helpers (logging, prompts, ownership, Caddy wiring) and a service registry. Single implementation of each helper. - setup.sh: dispatcher — interactive menu, run-one (./setup.sh <name>), --list, --dry-run, --unattended. Sources lib + services/*.sh (self-registering). - services/base.sh: essential CLI packages incl. glow (Charm apt repo). - services/homeassistant.sh: first migrated service (bridge/host networking, trusted_proxies, Caddy integration). - MODULAR.md: architecture, how to add a module, migration status. - Groups: base/homelab/gaming/backup. Gaming group makes this a base for homelab OR gaming boxes. Also add glow as a default app to the live -crowdsec scripts' essential packages so it's installed today regardless of entry point. Verified: bash -n on all new files; ./setup.sh --list groups services; dry-run run-one routes correctly. https://claude.ai/code/session_017eA2qqq9jfF2tNtpUYL8vK
This commit is contained in:
+95
@@ -0,0 +1,95 @@
|
||||
# Modular Post-Install (`setup.sh` + `lib/` + `services/`)
|
||||
|
||||
This is the new structure that gives you **one source of truth** *and* the
|
||||
ability to **run just the service you want** — without maintaining a pile of
|
||||
near-duplicate standalone scripts.
|
||||
|
||||
## Why
|
||||
|
||||
The full `ubuntu-post-install-*.sh` scripts are great as a "run once, set up the
|
||||
whole box" experience, but to add or update one service you edit a 300 KB file
|
||||
(in two or three places). The separate `setup-*.sh` scripts are easy to run for
|
||||
one service, but duplicate logic and drift apart.
|
||||
|
||||
The fix is **not** to generate per-service scripts from the monolith (that just
|
||||
triples the maintenance surface). It's to have **one implementation per service**
|
||||
in a module, shared helpers in a library, and a thin dispatcher with two entry
|
||||
points.
|
||||
|
||||
## Layout
|
||||
|
||||
```
|
||||
setup.sh # dispatcher: menu, run-one, --list, --dry-run, --unattended
|
||||
lib/common.sh # shared helpers: logging, prompts, ownership, Caddy wiring,
|
||||
# the service registry. THE single source of truth.
|
||||
services/
|
||||
base.sh # essential CLI packages (incl. glow)
|
||||
homeassistant.sh # Home Assistant
|
||||
... # one file per service
|
||||
```
|
||||
|
||||
## Usage
|
||||
|
||||
```bash
|
||||
sudo ./setup.sh # interactive menu (whiptail or text)
|
||||
sudo ./setup.sh homeassistant # install one service
|
||||
sudo ./setup.sh base glow # install several
|
||||
./setup.sh --list # list services, grouped
|
||||
sudo ./setup.sh --dry-run --unattended minecraft # preview, no prompts
|
||||
```
|
||||
|
||||
## Anatomy of a service module
|
||||
|
||||
Each `services/<name>.sh` does exactly two things: **register** itself and
|
||||
define **install_<name>**.
|
||||
|
||||
```bash
|
||||
#!/bin/bash
|
||||
register_service myapp homelab "What it does" 1234 # name group description [port]
|
||||
|
||||
install_myapp() {
|
||||
require_docker || return 1
|
||||
local DIR="$DOCKER_DIR/myapp"
|
||||
[ "$DRY_RUN" = true ] && { echo "[DRY-RUN] Would create $DIR"; return 0; }
|
||||
mkdir -p "$DIR"; ensure_docker_dir_ownership "$DIR"; cd "$DIR" || return 1
|
||||
cat > docker-compose.yml << 'YAML'
|
||||
...
|
||||
YAML
|
||||
configure_caddy_for_service "MyApp" "1234" "myapp" # optional reverse proxy
|
||||
prompt_yn "Start now? (y/n):" "y" START && docker compose up -d
|
||||
}
|
||||
```
|
||||
|
||||
Helpers available from `lib/common.sh`: `log_info/success/warning/error`,
|
||||
`prompt_yn`, `prompt_text`, `run_cmd`, `ensure_docker_dir_ownership`,
|
||||
`generate_password`, `validate_password`, `configure_caddy_for_service`,
|
||||
`require_root`, `require_docker`. Globals: `DOCKER_DIR`, `ACTUAL_USER`,
|
||||
`ACTUAL_HOME`, `DRY_RUN`, `UNATTENDED`.
|
||||
|
||||
Every service installs to its **own folder** `~/docker/<name>/` with its **own
|
||||
`docker-compose.yml`** (the DoTheEvo `selfhosted-apps-docker` layout) — never a
|
||||
single shared compose file.
|
||||
|
||||
## Groups
|
||||
|
||||
`base` · `homelab` · `gaming` · `backup`. The menu and `--list` are grouped by
|
||||
these. The **gaming** group (Wolf, js99er, Minecraft) makes this script a
|
||||
sensible base for either a homelab box or a gaming box — install only what that
|
||||
machine needs.
|
||||
|
||||
## Migration status
|
||||
|
||||
This is an incremental migration. The big `ubuntu-post-install-*-crowdsec.sh`
|
||||
script remains the current "install everything" entry point until the modules
|
||||
reach parity, at which point it is retired (like the `original` and
|
||||
`-no-keycloak` tiers, which stay frozen as the evolution record).
|
||||
|
||||
| Module | Status |
|
||||
|--------|--------|
|
||||
| `base` (incl. glow) | ✅ done |
|
||||
| `homeassistant` | ✅ done |
|
||||
| `minecraft` (multi-instance, rich) | ⏳ porting from `setupminecraft.sh` |
|
||||
| `wolf` (gaming) | ⏳ porting from `setupwolf.sh` |
|
||||
| `js99er` (gaming) | ⏳ porting from `setupjs99er.sh` |
|
||||
| `backup` (Kopia, cross-cutting) | ⏳ porting from `setupbackup.sh` |
|
||||
| remaining ~65 services | ⏳ migrate from the monolith incrementally |
|
||||
+213
@@ -0,0 +1,213 @@
|
||||
#!/bin/bash
|
||||
# lib/common.sh — shared helpers for the modular post-install system.
|
||||
#
|
||||
# This is the single source of truth for the helper functions every service
|
||||
# module relies on (logging, prompts, ownership, Caddy wiring, the service
|
||||
# registry). Both the full menu (setup.sh) and single-service runs source it,
|
||||
# so there is exactly ONE implementation of each helper.
|
||||
#
|
||||
# Modules under services/*.sh source this file (guarded), register themselves
|
||||
# with register_service, and define an install_<name> function.
|
||||
|
||||
# Guard against double-sourcing
|
||||
[ -n "${_COMMON_SH_LOADED:-}" ] && return 0
|
||||
_COMMON_SH_LOADED=1
|
||||
|
||||
# ── Global modes (overridable by the dispatcher / environment) ───────────────
|
||||
DRY_RUN="${DRY_RUN:-false}"
|
||||
UNATTENDED="${UNATTENDED:-false}"
|
||||
|
||||
# ── Identity / paths ─────────────────────────────────────────────────────────
|
||||
# The actual (non-root) user, even when run under sudo.
|
||||
ACTUAL_USER="${SUDO_USER:-${USER:-$(id -un)}}"
|
||||
ACTUAL_HOME="$(getent passwd "$ACTUAL_USER" 2>/dev/null | cut -d: -f6)"
|
||||
[ -z "$ACTUAL_HOME" ] && ACTUAL_HOME="$HOME"
|
||||
# Per-service docker folders live here: ~/docker/<service>/docker-compose.yml
|
||||
DOCKER_DIR="${DOCKER_DIR:-$ACTUAL_HOME/docker}"
|
||||
|
||||
# ── Colored logging ──────────────────────────────────────────────────────────
|
||||
RED='\033[0;31m'; GREEN='\033[0;32m'; YELLOW='\033[1;33m'; BLUE='\033[0;34m'; NC='\033[0m'
|
||||
log_info() { echo -e "${BLUE}[INFO]${NC} $1"; }
|
||||
log_success() { echo -e "${GREEN}[OK]${NC} $1"; }
|
||||
log_warning() { echo -e "${YELLOW}[WARN]${NC} $1"; }
|
||||
log_error() { echo -e "${RED}[ERROR]${NC} $1"; }
|
||||
|
||||
# ── Service registry ─────────────────────────────────────────────────────────
|
||||
# Modules call: register_service <name> <group> <description> [port]
|
||||
declare -gA SERVICE_GROUP=()
|
||||
declare -gA SERVICE_DESC=()
|
||||
declare -gA SERVICE_PORT=()
|
||||
declare -ga SERVICE_ORDER=()
|
||||
|
||||
register_service() {
|
||||
local name="$1" group="$2" desc="$3" port="${4:-}"
|
||||
SERVICE_GROUP["$name"]="$group"
|
||||
SERVICE_DESC["$name"]="$desc"
|
||||
SERVICE_PORT["$name"]="$port"
|
||||
SERVICE_ORDER+=("$name")
|
||||
}
|
||||
|
||||
# ── Pre-flight ───────────────────────────────────────────────────────────────
|
||||
require_root() {
|
||||
if [ "${EUID:-$(id -u)}" -ne 0 ]; then
|
||||
log_error "Please run as root (use sudo)."
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
require_docker() {
|
||||
if ! command -v docker &>/dev/null; then
|
||||
log_error "Docker is not installed. Install Docker first (run: $0 docker)."
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
|
||||
# ── Command execution honoring dry-run ───────────────────────────────────────
|
||||
run_cmd() {
|
||||
if [ "$DRY_RUN" = true ]; then
|
||||
echo "[DRY-RUN] Would execute: $*"
|
||||
return 0
|
||||
else
|
||||
"$@"
|
||||
fi
|
||||
}
|
||||
|
||||
# Ensure Docker directories are owned by the actual user (not root)
|
||||
ensure_docker_dir_ownership() {
|
||||
if [ "$DRY_RUN" = true ]; then
|
||||
echo "[DRY-RUN] Would set ownership of $* to $ACTUAL_USER:$ACTUAL_USER"
|
||||
return 0
|
||||
fi
|
||||
for dir in "$@"; do
|
||||
[ -d "$dir" ] && chown -R "$ACTUAL_USER:$ACTUAL_USER" "$dir" 2>/dev/null || true
|
||||
done
|
||||
}
|
||||
|
||||
# Generate a secure alphanumeric password (no special characters)
|
||||
generate_password() {
|
||||
local length="${1:-32}"
|
||||
openssl rand -base64 48 | tr -dc 'a-zA-Z0-9' | head -c "$length"
|
||||
}
|
||||
|
||||
# Validate password (alphanumeric only, minimum length). Returns 0/1.
|
||||
validate_password() {
|
||||
local password="$1" min_length="${2:-12}"
|
||||
if [ ${#password} -lt "$min_length" ]; then
|
||||
echo " ⚠ Password must be at least $min_length characters long"; return 1
|
||||
fi
|
||||
if echo "$password" | grep -q '[^a-zA-Z0-9]'; then
|
||||
echo " ⚠ Password must contain only letters and numbers (no special characters)"; return 1
|
||||
fi
|
||||
return 0
|
||||
}
|
||||
|
||||
# Prompt yes/no, honoring unattended. prompt_yn "Question?" "default" VARNAME
|
||||
prompt_yn() {
|
||||
local question="$1" default="$2" varname="$3" response
|
||||
if [ "$UNATTENDED" = true ]; then
|
||||
eval "$varname='$default'"; echo "$question [auto: $default]"; return
|
||||
fi
|
||||
read -p "$question " response
|
||||
eval "$varname='$response'"
|
||||
}
|
||||
|
||||
# Prompt text, honoring unattended. prompt_text "Question?" "default" VARNAME
|
||||
prompt_text() {
|
||||
local question="$1" default="$2" varname="$3" response
|
||||
if [ "$UNATTENDED" = true ]; then
|
||||
eval "$varname='$default'"; echo "$question [auto: $default]"; return
|
||||
fi
|
||||
read -p "$question " response
|
||||
eval "$varname='${response:-$default}'"
|
||||
}
|
||||
|
||||
# ── Caddy reverse-proxy wiring (shared by every web service) ─────────────────
|
||||
# Usage: configure_caddy_for_service "Name" "PORT" "default-subdomain" ["extra"]
|
||||
configure_caddy_for_service() {
|
||||
local SERVICE_NAME="$1" SERVICE_PORT="$2" DEFAULT_SUBDOMAIN="$3" EXTRA_CONFIG="${4:-}"
|
||||
|
||||
# Caddy not installed → nothing to do
|
||||
[ -d "$DOCKER_DIR/caddy" ] || return 0
|
||||
|
||||
echo ""
|
||||
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
||||
echo " CADDY REVERSE PROXY CONFIGURATION"
|
||||
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
||||
echo ""
|
||||
echo "Caddy is installed. You can configure a reverse proxy for $SERVICE_NAME."
|
||||
echo ""
|
||||
|
||||
local CONFIGURE_CADDY=""
|
||||
prompt_yn "Configure Caddy reverse proxy for $SERVICE_NAME? (y/n):" "n" CONFIGURE_CADDY
|
||||
if [ "$CONFIGURE_CADDY" != "y" ] && [ "$CONFIGURE_CADDY" != "Y" ]; then
|
||||
echo " Skipping Caddy configuration."
|
||||
echo " Access $SERVICE_NAME at: http://localhost:$SERVICE_PORT"
|
||||
return 0
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "Enter the full domain for $SERVICE_NAME:"
|
||||
echo " Examples: $DEFAULT_SUBDOMAIN.example.com, $DEFAULT_SUBDOMAIN.yourdomain.com"
|
||||
echo ""
|
||||
local SERVICE_DOMAIN=""
|
||||
prompt_text "Domain:" "" SERVICE_DOMAIN
|
||||
if [ -z "$SERVICE_DOMAIN" ]; then
|
||||
echo " ⚠ No domain provided, skipping Caddy configuration."; return 0
|
||||
fi
|
||||
|
||||
local CADDY_DIR="$DOCKER_DIR/caddy"
|
||||
local CADDYFILE="$CADDY_DIR/Caddyfile"
|
||||
local BACKUP_FILE="$CADDY_DIR/Caddyfile.backup.$(date +%Y%m%d-%H%M%S)"
|
||||
|
||||
if [ -f "$CADDYFILE" ]; then
|
||||
echo " Backing up Caddyfile to: $(basename "$BACKUP_FILE")"
|
||||
cp "$CADDYFILE" "$BACKUP_FILE"
|
||||
else
|
||||
echo " Creating new Caddyfile"; touch "$CADDYFILE"
|
||||
fi
|
||||
|
||||
if grep -q "^${SERVICE_DOMAIN}" "$CADDYFILE" 2>/dev/null; then
|
||||
echo " ⚠ $SERVICE_DOMAIN already exists in Caddyfile"
|
||||
local OVERWRITE=""
|
||||
prompt_yn "Overwrite existing configuration? (y/n):" "n" OVERWRITE
|
||||
if [ "$OVERWRITE" != "y" ] && [ "$OVERWRITE" != "Y" ]; then
|
||||
echo " Keeping existing configuration."; return 0
|
||||
fi
|
||||
sed -i "/^${SERVICE_DOMAIN}/,/^}/d" "$CADDYFILE"
|
||||
fi
|
||||
|
||||
echo " Adding $SERVICE_NAME configuration to Caddyfile..."
|
||||
cat >> "$CADDYFILE" << CADDY_BLOCK
|
||||
|
||||
# $SERVICE_NAME
|
||||
$SERVICE_DOMAIN {
|
||||
reverse_proxy localhost:$SERVICE_PORT
|
||||
|
||||
# Security headers
|
||||
header {
|
||||
Strict-Transport-Security "max-age=31536000; includeSubDomains; preload"
|
||||
X-Content-Type-Options "nosniff"
|
||||
X-Frame-Options "SAMEORIGIN"
|
||||
Referrer-Policy "strict-origin-when-cross-origin"
|
||||
}
|
||||
|
||||
# Logging for CrowdSec (Caddy JSON access logs)
|
||||
log {
|
||||
output file /var/log/caddy/${SERVICE_DOMAIN}.log
|
||||
format json
|
||||
}
|
||||
$EXTRA_CONFIG
|
||||
}
|
||||
CADDY_BLOCK
|
||||
|
||||
echo " ✓ Configuration added to Caddyfile"
|
||||
echo " Reloading Caddy configuration..."
|
||||
docker exec caddy caddy fmt --overwrite /etc/caddy/Caddyfile 2>/dev/null || true
|
||||
if docker exec caddy caddy reload --config /etc/caddy/Caddyfile 2>/dev/null; then
|
||||
echo " ✓ $SERVICE_NAME is now accessible at: https://$SERVICE_DOMAIN"
|
||||
else
|
||||
echo " ⚠ Failed to reload Caddy. Check: docker logs caddy"
|
||||
echo " You can restore from backup: $BACKUP_FILE"
|
||||
fi
|
||||
echo ""
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
#!/bin/bash
|
||||
# services/base.sh — essential CLI packages installed on every box.
|
||||
# Part of the modular post-install system (sourced by setup.sh).
|
||||
|
||||
register_service base base "Essential CLI packages (net-tools, git, htop, glow, …)"
|
||||
|
||||
install_base() {
|
||||
log_info "Installing essential packages..."
|
||||
run_cmd apt-get update -y
|
||||
|
||||
# Core utilities present on every install.
|
||||
run_cmd apt-get install -y \
|
||||
net-tools ncdu git curl wget htop tree zip unzip \
|
||||
ca-certificates gnupg jq rsync || log_warning "Some essential packages failed to install"
|
||||
|
||||
# glow — terminal markdown reader (charmbracelet). Not in Ubuntu repos,
|
||||
# so add Charm's apt repository first.
|
||||
install_glow
|
||||
}
|
||||
|
||||
# glow is also exposed as its own module so it can be (re)installed on its own.
|
||||
install_glow() {
|
||||
if command -v glow >/dev/null 2>&1; then
|
||||
log_success "glow already installed ($(glow --version 2>/dev/null | head -1))"
|
||||
return 0
|
||||
fi
|
||||
log_info "Installing glow (terminal markdown reader) from the Charm apt repo..."
|
||||
if [ "$DRY_RUN" = true ]; then
|
||||
echo "[DRY-RUN] Would add repo.charm.sh apt repo and install glow"
|
||||
return 0
|
||||
fi
|
||||
sudo mkdir -p /etc/apt/keyrings
|
||||
if curl -fsSL https://repo.charm.sh/apt/gpg.key \
|
||||
| sudo gpg --dearmor --yes -o /etc/apt/keyrings/charm.gpg; then
|
||||
echo "deb [signed-by=/etc/apt/keyrings/charm.gpg] https://repo.charm.sh/apt/ * *" \
|
||||
| sudo tee /etc/apt/sources.list.d/charm.list >/dev/null
|
||||
if sudo apt-get update -y && sudo apt-get install -y glow; then
|
||||
log_success "glow installed ($(glow --version 2>/dev/null | head -1))"
|
||||
else
|
||||
log_warning "glow install failed — see https://github.com/charmbracelet/glow"
|
||||
fi
|
||||
else
|
||||
log_warning "Could not fetch Charm signing key — skipping glow"
|
||||
fi
|
||||
}
|
||||
|
||||
# Register glow as a standalone service too (./setup.sh glow).
|
||||
register_service glow base "Terminal markdown reader (charmbracelet/glow)"
|
||||
@@ -0,0 +1,97 @@
|
||||
#!/bin/bash
|
||||
# services/homeassistant.sh — Home Assistant home-automation hub.
|
||||
# Part of the modular post-install system (sourced by setup.sh).
|
||||
|
||||
register_service homeassistant homelab "Home automation hub (Home Assistant)" 8123
|
||||
|
||||
install_homeassistant() {
|
||||
require_docker || return 1
|
||||
log_info "Installing Home Assistant..."
|
||||
local HOMEASSISTANT_DIR="$DOCKER_DIR/homeassistant"
|
||||
|
||||
if [ "$DRY_RUN" = true ]; then
|
||||
echo "[DRY-RUN] Would create $HOMEASSISTANT_DIR"
|
||||
return 0
|
||||
fi
|
||||
|
||||
mkdir -p "$HOMEASSISTANT_DIR"
|
||||
ensure_docker_dir_ownership "$HOMEASSISTANT_DIR"
|
||||
cd "$HOMEASSISTANT_DIR" || return 1
|
||||
|
||||
# Networking mode: bridge (published port) vs host networking.
|
||||
echo ""
|
||||
echo " Home Assistant networking mode:"
|
||||
echo " 1) Bridge - container gets its own network; port 8123 is published"
|
||||
echo " to the host. Works behind the Caddy reverse proxy and"
|
||||
echo " keeps HA isolated. Recommended for most setups."
|
||||
echo " 2) Host - HA shares the host's network directly. Needed for"
|
||||
echo " auto-discovery of devices on your LAN (Chromecast/Cast,"
|
||||
echo " HomeKit, mDNS/Zeroconf, some Zigbee/Z-Wave & Bluetooth)."
|
||||
local HA_NETMODE=""
|
||||
prompt_text " Choose networking mode [1]:" "1" HA_NETMODE
|
||||
local HA_NET_LINES
|
||||
if [ "$HA_NETMODE" = "2" ]; then
|
||||
HA_NET_LINES=" network_mode: host"
|
||||
echo " → Host networking selected (best device discovery)."
|
||||
else
|
||||
HA_NET_LINES=" ports:
|
||||
- \"8123:8123\""
|
||||
echo " → Bridge networking selected (port 8123 published)."
|
||||
fi
|
||||
|
||||
cat > docker-compose.yml << HOMEASSISTANT_COMPOSE
|
||||
name: homeassistant
|
||||
|
||||
services:
|
||||
homeassistant:
|
||||
image: ghcr.io/home-assistant/home-assistant:stable
|
||||
container_name: homeassistant
|
||||
hostname: homeassistant
|
||||
restart: unless-stopped
|
||||
privileged: true
|
||||
environment:
|
||||
- TZ=$(cat /etc/timezone 2>/dev/null || echo "UTC")
|
||||
volumes:
|
||||
- ./config:/config
|
||||
- /run/dbus:/run/dbus:ro
|
||||
${HA_NET_LINES}
|
||||
HOMEASSISTANT_COMPOSE
|
||||
|
||||
mkdir -p config
|
||||
|
||||
# Pre-seed trusted_proxies so HA works behind the Caddy reverse proxy.
|
||||
# Only written on a fresh install (never clobber an existing config).
|
||||
if [ ! -f config/configuration.yaml ]; then
|
||||
cat > config/configuration.yaml << 'HA_CONFIG'
|
||||
# Loads default set of integrations. Do not remove.
|
||||
default_config:
|
||||
|
||||
# Allow access through a reverse proxy (e.g. Caddy)
|
||||
http:
|
||||
use_x_forwarded_for: true
|
||||
trusted_proxies:
|
||||
- 172.16.0.0/12
|
||||
- 192.168.0.0/16
|
||||
- 10.0.0.0/8
|
||||
- 127.0.0.1
|
||||
- ::1
|
||||
HA_CONFIG
|
||||
fi
|
||||
|
||||
chown -R "$ACTUAL_USER:$ACTUAL_USER" "$HOMEASSISTANT_DIR"
|
||||
echo ""
|
||||
log_success "Home Assistant configured at $HOMEASSISTANT_DIR"
|
||||
|
||||
configure_caddy_for_service "Home Assistant" "8123" "home"
|
||||
|
||||
local START_HA=""
|
||||
prompt_yn "Start Home Assistant now? (y/n):" "y" START_HA
|
||||
if [ "$START_HA" = "y" ] || [ "$START_HA" = "Y" ]; then
|
||||
docker compose up -d 2>/dev/null && log_success "Home Assistant started" || log_warning "Failed to start"
|
||||
fi
|
||||
|
||||
echo " Access at: http://localhost:8123"
|
||||
echo " First run: open the URL and create your admin account (onboarding)."
|
||||
echo " Note: first startup can take a minute while HA initializes."
|
||||
echo ""
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
#!/bin/bash
|
||||
# setup.sh — modular post-install dispatcher.
|
||||
#
|
||||
# One source of truth, two ways to run it:
|
||||
# sudo ./setup.sh interactive menu (pick any services)
|
||||
# sudo ./setup.sh <service> ... install one or more services directly
|
||||
# ./setup.sh --list list available services (grouped)
|
||||
#
|
||||
# Flags:
|
||||
# --dry-run preview actions without making changes
|
||||
# --unattended use defaults, no prompts
|
||||
#
|
||||
# Every service lives in services/<name>.sh, registers itself, and defines
|
||||
# install_<name>. Adding a service = adding one file. Updating a service =
|
||||
# editing one file. Nothing is duplicated or generated.
|
||||
set -uo pipefail
|
||||
|
||||
HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
|
||||
# ── Parse global flags, collect service names ────────────────────────────────
|
||||
DRY_RUN=false
|
||||
UNATTENDED=false
|
||||
DO_LIST=false
|
||||
REQUESTED=()
|
||||
for arg in "$@"; do
|
||||
case "$arg" in
|
||||
--dry-run) DRY_RUN=true ;;
|
||||
--unattended) UNATTENDED=true ;;
|
||||
--list|-l) DO_LIST=true ;;
|
||||
-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
|
||||
# shellcheck source=/dev/null
|
||||
source "$_mod"
|
||||
done
|
||||
shopt -u nullglob
|
||||
|
||||
# Ordered list of unique groups, in first-seen order.
|
||||
groups_in_order() {
|
||||
local seen=" " g
|
||||
for name in "${SERVICE_ORDER[@]}"; do
|
||||
g="${SERVICE_GROUP[$name]}"
|
||||
case "$seen" in *" $g "*) : ;; *) echo "$g"; seen="$seen$g " ;; esac
|
||||
done
|
||||
}
|
||||
|
||||
list_services() {
|
||||
local g name
|
||||
while IFS= read -r g; do
|
||||
echo ""
|
||||
echo "── ${g^^} ──"
|
||||
for name in "${SERVICE_ORDER[@]}"; do
|
||||
[ "${SERVICE_GROUP[$name]}" = "$g" ] || continue
|
||||
printf " %-16s %s\n" "$name" "${SERVICE_DESC[$name]}"
|
||||
done
|
||||
done < <(groups_in_order)
|
||||
echo ""
|
||||
}
|
||||
|
||||
run_service() {
|
||||
local name="$1"
|
||||
if [ -z "${SERVICE_GROUP[$name]:-}" ]; then
|
||||
log_error "Unknown service: $name (try: $0 --list)"
|
||||
return 1
|
||||
fi
|
||||
if ! declare -F "install_${name}" >/dev/null; then
|
||||
log_error "Service '$name' has no install_${name} function."
|
||||
return 1
|
||||
fi
|
||||
log_info "=== ${name} (${SERVICE_DESC[$name]}) ==="
|
||||
"install_${name}"
|
||||
}
|
||||
|
||||
# ── --list ───────────────────────────────────────────────────────────────────
|
||||
if [ "$DO_LIST" = true ]; then
|
||||
list_services
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# ── Direct service install: ./setup.sh minecraft homeassistant ─────────────
|
||||
if [ "${#REQUESTED[@]}" -gt 0 ]; then
|
||||
require_root
|
||||
rc=0
|
||||
for name in "${REQUESTED[@]}"; do
|
||||
run_service "$name" || rc=1
|
||||
done
|
||||
exit "$rc"
|
||||
fi
|
||||
|
||||
# ── Interactive menu ─────────────────────────────────────────────────────────
|
||||
require_root
|
||||
|
||||
SELECTED=()
|
||||
if command -v whiptail >/dev/null 2>&1; then
|
||||
_items=()
|
||||
for name in "${SERVICE_ORDER[@]}"; do
|
||||
_items+=("$name" "${SERVICE_DESC[$name]}" "OFF")
|
||||
done
|
||||
_choice=$(whiptail --title "Ubuntu Post-Install — Services" \
|
||||
--checklist "Select services to install (space to toggle):" 25 78 16 \
|
||||
"${_items[@]}" 3>&1 1>&2 2>&3) || { echo "Cancelled."; exit 0; }
|
||||
# whiptail returns space-separated, quoted names
|
||||
eval "SELECTED=($_choice)"
|
||||
else
|
||||
echo "Available services:"
|
||||
list_services
|
||||
read -rp "Enter service names to install (space-separated): " -a SELECTED
|
||||
fi
|
||||
|
||||
[ "${#SELECTED[@]}" -eq 0 ] && { echo "Nothing selected."; exit 0; }
|
||||
|
||||
rc=0
|
||||
for name in "${SELECTED[@]}"; do
|
||||
run_service "$name" || rc=1
|
||||
done
|
||||
exit "$rc"
|
||||
@@ -1741,6 +1741,23 @@ run_cmd apt install -y \
|
||||
unzip \
|
||||
rclone || echo "Warning: Some utilities failed to install, continuing..."
|
||||
|
||||
# Install glow (terminal markdown reader) from the Charm apt repo
|
||||
echo ""
|
||||
echo "Installing glow (terminal markdown reader)..."
|
||||
if command -v glow >/dev/null 2>&1; then
|
||||
echo " ✓ glow already installed"
|
||||
elif [ "$DRY_RUN" = true ]; then
|
||||
echo "[DRY-RUN] Would add repo.charm.sh apt repo and install glow"
|
||||
else
|
||||
sudo mkdir -p /etc/apt/keyrings
|
||||
if curl -fsSL https://repo.charm.sh/apt/gpg.key | sudo gpg --dearmor --yes -o /etc/apt/keyrings/charm.gpg; then
|
||||
echo "deb [signed-by=/etc/apt/keyrings/charm.gpg] https://repo.charm.sh/apt/ * *" | sudo tee /etc/apt/sources.list.d/charm.list >/dev/null
|
||||
sudo apt update -y && sudo apt install -y glow && echo " ✓ glow installed" || echo " ⚠ glow install failed"
|
||||
else
|
||||
echo " ⚠ Could not fetch Charm signing key - skipping glow"
|
||||
fi
|
||||
fi
|
||||
|
||||
# Install OpenSSH Server
|
||||
echo ""
|
||||
echo "Installing OpenSSH Server..."
|
||||
|
||||
@@ -1741,6 +1741,23 @@ run_cmd apt install -y \
|
||||
unzip \
|
||||
rclone || echo "Warning: Some utilities failed to install, continuing..."
|
||||
|
||||
# Install glow (terminal markdown reader) from the Charm apt repo
|
||||
echo ""
|
||||
echo "Installing glow (terminal markdown reader)..."
|
||||
if command -v glow >/dev/null 2>&1; then
|
||||
echo " ✓ glow already installed"
|
||||
elif [ "$DRY_RUN" = true ]; then
|
||||
echo "[DRY-RUN] Would add repo.charm.sh apt repo and install glow"
|
||||
else
|
||||
sudo mkdir -p /etc/apt/keyrings
|
||||
if curl -fsSL https://repo.charm.sh/apt/gpg.key | sudo gpg --dearmor --yes -o /etc/apt/keyrings/charm.gpg; then
|
||||
echo "deb [signed-by=/etc/apt/keyrings/charm.gpg] https://repo.charm.sh/apt/ * *" | sudo tee /etc/apt/sources.list.d/charm.list >/dev/null
|
||||
sudo apt update -y && sudo apt install -y glow && echo " ✓ glow installed" || echo " ⚠ glow install failed"
|
||||
else
|
||||
echo " ⚠ Could not fetch Charm signing key - skipping glow"
|
||||
fi
|
||||
fi
|
||||
|
||||
# Install OpenSSH Server
|
||||
echo ""
|
||||
echo "Installing OpenSSH Server..."
|
||||
|
||||
Reference in New Issue
Block a user