pf_app/pf.sh
Paul Trowbridge 146961cc17 Point pf.sh at the DB_* env vars the server actually reads
server.js builds its pg pool from DB_HOST/DB_PORT/DB_NAME/DB_USER/
DB_PASSWORD, and that is what install.sh writes, but pf.sh had been
written against a DATABASE_URL/PF_USER scheme that nothing consumes.
The consequences were real: `status` always reported the database
unreachable, and `config` rewrote .env with cat >, replacing a working
connection with keys the server ignores.

Connect through a run_psql helper built from the DB_* vars, and have
config prompt for those six keys instead. Each prompt defaults to the
current value so entering through changes nothing, the password prompt
is hidden and keeps the stored one when left blank, and any other key
already in .env is carried over rather than dropped.

PF_USER goes away with it — pf_user reaches the server in the request
body, never from the environment.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 22:08:49 -04:00

388 lines
11 KiB
Bash
Executable File

#!/usr/bin/env bash
set -euo pipefail
# ---------------------------------------------------------------------------
# pf.sh — Pivot Forecast management script
# Usage: ./pf.sh [deploy|start|stop|restart|status|logs|db-setup|config]
# ./pf.sh (interactive menu)
# ---------------------------------------------------------------------------
SERVICE_NAME="pf_app"
APP_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
SERVICE_FILE="/etc/systemd/system/${SERVICE_NAME}.service"
ENV_FILE="${APP_DIR}/.env"
MIN_NODE_MAJOR=20
# -- Colors ------------------------------------------------------------------
R='\033[0;31m'; G='\033[0;32m'; Y='\033[1;33m'; B='\033[0;34m'; NC='\033[0m'
bold() { echo -e "\033[1m$*\033[0m"; }
info() { echo -e "${B}==>${NC} $*"; }
success() { echo -e "${G}${NC} $*"; }
warn() { echo -e "${Y} !${NC} $*"; }
error() { echo -e "${R}${NC} $*" >&2; }
die() { error "$*"; exit 1; }
# -- Helpers -----------------------------------------------------------------
require_systemd() {
systemctl --version &>/dev/null || die "systemd not found on this system."
}
node_binary() {
command -v node 2>/dev/null || true
}
check_node() {
local node
node=$(node_binary)
[[ -z "$node" ]] && die "node not found. Install Node.js >= ${MIN_NODE_MAJOR}."
local ver
ver=$(node --version | sed 's/v//')
local major="${ver%%.*}"
if (( major < MIN_NODE_MAJOR )); then
die "Node.js ${ver} found; requires >= ${MIN_NODE_MAJOR}. Please upgrade."
fi
success "Node.js ${ver}"
}
require_env() {
[[ -f "$ENV_FILE" ]] || die ".env not found. Run: ./pf.sh config"
}
load_env() {
require_env
set -a; source "$ENV_FILE"; set +a
}
sudo_if_needed() {
# Returns "sudo" if we're not root, empty string if we are
[[ "$EUID" -eq 0 ]] && echo "" || echo "sudo"
}
service_installed() {
[[ -f "$SERVICE_FILE" ]]
}
require_service() {
service_installed || die "systemd service not installed. Run: ./pf.sh install-service"
}
# The keys cmd_config manages; anything else in .env is left alone.
ENV_KEYS=(DB_HOST DB_PORT DB_NAME DB_USER DB_PASSWORD PORT)
env_get() {
[[ -f "$ENV_FILE" ]] || return 0
grep -E "^$1=" "$ENV_FILE" | tail -1 | cut -d= -f2- | tr -d '"' || true
}
# psql against the DB_* connection in .env; extra args are passed through.
run_psql() {
PGPASSWORD="${DB_PASSWORD:-}" psql \
-h "${DB_HOST:-localhost}" \
-p "${DB_PORT:-5432}" \
-U "${DB_USER}" \
-d "${DB_NAME}" \
"$@"
}
db_ping() {
load_env
if [[ -z "${DB_NAME:-}" || -z "${DB_USER:-}" ]]; then
warn "DB_NAME / DB_USER not set in .env"
return 1
fi
# Use psql if available for a real connectivity check
if command -v psql &>/dev/null; then
run_psql -tAc "SELECT 1" &>/dev/null && return 0 || return 1
else
warn "psql not in PATH — skipping live DB check"
return 0
fi
}
# -- Commands ----------------------------------------------------------------
cmd_deploy() {
echo; bold "Deploying Pivot Forecast"
echo " App dir: $APP_DIR"
echo
check_node
require_env
info "Pulling latest from git…"
git -C "$APP_DIR" pull
info "Installing server dependencies…"
npm --prefix "$APP_DIR" install --omit=dev
info "Installing UI dependencies…"
npm --prefix "$APP_DIR/ui" install
info "Building UI…"
npm --prefix "$APP_DIR/ui" run build
if service_installed; then
info "Restarting service…"
cmd_restart
else
warn "Service not installed — server not started."
echo " Run: ./pf.sh install-service"
fi
success "Deploy complete."
}
cmd_start() {
require_systemd; require_service
info "Starting ${SERVICE_NAME}"
$(sudo_if_needed) systemctl start "$SERVICE_NAME"
success "Started."
}
cmd_stop() {
require_systemd; require_service
info "Stopping ${SERVICE_NAME}"
$(sudo_if_needed) systemctl stop "$SERVICE_NAME"
success "Stopped."
}
cmd_restart() {
require_systemd; require_service
info "Restarting ${SERVICE_NAME}"
$(sudo_if_needed) systemctl restart "$SERVICE_NAME"
success "Restarted."
}
cmd_status() {
require_systemd
echo
bold "System service"
if service_installed; then
systemctl status "$SERVICE_NAME" --no-pager -l || true
else
warn "Service not installed — run: ./pf.sh install-service"
fi
echo
bold "Database"
if db_ping; then
success "DB reachable"
else
error "DB not reachable (check DATABASE_URL in .env)"
fi
echo
bold "Git"
git -C "$APP_DIR" log -1 --format=" Commit: %h %s (%ar)"
local branch
branch=$(git -C "$APP_DIR" rev-parse --abbrev-ref HEAD)
echo " Branch: $branch"
}
cmd_logs() {
require_systemd; require_service
info "Streaming logs (Ctrl-C to exit)…"
journalctl -u "$SERVICE_NAME" -f --no-pager
}
cmd_db_setup() {
require_env; load_env
[[ -n "${DB_NAME:-}" && -n "${DB_USER:-}" ]] || die "DB_NAME / DB_USER not set in .env — run: ./pf.sh config"
command -v psql &>/dev/null || die "psql not found — install postgresql-client"
echo
bold "DB Setup — will run: setup_sql/01_schema.sql"
warn "This creates the pf schema and tables. Safe to re-run (CREATE IF NOT EXISTS)."
read -rp " Continue? [y/N] " confirm
[[ "$confirm" =~ ^[Yy]$ ]] || { echo "Aborted."; return; }
run_psql -v ON_ERROR_STOP=1 -f "${APP_DIR}/setup_sql/01_schema.sql"
success "Schema applied."
}
cmd_config() {
echo
bold "Configure .env"
echo " File: $ENV_FILE"
echo
local cur_host cur_port cur_name cur_user cur_pass cur_app_port
cur_host=$(env_get DB_HOST)
cur_port=$(env_get DB_PORT)
cur_name=$(env_get DB_NAME)
cur_user=$(env_get DB_USER)
cur_pass=$(env_get DB_PASSWORD)
cur_app_port=$(env_get PORT)
local input
read -rp " DB_HOST [${cur_host:-localhost}]: " input
local host="${input:-${cur_host:-localhost}}"
read -rp " DB_PORT [${cur_port:-5432}]: " input
local port="${input:-${cur_port:-5432}}"
read -rp " DB_NAME [${cur_name:-not set}]: " input
local name="${input:-$cur_name}"
[[ -z "$name" ]] && die "DB_NAME is required."
read -rp " DB_USER [${cur_user:-$USER}]: " input
local user="${input:-${cur_user:-$USER}}"
if [[ -n "$cur_pass" ]]; then
read -rsp " DB_PASSWORD [keep existing]: " input; echo
else
read -rsp " DB_PASSWORD: " input; echo
fi
local pass="${input:-$cur_pass}"
read -rp " PORT (app) [${cur_app_port:-3010}]: " input
local app_port="${input:-${cur_app_port:-3010}}"
# Rewrite the managed keys, carrying over any other lines already in .env.
local tmp
tmp=$(mktemp)
cat > "$tmp" <<EOF
DB_HOST=${host}
DB_PORT=${port}
DB_NAME=${name}
DB_USER=${user}
DB_PASSWORD=${pass}
PORT=${app_port}
EOF
if [[ -f "$ENV_FILE" ]]; then
local managed
managed=$(IFS='|'; echo "${ENV_KEYS[*]}")
grep -vE "^(${managed})=" "$ENV_FILE" | grep -vE '^[[:space:]]*$' >> "$tmp" || true
fi
mv "$tmp" "$ENV_FILE"
chmod 600 "$ENV_FILE"
success ".env written."
if db_ping; then
success "Database connection verified."
else
warn "Could not reach the database — double-check the DB_* settings."
fi
}
cmd_install_service() {
require_systemd
require_env
local node_path
node_path=$(node_binary)
[[ -z "$node_path" ]] && die "node not found — install Node.js first."
local run_user="$USER"
local s
s=$(sudo_if_needed)
echo
bold "Install systemd service"
echo " Service file : $SERVICE_FILE"
echo " Run as user : $run_user"
echo " App dir : $APP_DIR"
echo " Node binary : $node_path"
echo
read -rp " Continue? [y/N] " confirm
[[ "$confirm" =~ ^[Yy]$ ]] || { echo "Aborted."; return; }
$s tee "$SERVICE_FILE" > /dev/null <<EOF
[Unit]
Description=Pivot Forecast App
After=network.target
[Service]
Type=simple
User=${run_user}
WorkingDirectory=${APP_DIR}
EnvironmentFile=${ENV_FILE}
ExecStart=${node_path} ${APP_DIR}/server.js
Restart=on-failure
RestartSec=5
StandardOutput=journal
StandardError=journal
SyslogIdentifier=${SERVICE_NAME}
[Install]
WantedBy=multi-user.target
EOF
$s systemctl daemon-reload
$s systemctl enable "$SERVICE_NAME"
success "Service installed and enabled."
echo " Start now with: ./pf.sh start"
}
cmd_uninstall_service() {
require_systemd
service_installed || { warn "Service not installed."; return; }
echo
warn "This will stop and remove the systemd service (does not touch app files)."
read -rp " Continue? [y/N] " confirm
[[ "$confirm" =~ ^[Yy]$ ]] || { echo "Aborted."; return; }
local s
s=$(sudo_if_needed)
$s systemctl stop "$SERVICE_NAME" 2>/dev/null || true
$s systemctl disable "$SERVICE_NAME" 2>/dev/null || true
$s rm -f "$SERVICE_FILE"
$s systemctl daemon-reload
success "Service removed."
}
# -- Interactive menu --------------------------------------------------------
interactive_menu() {
while true; do
echo
bold "Pivot Forecast — Management"
echo " 1) deploy pull + install + build + restart"
echo " 2) start start server"
echo " 3) stop stop server"
echo " 4) restart restart server"
echo " 5) status service + DB + git info"
echo " 6) logs tail journald logs"
echo " 7) db-setup apply setup_sql/01_schema.sql"
echo " 8) config set DB connection + app PORT"
echo " 9) install-service create systemd unit file"
echo " 10) uninstall-service remove systemd unit file"
echo " q) quit"
echo
read -rp " Choice: " choice
case "$choice" in
1|deploy) cmd_deploy ;;
2|start) cmd_start ;;
3|stop) cmd_stop ;;
4|restart) cmd_restart ;;
5|status) cmd_status ;;
6|logs) cmd_logs ;;
7|db-setup) cmd_db_setup ;;
8|config) cmd_config ;;
9|install-service) cmd_install_service ;;
10|uninstall-service) cmd_uninstall_service ;;
q|Q|quit|exit) echo "Bye."; exit 0 ;;
*) warn "Unknown option: $choice" ;;
esac
done
}
# -- Dispatch ----------------------------------------------------------------
case "${1:-}" in
deploy) cmd_deploy ;;
start) cmd_start ;;
stop) cmd_stop ;;
restart) cmd_restart ;;
status) cmd_status ;;
logs) cmd_logs ;;
db-setup) cmd_db_setup ;;
config) cmd_config ;;
install-service) cmd_install_service ;;
uninstall-service) cmd_uninstall_service ;;
"") interactive_menu ;;
*) die "Unknown command: $1. Valid: deploy start stop restart status logs db-setup config install-service uninstall-service" ;;
esac