NextExpense/install.sh

550 lines
21 KiB
Bash
Executable file

#!/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