chore: install.sh with --install/--update/--remove flags, help, update README

This commit is contained in:
Claus Lohmar 2026-07-14 10:09:50 +00:00
parent 64844baa83
commit 3bc3eddd5b
2 changed files with 396 additions and 268 deletions

View file

@ -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`.
---

View file

@ -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,12 +236,12 @@ if [ "${1:-}" = "-update" ]; then
else
warn "Service did not start. Check: systemctl status $SERVICE_NAME"
fi
exit 0
fi
}
# ──────────────────────────────────────────────────────────────────
# FRESH INSTALL
# ──────────────────────────────────────────────────────────────────
do_install() {
echo ""
echo " ╔═══════════════════════════════════════╗"
echo " ║ NextExpense Installer ║"
@ -162,10 +250,10 @@ 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; }
sudo -v 2>/dev/null || { err "This installer requires sudo access. Please run: sudo ./install.sh --install"; exit 1; }
fi
# ── Install dependencies ───────────────────────────────────────────
# ── Install dependencies ───────────────────────────────────
if ! command -v git &>/dev/null; then
info "Installing git..."
if command -v apt-get &>/dev/null; then
@ -175,7 +263,7 @@ if ! command -v git &>/dev/null; then
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 install.sh"
err "Please install git manually, then re-run."
exit 1
fi
fi
@ -189,7 +277,6 @@ if ! command -v curl &>/dev/null; then
sudo_if apk add curl 2>&1 | tail -1
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
@ -201,10 +288,9 @@ if ! command -v python3 &>/dev/null; then
fi
fi
# ── 1. Clone repo ────────────────────────────────────────────────
# ── 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
@ -213,10 +299,9 @@ else
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 ──────────────────────────────────
# ── Build binary ──────────────────────────────────────────
ARCH="$(uname -m)"
case "$ARCH" in
x86_64) ARCH="amd64" ;;
@ -224,7 +309,6 @@ case "$ARCH" in
*) 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
@ -249,11 +333,11 @@ 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 ────────────────────────────────
# ── Create runtime directories ─────────────────────────────
sudo_if mkdir -p storage
sudo_if chmod 755 templates static storage
# ── 4. AI Provider ───────────────────────────────────────────────
# ── AI Provider ────────────────────────────────────────────
echo ""
echo " ── AI Provider ──"
echo " Which AI should process receipt images?"
@ -283,7 +367,7 @@ case "$AI_CHOICE" in
;;
esac
# ── 5. SMTP Configuration ────────────────────────────────────────
# ── SMTP Configuration ────────────────────────────────────
echo ""
echo " ── Email (SMTP) ──"
echo " Required for sending OTP codes and expense reports."
@ -301,7 +385,7 @@ if [ -n "$SMTP_HOST" ]; then
read -r SMTP_PASS
fi
# ── 6. General settings ──────────────────────────────────────────
# ── General settings ──────────────────────────────────────
echo ""
echo " ── General ──"
echo " If the app is behind a proxy (e.g. dev.lohmar.co.uk), enter the domain."
@ -324,7 +408,7 @@ if [ "$DOMAIN" = "localhost" ]; then
BASE_URL="http://localhost:${PORT}"
fi
# ── 7. Write .env ────────────────────────────────────────────────
# ── Write .env ─────────────────────────────────────────────
info "Creating .env..."
sudo_if tee "$INSTALL_DIR/.env" > /dev/null << ENVEOF
# NextExpense Configuration
@ -362,10 +446,8 @@ 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)
# ── Determine app user ────────────────────────────────────
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
@ -376,13 +458,12 @@ else
info "Using existing user '${APP_USER}'"
fi
# ── 9. Set ownership ───────────────────────────────────────────────
# ── 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"
# ── 10. Systemd service ────────────────────────────────────────────
# ── Systemd service ───────────────────────────────────────
info "Creating systemd service..."
sudo_if tee "/etc/systemd/system/${SERVICE_NAME}.service" > /dev/null << SERVEOF
[Unit]
@ -410,7 +491,7 @@ sudo_if systemctl daemon-reload
sudo_if systemctl enable "${SERVICE_NAME}"
info "Service created: /etc/systemd/system/${SERVICE_NAME}.service"
# ── 9. Start ─────────────────────────────────────────────────────
# ── Start ──────────────────────────────────────────────────
info "Starting NextExpense..."
sudo_if systemctl restart "${SERVICE_NAME}" 2>/dev/null || true
sleep 2
@ -439,6 +520,31 @@ else
fi
echo ""
echo " ── Update ──"
echo " To update later: bash install.sh -update"
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