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>
388 lines
11 KiB
Bash
Executable File
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
|