diff --git a/MODULAR.md b/MODULAR.md new file mode 100644 index 0000000..e01b166 --- /dev/null +++ b/MODULAR.md @@ -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/.sh` does exactly two things: **register** itself and +define **install_**. + +```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//` 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 | diff --git a/lib/common.sh b/lib/common.sh new file mode 100644 index 0000000..e6bd534 --- /dev/null +++ b/lib/common.sh @@ -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_ 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//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 [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 "" +} diff --git a/services/base.sh b/services/base.sh new file mode 100644 index 0000000..a2875e6 --- /dev/null +++ b/services/base.sh @@ -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)" diff --git a/services/homeassistant.sh b/services/homeassistant.sh new file mode 100644 index 0000000..624ae25 --- /dev/null +++ b/services/homeassistant.sh @@ -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 "" +} diff --git a/setup.sh b/setup.sh new file mode 100755 index 0000000..2f0a925 --- /dev/null +++ b/setup.sh @@ -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 ... 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/.sh, registers itself, and defines +# install_. 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" diff --git a/ubuntu-post-install-24.04-crowdsec.sh b/ubuntu-post-install-24.04-crowdsec.sh index 25bd220..1741b9e 100644 --- a/ubuntu-post-install-24.04-crowdsec.sh +++ b/ubuntu-post-install-24.04-crowdsec.sh @@ -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..." diff --git a/ubuntu-post-install-26.04-crowdsec.sh b/ubuntu-post-install-26.04-crowdsec.sh index 3412afe..6100d9b 100644 --- a/ubuntu-post-install-26.04-crowdsec.sh +++ b/ubuntu-post-install-26.04-crowdsec.sh @@ -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..."