Finance-Accounts/start.command

299 lines
12 KiB
Bash
Executable File

#!/bin/bash
#
# Amazon A/R Aging Dashboard — double-click launcher (macOS)
#
# Starts the FastAPI backend and the Vite frontend, waits until both are actually
# answering, then opens the dashboard in your browser. Leave this Terminal window open
# while you work; press Ctrl-C (or just close the window) to stop both servers.
#
# Everything runs on this machine. No transaction data leaves your computer.
set -u -o pipefail
# --- resolve our own directory ------------------------------------------------------
# The project path contains spaces and an "&", so every path stays quoted throughout.
HERE="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd -P)"
cd -- "$HERE" || exit 1
APP_DIR="$HERE/ar-aging-app"
BACKEND_DIR="$APP_DIR/backend"
FRONTEND_DIR="$APP_DIR/frontend"
LOG_DIR="$APP_DIR/backend/data/logs"
BACKEND_LOG="$LOG_DIR/backend.log"
FRONTEND_LOG="$LOG_DIR/frontend.log"
BACKEND_PORT=8000 # the Vite proxy hard-codes localhost:8000 — don't change one without the other
FRONTEND_PORT=5173
DASHBOARD_URL="http://localhost:$FRONTEND_PORT"
# --- Finder gives a minimal PATH; put the usual tool locations back ------------------
# Double-clicking does not run your shell profile, so node/npm installed under
# ~/.local/bin or Homebrew would otherwise be "command not found".
export PATH="$HOME/.local/bin:/opt/homebrew/bin:/usr/local/bin:/Library/Frameworks/Python.framework/Versions/3.11/bin:$PATH"
# --- pretty output ------------------------------------------------------------------
if [ -t 1 ]; then
B=$'\033[1m'; DIM=$'\033[2m'; R=$'\033[0m'
OK=$'\033[32m'; WARN=$'\033[33m'; ERR=$'\033[31m'; ACCENT=$'\033[35m'
else
B=""; DIM=""; R=""; OK=""; WARN=""; ERR=""; ACCENT=""
fi
say() { printf '%s\n' "$*"; }
step() { printf ' %s→%s %s\n' "$DIM" "$R" "$*"; }
good() { printf ' %s✓%s %s\n' "$OK" "$R" "$*"; }
warn() { printf ' %s!%s %s\n' "$WARN" "$R" "$*"; }
fail() { printf ' %s✗%s %s\n' "$ERR" "$R" "$*"; }
die() {
printf '\n%s%sCould not start the dashboard.%s\n\n' "$ERR" "$B" "$R"
printf ' %s\n\n' "$1"
[ $# -gt 1 ] && printf ' Try: %s%s%s\n\n' "$B" "$2" "$R"
printf '%sPress Return to close this window.%s\n' "$DIM" "$R"
read -r _
exit 1
}
say ""
say "${ACCENT}${B} Amazon A/R Aging — Month-End Closing${R}"
say "${DIM} $HERE${R}"
say ""
# --- 1. prerequisites ---------------------------------------------------------------
say "${B}1. Checking prerequisites${R}"
[ -d "$BACKEND_DIR" ] || die "Backend folder not found at: $BACKEND_DIR" \
"keep start.command in the same folder as ar-aging-app/"
command -v python3 >/dev/null 2>&1 || die "python3 was not found." \
"install Python 3.11+ from python.org"
command -v npm >/dev/null 2>&1 || die "npm was not found." \
"install Node.js 20+ from nodejs.org"
good "python $(python3 -V 2>&1 | awk '{print $2}') · node $(node -v 2>/dev/null) · npm $(npm -v 2>/dev/null)"
# Each check below diagnoses ONE thing. A single "does the app import?" test used to stand in
# for all of them, so a database that was merely switched off was reported as broken Python
# packages — and the launcher then ran pip install, which of course changed nothing.
# 1a. Python packages — libraries only, no app code, so this cannot fail for config reasons.
if ! python3 -c "import fastapi, uvicorn, sqlalchemy, openpyxl, pymysql, dotenv" >/dev/null 2>&1; then
warn "Python packages are missing or incomplete."
printf ' Install them now? [Y/n] '
read -r reply
case "${reply:-Y}" in
[Nn]*) die "Python dependencies are not installed." \
"python3 -m pip install -r '$BACKEND_DIR/requirements.txt'" ;;
esac
step "installing Python packages (this can take a minute)…"
python3 -m pip install -q -r "$BACKEND_DIR/requirements.txt" \
|| die "pip install failed — see the messages above." \
"python3 -m pip install -r '$BACKEND_DIR/requirements.txt'"
python3 -c "import fastapi, uvicorn, sqlalchemy, openpyxl, pymysql, dotenv" >/dev/null 2>&1 \
|| die "Python packages are still incomplete after installing." "python3 -m pip check"
good "Python packages installed"
else
good "Python packages present"
fi
# 1b. Which database? SQLite needs nothing; MySQL needs a server and credentials. The app
# picks MySQL only when ar-aging-app/.env names a real host, so a machine with no
# database installed still runs off the local file instead of refusing to start.
ENV_FILE="$APP_DIR/.env"
db_backend="$(cd -- "$BACKEND_DIR" && python3 -c "
from app.config import DB_BACKEND; print(DB_BACKEND)" 2>/dev/null)"
db_label="$(cd -- "$BACKEND_DIR" && python3 -c "
from app.config import database_label; print(database_label())" 2>/dev/null)"
if [ "$db_backend" = "mysql" ]; then
good "database: $db_label"
# Reachable? A refused connection is a server/credentials problem, never a Python one.
db_err="$(cd -- "$BACKEND_DIR" && python3 -c "
import sys, pymysql
from app.config import MYSQL_HOST, MYSQL_PORT, MYSQL_USER, MYSQL_PASSWORD
try:
pymysql.connect(host=MYSQL_HOST, port=MYSQL_PORT, user=MYSQL_USER,
password=MYSQL_PASSWORD, connect_timeout=6).close()
except Exception as e:
sys.stderr.write(str(e)); sys.exit(1)
" 2>&1)" || {
fail "Cannot reach the MySQL server named in $ENV_FILE"
say ""
printf ' %s\n' "$(printf '%s' "$db_err" | tail -n 2)"
say ""
say " Start the server and run this again, or fall back to the local file by setting"
say " ${B}AR_DB_BACKEND=sqlite${R} in $ENV_FILE."
say ""
printf '%sPress Return to close this window.%s\n' "$DIM" "$R"
read -r _
exit 1
}
good "MySQL reachable"
else
good "database: $db_label"
[ -f "$ENV_FILE" ] || warn "no .env — using the local file (fine for a demo or one user)"
fi
# 1d. Everything above is fine — now prove the app itself loads. Anything failing here is a
# genuine code/dependency problem, so the message points at that rather than at config.
if ! import_error="$(cd -- "$BACKEND_DIR" && python3 -c "from app.api.main import app" 2>&1)"; then
fail "The backend failed to load even though its packages and database are fine:"
printf '%s\n' "$import_error" | tail -n 6 | sed 's/^/ /'
die "This looks like a code or dependency-version problem." "python3 -m pip check"
fi
good "backend loads cleanly"
# Frontend packages — safe to install unattended, they're local to the project.
if [ ! -d "$FRONTEND_DIR/node_modules" ]; then
step "installing frontend packages (first run only, ~1 minute)…"
( cd -- "$FRONTEND_DIR" && npm install --silent ) \
|| die "npm install failed — see the messages above." \
"cd '$FRONTEND_DIR' && npm install"
good "frontend packages installed"
else
good "frontend packages present"
fi
# --- 2. ports -----------------------------------------------------------------------
# A leftover server from a previous run holds the port and the launch silently fails.
# The Vite proxy points at a fixed localhost:8000, so we can't just pick another port.
free_port() {
local port="$1" label="$2" pids
pids="$(lsof -ti tcp:"$port" -sTCP:LISTEN 2>/dev/null)"
[ -z "$pids" ] && { good "port $port free ($label)"; return 0; }
warn "port $port is already in use ($label):"
local pid
for pid in $pids; do
printf ' pid %-7s %s\n' "$pid" "$(ps -p "$pid" -o command= 2>/dev/null | cut -c1-88)"
done
printf ' Stop it and continue? [Y/n] '
read -r reply
case "${reply:-Y}" in
[Nn]*) die "Port $port is in use, so the dashboard cannot start." \
"quit the other program, or close the old dashboard window" ;;
esac
for pid in $pids; do kill "$pid" 2>/dev/null; done
for _ in 1 2 3 4 5 6 7 8 9 10; do
sleep 0.3
[ -z "$(lsof -ti tcp:"$port" -sTCP:LISTEN 2>/dev/null)" ] && break
done
# Still holding on? Escalate once.
pids="$(lsof -ti tcp:"$port" -sTCP:LISTEN 2>/dev/null)"
if [ -n "$pids" ]; then
for pid in $pids; do kill -9 "$pid" 2>/dev/null; done
sleep 1
fi
[ -n "$(lsof -ti tcp:"$port" -sTCP:LISTEN 2>/dev/null)" ] \
&& die "Port $port is still in use after trying to stop it." \
"restart your Mac, or find the process with: lsof -i :$port"
good "port $port freed"
}
say ""
say "${B}2. Checking ports${R}"
free_port "$BACKEND_PORT" "backend"
free_port "$FRONTEND_PORT" "frontend"
# --- 3. start the servers -----------------------------------------------------------
mkdir -p "$LOG_DIR"
BACKEND_PID=""
FRONTEND_PID=""
shutdown() {
printf '\n%sStopping…%s\n' "$DIM" "$R"
# Kill the whole process group of each server: uvicorn --reload and vite both fork.
[ -n "$FRONTEND_PID" ] && kill -- "-$FRONTEND_PID" 2>/dev/null
[ -n "$BACKEND_PID" ] && kill -- "-$BACKEND_PID" 2>/dev/null
sleep 0.5
[ -n "$FRONTEND_PID" ] && kill -9 -- "-$FRONTEND_PID" 2>/dev/null
[ -n "$BACKEND_PID" ] && kill -9 -- "-$BACKEND_PID" 2>/dev/null
printf '%sBoth servers stopped.%s\n\n' "$OK" "$R"
exit 0
}
trap shutdown INT TERM
say ""
say "${B}3. Starting servers${R}"
step "backend (FastAPI on :$BACKEND_PORT)"
# setsid-style: run in its own process group so shutdown() can take down the reloader too.
set -m
python3 -m uvicorn app.api.main:app \
--app-dir "$BACKEND_DIR" \
--host 127.0.0.1 --port "$BACKEND_PORT" \
>"$BACKEND_LOG" 2>&1 &
BACKEND_PID=$!
set +m
# Wait for it to actually answer — "process started" is not the same as "server ready".
backend_ready=""
for _ in $(seq 1 60); do
if ! kill -0 "$BACKEND_PID" 2>/dev/null; then
say ""
tail -n 25 "$BACKEND_LOG"
die "The backend exited during startup (log above)." "full log: $BACKEND_LOG"
fi
if curl -fsS "http://127.0.0.1:$BACKEND_PORT/api/health" >/dev/null 2>&1; then
backend_ready=1; break
fi
sleep 0.5
done
[ -n "$backend_ready" ] || { tail -n 25 "$BACKEND_LOG"; \
die "The backend did not respond within 30 seconds." "full log: $BACKEND_LOG"; }
good "backend ready"
step "frontend (Vite on :$FRONTEND_PORT)"
set -m
npm --prefix "$FRONTEND_DIR" run dev >"$FRONTEND_LOG" 2>&1 &
FRONTEND_PID=$!
set +m
frontend_ready=""
for _ in $(seq 1 60); do
if ! kill -0 "$FRONTEND_PID" 2>/dev/null; then
say ""
tail -n 25 "$FRONTEND_LOG"
die "The frontend exited during startup (log above)." "full log: $FRONTEND_LOG"
fi
if curl -fsS -o /dev/null "$DASHBOARD_URL" 2>/dev/null; then
frontend_ready=1; break
fi
sleep 0.5
done
[ -n "$frontend_ready" ] || { tail -n 25 "$FRONTEND_LOG"; \
die "The frontend did not respond within 30 seconds." "full log: $FRONTEND_LOG"; }
good "frontend ready"
# End-to-end check: the browser reaches the API *through* the Vite proxy, not directly.
if curl -fsS -o /dev/null "$DASHBOARD_URL/api/sessions" 2>/dev/null; then
good "dashboard is talking to the API"
else
warn "the API proxy did not answer — the dashboard may show loading errors"
fi
# --- 4. open it ---------------------------------------------------------------------
say ""
say "${B}4. Opening the dashboard${R}"
open "$DASHBOARD_URL" 2>/dev/null && good "$DASHBOARD_URL" \
|| warn "could not open a browser — go to $DASHBOARD_URL yourself"
say ""
say "${OK}${B} Dashboard is running.${R}"
say ""
say " Dashboard ${B}$DASHBOARD_URL${R}"
say " API docs ${DIM}http://localhost:$BACKEND_PORT/docs${R}"
say " Logs ${DIM}$LOG_DIR${R}"
say ""
say "${DIM} Keep this window open while you work.${R}"
say "${DIM} Press Ctrl-C to stop both servers.${R}"
say ""
# Stay alive until a server dies or the user interrupts.
while kill -0 "$BACKEND_PID" 2>/dev/null && kill -0 "$FRONTEND_PID" 2>/dev/null; do
sleep 1
done
say ""
fail "A server stopped unexpectedly. Last lines of each log:"
say ""
say "${DIM}--- backend ---${R}"; tail -n 12 "$BACKEND_LOG" 2>/dev/null
say "${DIM}--- frontend ---${R}"; tail -n 12 "$FRONTEND_LOG" 2>/dev/null
shutdown