From 3bc3eddd5be0b89ccd2606358814aacd64e0b4b0 Mon Sep 17 00:00:00 2001 From: cclohmar Date: Tue, 14 Jul 2026 10:09:50 +0000 Subject: [PATCH] chore: install.sh with --install/--update/--remove flags, help, update README --- README.md | 56 +++-- install.sh | 608 +++++++++++++++++++++++++++++++---------------------- 2 files changed, 396 insertions(+), 268 deletions(-) diff --git a/README.md b/README.md index f531147..34fba84 100644 --- a/README.md +++ b/README.md @@ -9,13 +9,20 @@ ## 🚀 One-Command Install ```bash -bash -c "$(curl -fsSL https://git.lohmar.co.uk/cclohmar/NextExpense/raw/branch/main/install.sh)" +bash -c "$(curl -fsSL https://git.lohmar.co.uk/cclohmar/NextExpense/raw/branch/main/install.sh)" -s -- --install ``` Or from a local clone: ```bash -./install.sh +./install.sh --install +``` + +Running without flags shows help: + +```bash +./install.sh # Show help +./install.sh --help # Same ``` The installer will: @@ -41,11 +48,19 @@ The installer will: ## 🔄 Updating ```bash -bash /opt/nextexpense/install.sh -update +./install.sh --update ``` Update pulls the latest code, copies updated templates/static, rebuilds the binary, automatically backs up the database, and restarts the service. +## 🗑️ Uninstalling + +```bash +./install.sh --remove +``` + +Stops the service, removes the systemd unit, and deletes `/opt/nextexpense/` (asks for confirmation). + --- ## 🔧 Building from Source (install.sh does this automatically) @@ -118,14 +133,14 @@ Navigate to `http://YOUR_SERVER:8080` 1. Enter your email → click "Send Verification Code" 2. Check your inbox (or server log) for the 6-digit OTP 3. Enter OTP → click "Verify Code" -4. Click "+ New" → name your event → set claim currency → enter a conversion sample -5. Click "Open" on the event -6. Tap "📷 Camera" or "📁 Upload" to add a receipt -7. AI extracts amount, merchant, category, date → form is pre-filled -8. Click "Save Expense" -9. Click "Submit Event" → enter recipient email → choose CSV or PDF -10. Report + receipt images ZIP are emailed → event auto-closes -11. Click "Reopen" to add more receipts +4. Create a month (e.g. "July 2026") → click "Open" +5. Click "+ New" to add an event → set claim currency + conversion sample +6. Click "Open" on the event +7. Tap "📷 Camera" or "📁 Upload" to add a receipt +8. AI extracts amount, merchant, category, date → form is pre-filled +9. Click "Save Expense" +10. Back on the month view, click "Generate Monthly Report" +11. Monthly ZIP contains CSV + PDF report + all receipt images ``` --- @@ -145,15 +160,22 @@ Navigate to `http://YOUR_SERVER:8080` | Method | Path | Description | |--------|------|-------------| -| `GET` | `/dashboard` | Event list | -| `POST` | `/events` | Create event | -| `GET` | `/events/{id}/expenses` | View event + expenses | -| `PUT` | `/events/{id}/reopen` | Reopen closed event | +| `GET` | `/dashboard` | Month list | +| `POST` | `/months` | Create month | +| `GET` | `/months/{mid}` | View month + events | +| `PUT` | `/months/{mid}` | Update month | +| `DELETE` | `/months/{mid}` | Delete month | +| `POST` | `/months/{mid}/events` | Create event | +| `PUT` | `/months/{mid}/events/{eid}` | Update event | +| `DELETE` | `/months/{mid}/events/{eid}` | Delete event | +| `GET` | `/months/{mid}/events/{eid}/expenses` | View event + expenses | +| `POST` | `/months/{mid}/events/{eid}/generate` | Generate event report | +| `POST` | `/months/{mid}/generate` | Generate monthly report | | `POST` | `/expenses/upload` | Upload receipt image/PDF | | `POST` | `/expenses` | Save expense | | `GET` | `/expenses/{id}/edit` | Get edit form | | `PUT` | `/expenses/{id}` | Update expense | -| `POST` | `/events/{id}/file` | File report (CSV/PDF + images ZIP) | +| `DELETE` | `/expenses/{id}` | Delete expense | --- @@ -215,7 +237,7 @@ Navigate to `http://YOUR_SERVER:8080` ## 🗄️ Database -SQLite, auto-created on first run — 4 tables: `users`, `auth_otps`, `events`, `expenses`. +SQLite, auto-created on first run — 5 tables: `users`, `auth_otps`, `months`, `events`, `expenses`. --- diff --git a/install.sh b/install.sh index d989cb6..0373cc9 100755 --- a/install.sh +++ b/install.sh @@ -4,9 +4,13 @@ # ===================== # # Usage: -# curl -fsSL https://git.lohmar.co.uk/cclohmar/NextExpense/raw/branch/main/install.sh | bash -# bash install.sh — Full install -# bash install.sh -update — Pull latest, rebuild, restart +# ./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. @@ -39,22 +43,110 @@ sudo_if() { } # ────────────────────────────────────────────────────────────────── -# UPDATE MODE +# HELP # ────────────────────────────────────────────────────────────────── -if [ "${1:-}" = "-update" ]; then +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 — Update Mode ║" + echo " ║ NextExpense — Uninstall ║" echo " ╚═══════════════════════════════════════╝" echo "" - # Cache sudo credentials upfront so subsequent sudo_if calls don't prompt. + 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 "This update requires sudo access. Please run: sudo ./install.sh -update"; exit 1; } + 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.sh without -update first." + err "$INSTALL_DIR does not exist. Run --install first." exit 1 fi @@ -73,46 +165,43 @@ if [ "${1:-}" = "-update" ]; then cd "$INSTALL_DIR" fi - # Determine the app user from the existing service file (most reliable). + # 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 - # Fallback: check the owner of the install directory or binary. 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 - # Reset ownership so the app user can write files. + sudo_if chown -R "${APP_USER}:${APP_USER}" "$INSTALL_DIR" 2>/dev/null || true - # Ensure key directories have correct permissions. 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 the database before making any changes. + # 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" - # Restore file ownership so the app user can still write. 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 templates, static assets, and config files. + # 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 before rebuilding so the old binary can be removed. + # Stop service. info "Stopping service..." sudo_if systemctl stop "$SERVICE_NAME" 2>/dev/null || true - # Ensure Go is installed for building from source. + # Ensure Go is installed. if ! command -v go &>/dev/null; then info "Installing Go..." if command -v apt-get &>/dev/null; then @@ -136,7 +225,6 @@ if [ "${1:-}" = "-update" ]; then sudo_if rm -f app CGO_ENABLED=0 go build -buildvcs=false -ldflags="-s -w" -o app . - # Fix ownership of all files (build cache may be root-owned from earlier builds). sudo_if chown -R "${APP_USER}:${APP_USER}" "$INSTALL_DIR" 2>/dev/null || true info "Starting service..." @@ -148,185 +236,181 @@ if [ "${1:-}" = "-update" ]; then else warn "Service did not start. Check: systemctl status $SERVICE_NAME" fi - exit 0 -fi +} # ────────────────────────────────────────────────────────────────── # FRESH INSTALL # ────────────────────────────────────────────────────────────────── -echo "" -echo " ╔═══════════════════════════════════════╗" -echo " ║ NextExpense Installer ║" -echo " ╚═══════════════════════════════════════╝" -echo "" +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"; exit 1; } -fi + # 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 + # ── 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 - err "Please install git manually, then re-run install.sh" - exit 1 + info "Cloning repository to $INSTALL_DIR..." + sudo_if git clone "$REPO_URL" "$INSTALL_DIR" + cd "$INSTALL_DIR" 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 + 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 -fi -# python3 is used for parsing release JSON -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 + + 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 -fi -# ── 1. Clone repo ──────────────────────────────────────────────── -if [ -d "$INSTALL_DIR" ]; then - warn "$INSTALL_DIR already exists — pulling latest..." - # Mark the repo as safe — it was created by root via sudo - 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 -# Fix ownership so the build/pull steps can write as the invoking user -sudo_if chown -R "$(whoami):$(whoami)" "$INSTALL_DIR" 2>/dev/null || true - -# ── 2. Build or download binary ────────────────────────────────── -ARCH="$(uname -m)" -case "$ARCH" in - x86_64) ARCH="amd64" ;; - aarch64) ARCH="arm64" ;; - *) err "Unsupported architecture: $ARCH"; exit 1 ;; -esac - -# Ensure Go is installed for building from source. -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 + # ── 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 - err "No package manager found. Please install Go 1.23+ manually." - exit 1 + 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 -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" - -# ── 3. Create runtime directories ──────────────────────────────── -sudo_if mkdir -p storage -sudo_if chmod 755 templates static storage - -# ── 4. 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 - -# ── 5. 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 - -# ── 6. 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 - -# ── 7. Write .env ──────────────────────────────────────────────── -info "Creating .env..." -sudo_if tee "$INSTALL_DIR/.env" > /dev/null << ENVEOF + # ── Write .env ───────────────────────────────────────────── + info "Creating .env..." + sudo_if tee "$INSTALL_DIR/.env" > /dev/null << ENVEOF # NextExpense Configuration # Generated by install.sh on $(date) @@ -335,56 +419,53 @@ BASE_URL=${BASE_URL} AI_PROVIDER=${AI_PROVIDER} ENVEOF -case "$AI_PROVIDER" in - openai) - sudo_if tee -a "$INSTALL_DIR/.env" > /dev/null << 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) + sudo_if tee -a "$INSTALL_DIR/.env" > /dev/null << ENVEOF GEMINI_API_KEY=${GEMINI_API_KEY} ENVEOF - ;; -esac + ;; + esac -if [ -n "$SMTP_HOST" ]; then - sudo_if tee -a "$INSTALL_DIR/.env" > /dev/null << ENVEOF + 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" - -# ── 8. Determine app user ────────────────────────────────────────── -# Detect the real user (the one who invoked the script, not root via sudo) -APP_USER="${SUDO_USER:-$(whoami)}" -# If still root, create a dedicated system user -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 -# ── 9. Set ownership ─────────────────────────────────────────────── -sudo_if chown -R "${APP_USER}:${APP_USER}" "${INSTALL_DIR}" -sudo_if chmod 755 "${INSTALL_DIR}" "${INSTALL_DIR}/templates" "${INSTALL_DIR}/static" -# Storage needs write permission for uploaded receipts -sudo_if chmod 775 "${INSTALL_DIR}/storage" + sudo_if chmod 600 "$INSTALL_DIR/.env" + info "Configuration saved to $INSTALL_DIR/.env" -# ── 10. Systemd service ──────────────────────────────────────────── -info "Creating systemd service..." -sudo_if tee "/etc/systemd/system/${SERVICE_NAME}.service" > /dev/null << SERVEOF + # ── 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 @@ -406,39 +487,64 @@ StandardError=append:/var/log/nextexpense.log 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" + sudo_if systemctl daemon-reload + sudo_if systemctl enable "${SERVICE_NAME}" + info "Service created: /etc/systemd/system/${SERVICE_NAME}.service" -# ── 9. Start ───────────────────────────────────────────────────── -info "Starting NextExpense..." -sudo_if systemctl restart "${SERVICE_NAME}" 2>/dev/null || true -sleep 2 + # ── 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 "" + 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 + 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 " ── Update ──" -echo " To update later: bash install.sh -update" -echo "" + 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