Finance-Accounts/start.command

362 lines
13 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"
VENV_DIR="$APP_DIR/.venv"
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"
# Auto-answer prompts when stdin is not a Terminal (CI / Cursor / piped runs).
INTERACTIVE=0
[ -t 0 ] && INTERACTIVE=1
ask_yes() {
local prompt="$1"
if [ "$INTERACTIVE" -eq 0 ]; then
printf ' %s Y (non-interactive)\n' "$prompt"
return 0
fi
printf ' %s [Y/n] ' "$prompt"
read -r reply
case "${reply:-Y}" in
[Nn]*) return 1 ;;
*) return 0 ;;
esac
}
# --- 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"
if [ "$INTERACTIVE" -eq 1 ]; then
printf '%sPress Return to close this window.%s\n' "$DIM" "$R"
read -r _
fi
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)"
# Isolated venv — required. Global site-packages (e.g. streamlit's Starlette 1.x) break
# FastAPI 0.115's Router(on_startup=...) and make "import fastapi" look fine while the app dies.
ensure_venv() {
if [ ! -x "$VENV_DIR/bin/python" ]; then
step "creating project virtualenv at $VENV_DIR"
python3 -m venv "$VENV_DIR" \
|| die "Could not create the virtualenv." "python3 -m venv '$VENV_DIR'"
good "virtualenv created"
else
good "virtualenv present"
fi
PYTHON="$VENV_DIR/bin/python"
PIP="$VENV_DIR/bin/pip"
}
install_python_deps() {
step "installing Python packages into the project venv…"
"$PIP" install -q --upgrade pip \
|| die "pip upgrade failed." "'$PIP' install --upgrade pip"
"$PIP" install -q -r "$BACKEND_DIR/requirements.txt" \
|| die "pip install failed — see the messages above." \
"'$PIP' install -r '$BACKEND_DIR/requirements.txt'"
}
backend_imports_ok() {
"$PYTHON" -c "import fastapi, uvicorn, sqlalchemy, openpyxl, pymysql, dotenv" >/dev/null 2>&1
}
# Prove the pin that Streamlit commonly breaks: Starlette must stay <0.42 for this FastAPI.
backend_versions_ok() {
"$PYTHON" -c "
import fastapi, starlette
from packaging.version import Version
assert Version(fastapi.__version__) >= Version('0.115.0')
assert Version(starlette.__version__) < Version('0.42.0'), starlette.__version__
" >/dev/null 2>&1
}
backend_app_loads() {
( cd -- "$BACKEND_DIR" && "$PYTHON" -c "from app.api.main import app" ) >/dev/null 2>&1
}
ensure_venv
need_install=0
if ! backend_imports_ok; then
warn "Python packages are missing or incomplete in the project venv."
need_install=1
elif ! backend_versions_ok; then
warn "Wrong Starlette/FastAPI versions in the venv (often after a global pip upgrade)."
need_install=1
elif ! backend_app_loads; then
warn "Backend failed to import — reinstalling pinned dependencies."
need_install=1
fi
if [ "$need_install" -eq 1 ]; then
ask_yes "Install / repair Python packages now?" \
|| die "Python dependencies are not installed." \
"'$PIP' install -r '$BACKEND_DIR/requirements.txt'"
install_python_deps
# packaging is used only for the version check; requirements may not list it.
"$PIP" install -q packaging >/dev/null 2>&1 || true
backend_imports_ok \
|| die "Python packages are still incomplete after installing." "'$PIP' check"
backend_versions_ok \
|| die "Starlette is still too new for this FastAPI pin." \
"'$PIP' install 'starlette==0.41.3'"
good "Python packages installed"
else
good "Python packages present (pinned versions)"
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" && "$PYTHON" -c "
from app.config import DB_BACKEND; print(DB_BACKEND)" 2>/dev/null)"
db_label="$(cd -- "$BACKEND_DIR" && "$PYTHON" -c "
from app.config import database_label; print(database_label())" 2>/dev/null)"
if [ "$db_backend" = "mysql" ]; then
good "database: $db_label"
db_err="$(cd -- "$BACKEND_DIR" && "$PYTHON" -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 ""
if [ "$INTERACTIVE" -eq 1 ]; then
printf '%sPress Return to close this window.%s\n' "$DIM" "$R"
read -r _
fi
exit 1
}
good "MySQL reachable"
else
good "database: ${db_label:-SQLite (local file)}"
[ -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.
if ! import_error="$(cd -- "$BACKEND_DIR" && "$PYTHON" -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 8 | sed 's/^/ /'
die "This looks like a code or dependency-version problem." "'$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 || true)"
[ -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
ask_yes "Stop it and continue?" \
|| die "Port $port is in use, so the dashboard cannot start." \
"quit the other program, or close the old dashboard window"
for pid in $pids; do kill "$pid" 2>/dev/null || true; 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 || true)" ] && break
done
pids="$(lsof -ti tcp:"$port" -sTCP:LISTEN 2>/dev/null || true)"
if [ -n "$pids" ]; then
for pid in $pids; do kill -9 "$pid" 2>/dev/null || true; done
sleep 1
fi
[ -n "$(lsof -ti tcp:"$port" -sTCP:LISTEN 2>/dev/null || true)" ] \
&& 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 || true
[ -n "$BACKEND_PID" ] && kill -- "-$BACKEND_PID" 2>/dev/null || true
sleep 0.5
[ -n "$FRONTEND_PID" ] && kill -9 -- "-$FRONTEND_PID" 2>/dev/null || true
[ -n "$BACKEND_PID" ] && kill -9 -- "-$BACKEND_PID" 2>/dev/null || true
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)"
# Own process group so shutdown() can take down the reloader children too.
set -m
"$PYTHON" -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
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"
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 " Python ${DIM}$PYTHON${R}"
say ""
say "${DIM} Keep this window open while you work.${R}"
say "${DIM} Press Ctrl-C to stop both servers.${R}"
say ""
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