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:
Claude
2026-06-03 12:57:12 +00:00
parent d528cfbea3
commit d7b9f935c2
7 changed files with 615 additions and 0 deletions
+95
View File
@@ -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
View File
@@ -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 ""
}
+48
View File
@@ -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)"
+97
View File
@@ -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 ""
}
Executable
+128
View File
@@ -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"
+17
View File
@@ -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..."
+17
View File
@@ -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..."