#!/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 [add-user|passwd|list-users|disable-user|enable-user] # ./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 SESSION_SECRET COOKIE_SECURE) 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, setup_sql/02_auth.sql" warn "This creates the pf schema, tables, and the account/session tables." warn "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" run_psql -v ON_ERROR_STOP=1 -f "${APP_DIR}/setup_sql/02_auth.sql" success "Schema applied." local n n=$(run_psql -tAc "SELECT count(*) FROM pf.app_user WHERE is_active" 2>/dev/null || echo 0) if [[ "${n:-0}" == "0" ]]; then warn "No active accounts yet — create one with: ./pf.sh add-user" fi } 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}}" # Session cookies are signed with this; regenerating it signs everyone out, # so an existing secret is kept rather than re-rolled on every config run. local secret secret=$(env_get SESSION_SECRET) if [[ -z "$secret" ]]; then secret=$(node -e 'console.log(require("crypto").randomBytes(32).toString("hex"))') success "SESSION_SECRET generated." else success "SESSION_SECRET kept (delete the line in .env to re-roll)." fi local cur_secure cur_secure=$(env_get COOKIE_SECURE) read -rp " COOKIE_SECURE — HTTPS-only cookie [${cur_secure:-true}]: " input local cookie_secure="${input:-${cur_secure:-true}}" # Rewrite the managed keys, carrying over any other lines already in .env. local tmp tmp=$(mktemp) cat > "$tmp" <> "$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 } # -- Accounts ---------------------------------------------------------------- # Reads a password twice without echo and hashes it with lib/auth.js, so the # plaintext never reaches argv, psql, or the shell history. read_new_password() { local p1 p2 # Prompts and their newlines go to stderr: stdout is the hash, and a stray # newline there ends up prefixed to it by the caller's $( ). read -rsp " Password: " p1; echo >&2 [[ -z "$p1" ]] && { error "Password cannot be empty."; return 1; } read -rsp " Confirm : " p2; echo >&2 [[ "$p1" != "$p2" ]] && { error "Passwords do not match."; return 1; } printf '%s' "$p1" | node "${APP_DIR}/lib/auth.js" hash } # psql single-quoted literal: double any embedded quote. sql_lit() { printf "%s" "${1//\'/\'\'}"; } cmd_add_user() { require_env; load_env check_node >/dev/null echo; bold "Add account" local username display hash read -rp " Username: " username [[ -z "$username" ]] && die "Username is required." read -rp " Display name [${username}]: " display display="${display:-$username}" hash=$(read_new_password) || return 1 run_psql -v ON_ERROR_STOP=1 -tAc " WITH ins AS ( INSERT INTO pf.app_user (username, pass_hash, display_name) VALUES ('$(sql_lit "$username")', '$(sql_lit "$hash")', '$(sql_lit "$display")') ON CONFLICT (username) DO NOTHING RETURNING id ) SELECT id FROM ins" | grep -q . \ && success "Account '${username}' created." \ || die "Account '${username}' already exists — change its password with: ./pf.sh passwd" } cmd_passwd() { require_env; load_env check_node >/dev/null echo; bold "Change password" local username hash read -rp " Username: " username [[ -z "$username" ]] && die "Username is required." hash=$(read_new_password) || return 1 run_psql -v ON_ERROR_STOP=1 -tAc " WITH upd AS ( UPDATE pf.app_user SET pass_hash = '$(sql_lit "$hash")' WHERE lower(username) = lower('$(sql_lit "$username")') RETURNING id ) SELECT id FROM upd" | grep -q . \ && success "Password updated for '${username}'." \ || die "No such account: ${username}" } cmd_list_users() { require_env; load_env echo; bold "Accounts" run_psql -c " SELECT username, display_name, is_active, to_char(last_login_at, 'YYYY-MM-DD HH24:MI') AS last_login FROM pf.app_user ORDER BY username" } # Deactivating leaves the row (and its history) in place, and drops any live # session so the account loses access immediately rather than at cookie expiry. cmd_disable_user() { require_env; load_env local username="${1:-}" [[ -z "$username" ]] && { read -rp " Username to disable: " username; } [[ -z "$username" ]] && die "Username is required." run_psql -v ON_ERROR_STOP=1 -tAc " WITH upd AS ( UPDATE pf.app_user SET is_active = false WHERE lower(username) = lower('$(sql_lit "$username")') RETURNING id ) SELECT id FROM upd" | grep -q . \ || die "No such account: ${username}" run_psql -v ON_ERROR_STOP=1 -c " DELETE FROM pf.session WHERE sess::jsonb -> 'user' ->> 'username' ILIKE '$(sql_lit "$username")'" >/dev/null success "Account '${username}' disabled and signed out." } cmd_enable_user() { require_env; load_env local username="${1:-}" [[ -z "$username" ]] && { read -rp " Username to enable: " username; } [[ -z "$username" ]] && die "Username is required." run_psql -v ON_ERROR_STOP=1 -tAc " WITH upd AS ( UPDATE pf.app_user SET is_active = true WHERE lower(username) = lower('$(sql_lit "$username")') RETURNING id ) SELECT id FROM upd" | grep -q . \ && success "Account '${username}' enabled." \ || die "No such account: ${username}" } 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 </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 " 11) add-user create a login account" echo " 12) passwd change an account password" echo " 13) list-users show accounts" echo " 14) disable-user deactivate an account and sign it out" echo " 15) enable-user reactivate an account" 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 ;; 11|add-user) cmd_add_user ;; 12|passwd) cmd_passwd ;; 13|list-users) cmd_list_users ;; 14|disable-user) cmd_disable_user ;; 15|enable-user) cmd_enable_user ;; 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 ;; add-user) cmd_add_user ;; passwd) cmd_passwd ;; list-users) cmd_list_users ;; disable-user) cmd_disable_user "${2:-}" ;; enable-user) cmd_enable_user "${2:-}" ;; "") interactive_menu ;; *) die "Unknown command: $1. Valid: deploy start stop restart status logs db-setup config install-service uninstall-service add-user passwd list-users disable-user enable-user" ;; esac