Files
pi-extensions/scripts/install-client.sh
T

285 lines
11 KiB
Bash
Executable File

#!/usr/bin/env bash
#
# install-client.sh — Provision (or update) a pi client machine with the
# parts of pi-extensions you select. Idempotent, non-destructive by default
# only when --update-only is set.
#
# Run from a checked-out copy of the pi-extensions repo:
#
# scripts/install-client.sh # everything
# scripts/install-client.sh --components ai-server,certs
# scripts/install-client.sh --update-only # add new files only,
# # never replace existing
#
# Components:
# certs mTLS client cert + root CA from the Caddy host
# ssh SSH key-auth to the ai-server host (admin commands)
# ai-server the pi extension (mTLS llama.cpp router provider)
# dark-mechanicus theme TUI bundle (banner, indicator, status line, etc.)
# token-stats footer owner for context + token rate display
# themes JSON theme palette files (themes/*.json)
# local-llama split local llama.cpp extension (llama.cpp/)
# ai-complete shell CLI for direct llama-server access (installed to
# --ai-complete-dir, default ~/bin/)
# shared shared TS utils — auto-included if dark-mechanicus is
# all shorthand for every component above (default if neither
# --components nor any --no-<x> flag is given)
#
# Modes:
# --update-only Do not replace existing files. Only add new ones. Useful
# after a `git pull` to surface new modules without
# clobbering local edits.
# --force Overwrite existing cert files (other components: same as
# no flag — rsync replaces unless --update-only).
#
# Options:
# --components LIST Comma-separated component names. Default: all.
# --update-only Non-destructive: only add missing files.
# --ai-complete-dir PATH Where to install ai-complete (default: ~/bin)
# --caddy-host USER@HOST Default: shahondin1624@192.168.2.2
# --caddy-cert-dir PATH Default: /mnt/ssdpool/@docker/caddy/certs
# --ai-server-host USER@HOST Default: ai-server@192.168.2.3
# --ai-server-url URL Default: https://ai.shahondin1624.de
# --pi-dir PATH Default: $HOME/.pi/agent
# --no-certs Same as omitting "certs" from --components
# --no-ssh-setup Same as omitting "ssh" from --components
# --force Overwrite existing cert files
# --skip-verify Skip the final mTLS health probe
# --help Show this message
set -euo pipefail
# ─── defaults ────────────────────────────────────────────────────────────
CADDY_HOST="${CADDY_HOST:-shahondin1624@192.168.2.2}"
CADDY_CERT_DIR="${CADDY_CERT_DIR:-/mnt/ssdpool/@docker/caddy/certs}"
AI_SERVER_HOST="${AI_SERVER_HOST:-ai-server@192.168.2.3}"
AI_SERVER_URL="${AI_SERVER_URL:-https://ai.shahondin1624.de}"
PI_DIR="${PI_DIR:-$HOME/.pi/agent}"
AI_COMPLETE_DIR="${AI_COMPLETE_DIR:-$HOME/bin}"
COMPONENTS=""
UPDATE_ONLY=0
FORCE=0
SKIP_VERIFY=0
LEGACY_NO_CERTS=0
LEGACY_NO_SSH=0
ALL_COMPONENTS=(certs ssh ai-server dark-mechanicus token-stats themes local-llama ai-complete shared)
usage() { sed -n '2,/^$/p' "$0" | sed 's/^#\{0,1\} \{0,1\}//'; exit 0; }
while [[ $# -gt 0 ]]; do
case "$1" in
--components) COMPONENTS="$2"; shift 2 ;;
--update-only) UPDATE_ONLY=1; shift ;;
--ai-complete-dir) AI_COMPLETE_DIR="$2"; shift 2 ;;
--caddy-host) CADDY_HOST="$2"; shift 2 ;;
--caddy-cert-dir) CADDY_CERT_DIR="$2"; shift 2 ;;
--ai-server-host) AI_SERVER_HOST="$2"; shift 2 ;;
--ai-server-url) AI_SERVER_URL="$2"; shift 2 ;;
--pi-dir) PI_DIR="$2"; shift 2 ;;
--no-ssh-setup) LEGACY_NO_SSH=1; shift ;;
--no-certs) LEGACY_NO_CERTS=1; shift ;;
--force) FORCE=1; shift ;;
--skip-verify) SKIP_VERIFY=1; shift ;;
-h|--help) usage ;;
*) echo "Unknown arg: $1 (try --help)" >&2; exit 1 ;;
esac
done
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
cd "$REPO_ROOT"
[[ -d ai-server ]] || { echo "Run from a checked-out pi-extensions repo (ai-server/ not found here)" >&2; exit 1; }
# ─── component selection ─────────────────────────────────────────────────
# If --components not given, default to all, then strip what --no-* removed
if [[ -z "$COMPONENTS" ]]; then
COMPONENTS="all"
fi
# Expand "all" -> every known component
if [[ "$COMPONENTS" == "all" || "$COMPONENTS" == *",all,"* || "$COMPONENTS" == "all,"* || "$COMPONENTS" == *",all" ]]; then
COMPONENTS=$(IFS=,; echo "${ALL_COMPONENTS[*]}")
fi
# Honor legacy --no-certs / --no-ssh-setup by removing from the list
if [[ $LEGACY_NO_CERTS -eq 1 ]]; then
COMPONENTS=$(echo "$COMPONENTS" | tr ',' '\n' | grep -vx 'certs' | paste -sd, -)
fi
if [[ $LEGACY_NO_SSH -eq 1 ]]; then
COMPONENTS=$(echo "$COMPONENTS" | tr ',' '\n' | grep -vx 'ssh' | paste -sd, -)
fi
# Auto-add 'shared' if dark-mechanicus or token-stats is selected (they import from ../shared/ or ./shared/)
if [[ ",$COMPONENTS," == *",dark-mechanicus,"* || ",$COMPONENTS," == *",token-stats,"* ]]; then
if [[ ",$COMPONENTS," != *",shared,"* ]]; then
COMPONENTS="$COMPONENTS,shared"
fi
fi
# Normalize and dedupe
COMPONENTS=$(echo "$COMPONENTS" | tr ',' '\n' | sed '/^$/d' | awk '!seen[$0]++' | paste -sd, -)
want() { [[ ",$COMPONENTS," == *",$1,"* ]]; }
# Validate component names
for c in $(echo "$COMPONENTS" | tr ',' ' '); do
found=0
for known in "${ALL_COMPONENTS[@]}"; do
[[ "$c" == "$known" ]] && { found=1; break; }
done
[[ $found -eq 1 ]] || { echo "Unknown component: '$c' (valid: ${ALL_COMPONENTS[*]}, all)" >&2; exit 1; }
done
# rsync flags differ per mode
if [[ $UPDATE_ONLY -eq 1 ]]; then
RSYNC_FLAGS=(-a --ignore-existing)
MODE_LABEL="update-only (existing files preserved)"
else
RSYNC_FLAGS=(-a)
MODE_LABEL="full sync (existing files replaced)"
fi
echo "==> pi-extensions client install"
echo " repo root: $REPO_ROOT"
echo " pi dir: $PI_DIR"
echo " components: $COMPONENTS"
echo " mode: $MODE_LABEL"
[[ $FORCE -eq 1 ]] && echo " cert force: on (will overwrite existing certs)"
mkdir -p "$PI_DIR/extensions"
# ─── components ──────────────────────────────────────────────────────────
if want certs; then
echo; echo "==> [certs] installing mTLS certs from $CADDY_HOST"
mkdir -p "$PI_DIR/certs"
chmod 700 "$PI_DIR/certs"
for pair in "client.crt:client.pem" "client.key:client-key.pem" "root-ca.pem:root-ca.pem"; do
src="${pair%%:*}"; dst="${pair##*:}"
target="$PI_DIR/certs/$dst"
# Cert preservation rules:
# - default : keep existing (don't clobber a working identity)
# - --force : overwrite
# - --update-only : keep existing (same as default)
if [[ -f "$target" && $FORCE -eq 0 ]]; then
echo " keep existing $dst (use --force to replace)"
continue
fi
echo " fetch $CADDY_HOST:$CADDY_CERT_DIR/$src -> $target"
scp -q "$CADDY_HOST:$CADDY_CERT_DIR/$src" "$target"
done
chmod 600 "$PI_DIR"/certs/*.pem 2>/dev/null || true
fi
if want ai-server; then
echo; echo "==> [ai-server] syncing extension"
rsync "${RSYNC_FLAGS[@]}" --exclude='.git' ai-server "$PI_DIR/extensions/"
fi
if want dark-mechanicus; then
if [[ -d dark-mechanicus ]]; then
echo; echo "==> [dark-mechanicus] syncing theme TUI bundle"
rsync "${RSYNC_FLAGS[@]}" --exclude='.git' dark-mechanicus "$PI_DIR/extensions/"
else
echo " (dark-mechanicus/ not in repo, skipping)"
fi
fi
if want token-stats; then
if [[ -d token-stats ]]; then
echo; echo "==> [token-stats] syncing footer extension"
rsync "${RSYNC_FLAGS[@]}" --exclude='.git' token-stats "$PI_DIR/extensions/"
else
echo " (token-stats/ not in repo, skipping)"
fi
fi
if want shared; then
if [[ -d shared ]]; then
echo; echo "==> [shared] syncing shared TS utils"
rsync "${RSYNC_FLAGS[@]}" --exclude='.git' shared "$PI_DIR/extensions/"
else
echo " (shared/ not in repo, skipping)"
fi
fi
if want themes; then
if [[ -d themes ]]; then
echo; echo "==> [themes] syncing palette files"
mkdir -p "$PI_DIR/themes"
rsync "${RSYNC_FLAGS[@]}" themes/ "$PI_DIR/themes/"
echo " $(ls themes/*.json 2>/dev/null | wc -l) theme file(s) installed"
fi
fi
if want local-llama; then
if [[ -d llama.cpp ]]; then
echo; echo "==> [local-llama] syncing llama.cpp extension"
rsync "${RSYNC_FLAGS[@]}" --exclude='.git' llama.cpp "$PI_DIR/extensions/"
else
echo " (llama.cpp/ not in repo, skipping)"
fi
fi
if want ai-complete; then
echo; echo "==> [ai-complete] installing CLI to $AI_COMPLETE_DIR"
mkdir -p "$AI_COMPLETE_DIR"
target="$AI_COMPLETE_DIR/ai-complete"
if [[ -f "$target" && $UPDATE_ONLY -eq 1 ]]; then
echo " keep existing $target (--update-only)"
else
install -m 755 scripts/ai-complete "$target"
echo " installed $target"
fi
# Add to PATH in shell rc if missing (idempotent)
case ":$PATH:" in
*":$AI_COMPLETE_DIR:"*) ;;
*)
rc=""
[[ -f "$HOME/.bashrc" ]] && rc="$HOME/.bashrc"
[[ -f "$HOME/.zshrc" ]] && rc="$HOME/.zshrc"
if [[ -n "$rc" ]] && ! grep -qF "$AI_COMPLETE_DIR" "$rc" 2>/dev/null; then
echo " appending PATH += $AI_COMPLETE_DIR to $rc"
echo "export PATH=\"$AI_COMPLETE_DIR:\$PATH\"" >> "$rc"
fi
;;
esac
fi
if want ssh; then
echo; echo "==> [ssh] checking SSH key-auth to $AI_SERVER_HOST"
if ssh -o BatchMode=yes -o ConnectTimeout=5 "$AI_SERVER_HOST" 'true' 2>/dev/null; then
echo " key-auth already works"
else
if [[ ! -f "$HOME/.ssh/id_ed25519.pub" && ! -f "$HOME/.ssh/id_rsa.pub" ]]; then
echo " no local ssh key; generating ed25519"
ssh-keygen -t ed25519 -N '' -f "$HOME/.ssh/id_ed25519"
fi
echo " running ssh-copy-id (will prompt for password once)"
ssh-copy-id "$AI_SERVER_HOST"
fi
fi
# ─── verification ────────────────────────────────────────────────────────
if [[ $SKIP_VERIFY -eq 0 ]] && want certs; then
echo; echo "==> Verifying mTLS reachability"
if curl -sS --max-time 5 \
--cert "$PI_DIR/certs/client.pem" \
--key "$PI_DIR/certs/client-key.pem" \
--cacert "$PI_DIR/certs/root-ca.pem" \
"$AI_SERVER_URL/health" | grep -q '"ok"'; then
echo " ✓ mTLS handshake + /health OK"
else
echo " ✗ could not verify — check cert files and server reachability"
exit 1
fi
fi
echo
echo "==> Done."
echo " Next: open pi and /reload, or run 'pi' fresh."
want ai-complete && echo " ai-complete installed; open a new shell (or 'hash -r') to pick up PATH."