#!/usr/bin/env bash # # NextExpense Installer # ===================== # # Usage: # ./install.sh — Show help # ./install.sh --install — Full fresh install # ./install.sh --update — Pull latest, rebuild, restart # ./install.sh --remove — Stop service, remove all files # # Pipe install (requires --install flag): # curl -fsSL https://git.lohmar.co.uk/cclohmar/NextExpense/raw/branch/main/install.sh | bash -s -- --install # # Installs to /opt/nextexpense/ and sets up a systemd service. # Uses sudo internally only for operations that require it. # set -euo pipefail INSTALL_DIR="/opt/nextexpense" SERVICE_NAME="nextexpense" REPO_URL="https://git.lohmar.co.uk/cclohmar/NextExpense.git" RED='\033[0;31m' GREEN='\033[0;32m' YELLOW='\033[1;33m' CYAN='\033[0;36m' NC='\033[0m' info() { echo -e "${GREEN}[✓]${NC} $1"; } warn() { echo -e "${YELLOW}[!]${NC} $1"; } err() { echo -e "${RED}[✗]${NC} $1"; } ask() { echo -en "${CYAN}→${NC} $1"; } # Helper: run a command with sudo if not already root sudo_if() { if [ "$EUID" -eq 0 ]; then "$@" else sudo "$@" fi } # ────────────────────────────────────────────────────────────────── # HELP # ────────────────────────────────────────────────────────────────── show_help() { cat << EOF ╔═══════════════════════════════════════════╗ ║ NextExpense Installer ║ ╚═══════════════════════════════════════════╝ Usage: ./install.sh [FLAG] Flags: --install, -i Full fresh install (clone, build, configure, start service) --update, -u Pull latest, rebuild binary, restart service --remove, -r Stop service, remove files (asks for confirmation) --help, -h Show this help Examples: ./install.sh --install ./install.sh --update ./install.sh --remove Pipe install: curl -fsSL $REPO_URL/install.sh | bash -s -- --install EOF } # ────────────────────────────────────────────────────────────────── # REMOVE / UNINSTALL # ────────────────────────────────────────────────────────────────── do_remove() { echo "" echo " ╔═══════════════════════════════════════╗" echo " ║ NextExpense — Uninstall ║" echo " ╚═══════════════════════════════════════╝" echo "" if [ ! -d "$INSTALL_DIR" ] && [ ! -f "/etc/systemd/system/${SERVICE_NAME}.service" ]; then warn "Nothing to remove — NextExpense is not installed." exit 0 fi echo " This will:" echo " 1. Stop the ${SERVICE_NAME} service" echo " 2. Disable and remove the systemd service file" echo " 3. Remove ${INSTALL_DIR}/ (including database, receipts, and config)" echo " 4. Remove /var/log/nextexpense.log" echo "" ask " Type 'yes' to confirm: " read -r CONFIRM if [ "$CONFIRM" != "yes" ]; then warn "Aborted." exit 0 fi # Cache sudo credentials. if [ "$EUID" -ne 0 ]; then sudo -v 2>/dev/null || { err "Uninstall requires sudo access."; exit 1; } fi # Stop and disable service. if [ -f "/etc/systemd/system/${SERVICE_NAME}.service" ]; then info "Stopping service..." sudo_if systemctl stop "$SERVICE_NAME" 2>/dev/null || true sudo_if systemctl disable "$SERVICE_NAME" 2>/dev/null || true sudo_if rm -f "/etc/systemd/system/${SERVICE_NAME}.service" sudo_if systemctl daemon-reload info "Service removed." fi # Remove install directory. if [ -d "$INSTALL_DIR" ]; then info "Removing $INSTALL_DIR..." sudo_if rm -rf "$INSTALL_DIR" fi # Remove log file. if [ -f "/var/log/nextexpense.log" ]; then sudo_if rm -f "/var/log/nextexpense.log" fi echo "" info "NextExpense has been uninstalled." } # ────────────────────────────────────────────────────────────────── # UPDATE # ────────────────────────────────────────────────────────────────── do_update() { echo "" echo " ╔═══════════════════════════════════════╗" echo " ║ NextExpense — Update ║" echo " ╚═══════════════════════════════════════╝" echo "" # Cache sudo credentials upfront. if [ "$EUID" -ne 0 ]; then sudo -v 2>/dev/null || { err "This update requires sudo access."; exit 1; } fi if [ ! -d "$INSTALL_DIR" ]; then err "$INSTALL_DIR does not exist. Run --install first." exit 1 fi cd "$INSTALL_DIR" if [ -d .git ]; then info "Pulling latest code..." sudo_if git pull else warn "Not a git repository — re-cloning..." cd /tmp rm -rf nextexpense-update sudo_if git clone "$REPO_URL" nextexpense-update sudo_if rsync -a --delete nextexpense-update/ "$INSTALL_DIR/" rm -rf nextexpense-update cd "$INSTALL_DIR" fi # Determine the app user. APP_USER="" if [ -f "/etc/systemd/system/${SERVICE_NAME}.service" ]; then APP_USER=$(grep -Po '^User=\K.*' "/etc/systemd/system/${SERVICE_NAME}.service" 2>/dev/null || true) fi if [ -z "$APP_USER" ]; then APP_USER=$(stat -c '%U' "$INSTALL_DIR/app" 2>/dev/null || stat -c '%U' "$INSTALL_DIR" 2>/dev/null || true) fi if [ -z "$APP_USER" ] || [ "$APP_USER" = "root" ]; then APP_USER="${SUDO_USER:-$(whoami)}" fi sudo_if chown -R "${APP_USER}:${APP_USER}" "$INSTALL_DIR" 2>/dev/null || true sudo_if chmod 755 "$INSTALL_DIR" "$INSTALL_DIR/templates" "$INSTALL_DIR/static" 2>/dev/null || true sudo_if chmod 775 "$INSTALL_DIR/storage" 2>/dev/null || true # Backup database. if [ -f "$INSTALL_DIR/expenses.db" ]; then BACKUP_DIR="$INSTALL_DIR/backups" sudo_if mkdir -p "$BACKUP_DIR" BACKUP_FILE="$BACKUP_DIR/expenses-$(date +%Y%m%d-%H%M%S).db" sudo_if cp "$INSTALL_DIR/expenses.db" "$BACKUP_FILE" sudo_if chown $(stat -c "%U:%G" "$INSTALL_DIR/expenses.db") "$BACKUP_FILE" 2>/dev/null || true info "Database backed up to $BACKUP_FILE" fi # Copy updated files. info "Updating templates and static assets..." sudo_if rsync -a --delete templates/ "$INSTALL_DIR/templates/" 2>/dev/null || true sudo_if rsync -a --delete static/ "$INSTALL_DIR/static/" 2>/dev/null || true sudo_if cp install.sh "$INSTALL_DIR/" 2>/dev/null || true # Stop service. info "Stopping service..." sudo_if systemctl stop "$SERVICE_NAME" 2>/dev/null || true # Ensure Go is installed. if ! command -v go &>/dev/null; then info "Installing Go..." if command -v apt-get &>/dev/null; then sudo_if apt-get update -qq && sudo_if apt-get install -y -qq golang-go 2>&1 | tail -1 elif command -v dnf &>/dev/null; then sudo_if dnf install -y golang 2>&1 | tail -1 elif command -v apk &>/dev/null; then sudo_if apk add go 2>&1 | tail -1 else err "No package manager found. Please install Go 1.23+ manually." exit 1 fi fi info "Rebuilding binary..." export GOMODCACHE="${INSTALL_DIR}/.go/mod" export GOPATH="${INSTALL_DIR}/.go" export GOCACHE="${INSTALL_DIR}/.go/build" mkdir -p "${GOMODCACHE}" "${GOCACHE}" 2>/dev/null || sudo_if mkdir -p "${GOMODCACHE}" "${GOCACHE}" sudo_if chown -R "${APP_USER}" "${INSTALL_DIR}/.go" "${INSTALL_DIR}/app" 2>/dev/null || true sudo_if rm -f app CGO_ENABLED=0 go build -buildvcs=false -ldflags="-s -w" -o app . sudo_if chown -R "${APP_USER}:${APP_USER}" "$INSTALL_DIR" 2>/dev/null || true info "Starting service..." sudo_if systemctl start "$SERVICE_NAME" sleep 2 if systemctl is-active --quiet "$SERVICE_NAME"; then info "Update complete — NextExpense is running" else warn "Service did not start. Check: systemctl status $SERVICE_NAME" fi } # ────────────────────────────────────────────────────────────────── # FRESH INSTALL # ────────────────────────────────────────────────────────────────── do_install() { echo "" echo " ╔═══════════════════════════════════════╗" echo " ║ NextExpense Installer ║" echo " ╚═══════════════════════════════════════╝" echo "" # Cache sudo credentials upfront. if [ "$EUID" -ne 0 ]; then sudo -v 2>/dev/null || { err "This installer requires sudo access. Please run: sudo ./install.sh --install"; exit 1; } fi # ── Install dependencies ─────────────────────────────────── if ! command -v git &>/dev/null; then info "Installing git..." if command -v apt-get &>/dev/null; then sudo_if apt-get update -qq && sudo_if apt-get install -y -qq git 2>&1 | tail -1 elif command -v dnf &>/dev/null; then sudo_if dnf install -y git 2>&1 | tail -1 elif command -v apk &>/dev/null; then sudo_if apk add git 2>&1 | tail -1 else err "Please install git manually, then re-run." exit 1 fi fi if ! command -v curl &>/dev/null; then info "Installing curl..." if command -v apt-get &>/dev/null; then sudo_if apt-get install -y -qq curl 2>&1 | tail -1 elif command -v dnf &>/dev/null; then sudo_if dnf install -y curl 2>&1 | tail -1 elif command -v apk &>/dev/null; then sudo_if apk add curl 2>&1 | tail -1 fi fi if ! command -v python3 &>/dev/null; then info "Installing python3..." if command -v apt-get &>/dev/null; then sudo_if apt-get install -y -qq python3 2>&1 | tail -1 elif command -v dnf &>/dev/null; then sudo_if dnf install -y python3 2>&1 | tail -1 elif command -v apk &>/dev/null; then sudo_if apk add python3 2>&1 | tail -1 fi fi # ── Clone repo ──────────────────────────────────────────── if [ -d "$INSTALL_DIR" ]; then warn "$INSTALL_DIR already exists — pulling latest..." sudo_if git config --global --add safe.directory "$INSTALL_DIR" 2>/dev/null || true cd "$INSTALL_DIR" sudo_if git pull else info "Cloning repository to $INSTALL_DIR..." sudo_if git clone "$REPO_URL" "$INSTALL_DIR" cd "$INSTALL_DIR" fi sudo_if chown -R "$(whoami):$(whoami)" "$INSTALL_DIR" 2>/dev/null || true # ── Build binary ────────────────────────────────────────── ARCH="$(uname -m)" case "$ARCH" in x86_64) ARCH="amd64" ;; aarch64) ARCH="arm64" ;; *) err "Unsupported architecture: $ARCH"; exit 1 ;; esac if ! command -v go &>/dev/null; then info "Installing Go..." if command -v apt-get &>/dev/null; then sudo_if apt-get update -qq && sudo_if apt-get install -y -qq golang-go 2>&1 | tail -1 elif command -v dnf &>/dev/null; then sudo_if dnf install -y golang 2>&1 | tail -1 elif command -v apk &>/dev/null; then sudo_if apk add go 2>&1 | tail -1 else err "No package manager found. Please install Go 1.23+ manually." exit 1 fi fi info "Building binary from source (pure Go, no CGO)..." export GOMODCACHE="${INSTALL_DIR}/.go/mod" export GOPATH="${INSTALL_DIR}/.go" export GOCACHE="${INSTALL_DIR}/.go/build" mkdir -p "${GOMODCACHE}" "${GOCACHE}" 2>/dev/null || sudo_if mkdir -p "${GOMODCACHE}" "${GOCACHE}" sudo_if chown -R "$(whoami):$(whoami)" "${INSTALL_DIR}/.go" "${INSTALL_DIR}/app" 2>/dev/null || true sudo_if rm -f app CGO_ENABLED=0 go build -buildvcs=false -ldflags="-s -w" -o app . info "Binary ready: $INSTALL_DIR/app" # ── Create runtime directories ───────────────────────────── sudo_if mkdir -p storage sudo_if chmod 755 templates static storage # ── AI Provider ──────────────────────────────────────────── echo "" echo " ── AI Provider ──" echo " Which AI should process receipt images?" echo " 1) Google Gemini (cloud API, needs API key)" echo " 2) OpenAI / Compatible (also works with Ollama, LocalAI, etc.)" echo "" ask " Choose [1-2] (default: 1): " read -r AI_CHOICE AI_CHOICE="${AI_CHOICE:-1}" case "$AI_CHOICE" in 2) AI_PROVIDER="openai" ask " API key (or press Enter for Ollama): " read -r OPENAI_API_KEY ask " Model [gpt-4o-mini]: " read -r AI_MODEL AI_MODEL="${AI_MODEL:-gpt-4o-mini}" ask " Base URL [https://api.openai.com/v1]: " read -r AI_BASE_URL AI_BASE_URL="${AI_BASE_URL:-https://api.openai.com/v1}" ;; *) AI_PROVIDER="gemini" ask " Gemini API key: " read -r GEMINI_API_KEY ;; esac # ── SMTP Configuration ──────────────────────────────────── echo "" echo " ── Email (SMTP) ──" echo " Required for sending OTP codes and expense reports." echo " Leave blank to skip (OTP codes will be logged to console)." echo "" ask " SMTP host: " read -r SMTP_HOST if [ -n "$SMTP_HOST" ]; then ask " SMTP port [587]: " read -r SMTP_PORT SMTP_PORT="${SMTP_PORT:-587}" ask " SMTP user: " read -r SMTP_USER ask " SMTP password: " read -r SMTP_PASS fi # ── General settings ────────────────────────────────────── echo "" echo " ── General ──" echo " If the app is behind a proxy (e.g. dev.lohmar.co.uk), enter the domain." echo " Otherwise leave blank to use localhost." echo "" ask " Domain name [localhost]: " read -r DOMAIN DOMAIN="${DOMAIN:-localhost}" ask " HTTPS? (y/N): " read -r USE_HTTPS if [[ "$USE_HTTPS" =~ ^[Yy]$ ]]; then BASE_URL="https://${DOMAIN}" else BASE_URL="http://${DOMAIN}" fi ask " Web server port [8080]: " read -r PORT PORT="${PORT:-8080}" if [ "$DOMAIN" = "localhost" ]; then BASE_URL="http://localhost:${PORT}" fi # ── Write .env ───────────────────────────────────────────── info "Creating .env..." sudo_if tee "$INSTALL_DIR/.env" > /dev/null << ENVEOF # NextExpense Configuration # Generated by install.sh on $(date) PORT=${PORT} BASE_URL=${BASE_URL} AI_PROVIDER=${AI_PROVIDER} ENVEOF case "$AI_PROVIDER" in openai) sudo_if tee -a "$INSTALL_DIR/.env" > /dev/null << ENVEOF OPENAI_API_KEY=${OPENAI_API_KEY} AI_MODEL=${AI_MODEL} AI_BASE_URL=${AI_BASE_URL} ENVEOF ;; gemini) sudo_if tee -a "$INSTALL_DIR/.env" > /dev/null << ENVEOF GEMINI_API_KEY=${GEMINI_API_KEY} ENVEOF ;; esac if [ -n "$SMTP_HOST" ]; then sudo_if tee -a "$INSTALL_DIR/.env" > /dev/null << ENVEOF SMTP_HOST=${SMTP_HOST} SMTP_PORT=${SMTP_PORT} SMTP_USER=${SMTP_USER} SMTP_PASS=${SMTP_PASS} ENVEOF fi sudo_if chmod 600 "$INSTALL_DIR/.env" info "Configuration saved to $INSTALL_DIR/.env" # ── Determine app user ──────────────────────────────────── APP_USER="${SUDO_USER:-$(whoami)}" if [ "$APP_USER" = "root" ]; then APP_USER="${SERVICE_NAME}" if ! id -u "${APP_USER}" &>/dev/null 2>&1; then info "Creating system user '${APP_USER}'..." sudo_if useradd --system --no-create-home --home-dir "${INSTALL_DIR}" --shell /usr/sbin/nologin "${APP_USER}" fi else info "Using existing user '${APP_USER}'" fi # ── Set ownership ───────────────────────────────────────── sudo_if chown -R "${APP_USER}:${APP_USER}" "${INSTALL_DIR}" sudo_if chmod 755 "${INSTALL_DIR}" "${INSTALL_DIR}/templates" "${INSTALL_DIR}/static" sudo_if chmod 775 "${INSTALL_DIR}/storage" # ── Systemd service ─────────────────────────────────────── info "Creating systemd service..." sudo_if tee "/etc/systemd/system/${SERVICE_NAME}.service" > /dev/null << SERVEOF [Unit] Description=NextExpense — AI-Powered Expense Tracker Documentation=https://git.lohmar.co.uk/cclohmar/NextExpense After=network.target [Service] Type=simple User=${APP_USER} Group=${APP_USER} WorkingDirectory=${INSTALL_DIR} ExecStart=${INSTALL_DIR}/app Restart=always RestartSec=5 EnvironmentFile=-${INSTALL_DIR}/.env StandardOutput=append:/var/log/nextexpense.log StandardError=append:/var/log/nextexpense.log [Install] WantedBy=multi-user.target SERVEOF sudo_if systemctl daemon-reload sudo_if systemctl enable "${SERVICE_NAME}" info "Service created: /etc/systemd/system/${SERVICE_NAME}.service" # ── Start ────────────────────────────────────────────────── info "Starting NextExpense..." sudo_if systemctl restart "${SERVICE_NAME}" 2>/dev/null || true sleep 2 echo "" echo " ╔═══════════════════════════════════════╗" echo " ║ Installation Complete! ║" echo " ╚═══════════════════════════════════════╝" echo "" echo " Install: $INSTALL_DIR" echo " Binary: $INSTALL_DIR/app" echo " Config: $INSTALL_DIR/.env" echo " Templates: $INSTALL_DIR/templates/" echo " Static: $INSTALL_DIR/static/" echo " Storage: $INSTALL_DIR/storage/" echo " Service: systemctl status $SERVICE_NAME" echo " Logs: journalctl -u $SERVICE_NAME -f" echo "" if systemctl is-active --quiet "${SERVICE_NAME}"; then info "NextExpense is running on port ${PORT}" echo " Open http://localhost:${PORT} (or your server IP)" else warn "Service did not start. Check: systemctl status ${SERVICE_NAME}" journalctl -u "${SERVICE_NAME}" -n 20 --no-pager fi echo "" echo " ── Commands ──" echo " Update: ./install.sh --update" echo " Remove: ./install.sh --remove" echo "" } # ────────────────────────────────────────────────────────────────── # MAIN — Parse flags # ────────────────────────────────────────────────────────────────── MODE="${1:-}" case "$MODE" in --install|-i) do_install ;; --update|-u) do_update ;; --remove|-r) do_remove ;; --help|-h) show_help ;; "") show_help ;; *) # Backward-compat: bare `-update` still works if [ "$MODE" = "-update" ]; then do_update else err "Unknown flag: $MODE" show_help exit 1 fi ;; esac