bin/ Re-engineering Phase 2 — Implementation Plan
For Claude: REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
Goal: Refactor 5 existing bin/ scripts to source bin/lib/ shared libraries, create deploy-systemd.sh, add smoke tests for all scripts, and update docs.
Architecture: Each script replaces its inline color/logging/path/dotenv/check code with source "$SCRIPT_DIR/lib/<lib>.sh" calls. The deploy-systemd.sh follows the same pattern as deploy-docker.sh. Smoke tests use BATS to verify syntax, –help flags, and graceful failure without prerequisites.
Tech Stack: Bash, BATS, systemd, existing bin/lib/ from Phase 1
Task 1: Refactor install.sh to source bin/lib/
Files:
- Modify:
bin/install.sh
What to change:
Replace lines 7-32 (everything between set -e and first function definition) with:
set -e
# Source shared libraries
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
source "$SCRIPT_DIR/lib/logging.sh"
source "$SCRIPT_DIR/lib/checks.sh"
source "$SCRIPT_DIR/lib/dotenv.sh"
cd "$PROJECT_DIR"
Then delete these functions that are now in lib files (they are exact duplicates):
command_exists()(line 30-32) — now inlib/checks.shdotenv_ensure_file()(lines 34-51) — now inlib/dotenv.shdotenv_has_key()(lines 53-57) — now inlib/dotenv.shdotenv_set_if_missing()(lines 59-69) — now inlib/dotenv.shprompt_non_empty()(lines 71-82) — now inlib/dotenv.shcheck_python()(lines 276-302) — now inlib/checks.shcheck_uv()(lines 304-332) — now inlib/checks.sh
Keep these functions that are unique to install.sh:
dotenv_prompt_setup()(lines 84-215) — install-specific interactive promptsdotenv_prompt_docker()(lines 217-270) — docker-specific interactive prompts
Important behavior change: The lib versions of check_python and check_uv use return 1 instead of exit 1. In the main body where they’re called (lines 387-388), wrap them:
check_python || exit 1
check_uv || exit 1
Step 1: Make the changes
Read bin/install.sh fully. Replace the header (lines 7-32) with source lines. Delete the 7 duplicated functions. Update check_python/check_uv calls to || exit 1. Keep all unique functions and the main body intact.
Step 2: Verify syntax
Run: bash -n bin/install.sh Expected: No errors
Step 3: Verify lib functions are accessible
Run: bash -c 'source bin/lib/dotenv.sh && type dotenv_has_key' Expected: Shows function definition
Step 4: Commit
git add bin/install.sh
git commit -m "refactor: install.sh sources bin/lib/ instead of inline helpers"
Task 2: Refactor deploy-docker.sh to source bin/lib/
Files:
- Modify:
bin/deploy-docker.sh
What to change:
Replace lines 7-58 (set -e through get_service_state function) with:
set -e
# Source shared libraries
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
source "$SCRIPT_DIR/lib/logging.sh"
source "$SCRIPT_DIR/lib/docker.sh"
source "$SCRIPT_DIR/lib/paths.sh"
COMPOSE_FILE="$PROJECT_DIR/deploy/docker/docker-compose.yml"
cd "$PROJECT_DIR"
Delete:
- Color variables (lines 21-25)
- Logging functions (lines 28-31)
get_service_state()function (lines 36-58) — now inlib/docker.sh
Important: The lib’s get_service_state takes TWO arguments (compose_file, service) while the current inline version takes ONE (service) and uses the global $COMPOSE_FILE. Update all call sites in the health verification section:
get_service_state web→get_service_state "$COMPOSE_FILE" webget_service_state celery→get_service_state "$COMPOSE_FILE" celery
There are 4 call sites total (lines 142, 151, 164, 165).
Also replace the inline pre-flight checks (lines 70-99) with docker_preflight from the lib, but keep the .env check since docker_preflight doesn’t do that:
# Check .env
info "Checking for .env file..."
if [ ! -f "$PROJECT_DIR/.env" ]; then
error ".env file not found."
echo " Run ./bin/install.sh first, or copy .env.sample to .env and configure it."
exit 1
fi
success ".env file found"
# Docker pre-flight (daemon + compose v2)
docker_preflight || exit 1
Step 1: Make the changes Step 2: Verify syntax: bash -n bin/deploy-docker.sh Step 3: Commit
git add bin/deploy-docker.sh
git commit -m "refactor: deploy-docker.sh sources bin/lib/ instead of inline helpers"
Task 3: Refactor check_system.sh to source bin/lib/
Files:
- Modify:
bin/check_system.sh
What to change:
Replace lines 7-19 (colors and path resolution) with:
set -e
# Source shared libraries
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
source "$SCRIPT_DIR/lib/colors.sh"
source "$SCRIPT_DIR/lib/paths.sh"
cd "$PROJECT_DIR"
Note: check_system.sh uses its own check_pass/check_warn/check_fail functions (not the standard info/success/warn/error), so we only source colors.sh and paths.sh. The BOLD variable comes from colors.sh. The command -v on line 56 stays inline — it’s a one-off, not worth importing all of checks.sh.
Delete: lines 10-14 (RED, GREEN, YELLOW, BOLD, NC) and lines 17-19 (SCRIPT_DIR, PROJECT_DIR).
Step 1: Make the changes Step 2: Verify syntax: bash -n bin/check_system.sh Step 3: Commit
git add bin/check_system.sh
git commit -m "refactor: check_system.sh sources bin/lib/ instead of inline helpers"
Task 4: Refactor setup_cron.sh to source bin/lib/
Files:
- Modify:
bin/setup_cron.sh
What to change:
Replace lines 7-35 (colors, logging functions, path resolution) with:
set -e
# Source shared libraries
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
source "$SCRIPT_DIR/lib/logging.sh"
source "$SCRIPT_DIR/lib/paths.sh"
cd "$PROJECT_DIR"
Delete:
- Lines 10-14: color variables
- Lines 16-30: info/success/warn/error functions
- Lines 33-35: SCRIPT_DIR/PROJECT_DIR
Keep the rest of the script unchanged (the menu, cron logic, summary).
Step 1: Make the changes Step 2: Verify syntax: bash -n bin/setup_cron.sh Step 3: Commit
git add bin/setup_cron.sh
git commit -m "refactor: setup_cron.sh sources bin/lib/ instead of inline helpers"
Task 5: Refactor setup_aliases.sh to source bin/lib/
Files:
- Modify:
bin/setup_aliases.sh
What to change:
Replace lines 13-32 (colors, paths, helpers) with:
set -euo pipefail
# Source shared libraries
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
source "$SCRIPT_DIR/lib/logging.sh"
source "$SCRIPT_DIR/lib/paths.sh"
ALIASES_FILE="$SCRIPT_DIR/aliases.sh"
MARKER="# server-maintanence aliases"
Delete:
- Lines 16-20: color variables
- Lines 23-24: SCRIPT_DIR/PROJECT_DIR
- Lines 29-32: info/success/warn/error functions
Keep detect_profile, show_help, show_list, generate_aliases, install_source_line, do_remove, main — all unique to this script.
Note on label differences: The current setup_aliases.sh uses [info], [ok], [warn], [error] labels while the lib uses [INFO], [OK], [WARN], [ERROR]. The lib’s labels will now be used. This is an intentional unification — the design doc specifies consistent output.
Step 1: Make the changes Step 2: Verify syntax: bash -n bin/setup_aliases.sh Step 3: Verify flags work: bin/setup_aliases.sh --help and bin/setup_aliases.sh --list Step 4: Commit
git add bin/setup_aliases.sh
git commit -m "refactor: setup_aliases.sh sources bin/lib/ instead of inline helpers"
Task 6: Create deploy-systemd.sh
Files:
- Create:
bin/deploy-systemd.sh
Step 1: Create the script
#!/bin/bash
#
# systemd deployment script for server-maintanence
# Installs systemd units, runs migrations, and starts services.
# Assumes install.sh prod mode already handled .env and dependencies.
#
set -e
# Source shared libraries
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
source "$SCRIPT_DIR/lib/logging.sh"
source "$SCRIPT_DIR/lib/checks.sh"
source "$SCRIPT_DIR/lib/paths.sh"
UNIT_DIR="$PROJECT_DIR/deploy/systemd"
INSTALL_DIR="/opt/server-monitoring"
ENV_FILE="/etc/server-monitoring/env"
# ===========================================
# Pre-flight checks
# ===========================================
echo ""
echo "============================================"
echo " systemd Deployment — Pre-flight Checks"
echo "============================================"
echo ""
# Must be root
info "Checking privileges..."
if [ "$(id -u)" -ne 0 ]; then
error "This script must be run as root (or with sudo)."
exit 1
fi
success "Running as root"
# Project dir exists with .venv
info "Checking installation at $INSTALL_DIR..."
if [ ! -d "$INSTALL_DIR/.venv" ]; then
error "$INSTALL_DIR/.venv not found."
echo " Run install.sh in prod mode first to set up the project."
exit 1
fi
success "Installation found at $INSTALL_DIR"
# Environment file exists
info "Checking environment file..."
if [ ! -f "$ENV_FILE" ]; then
error "$ENV_FILE not found."
echo " Create it with your production environment variables."
echo " See: docs/Deployment.md"
exit 1
fi
success "Environment file found"
# Redis is running (check both unit names)
info "Checking Redis service..."
if systemctl is-active --quiet redis-server 2>/dev/null; then
success "Redis is running (redis-server)"
elif systemctl is-active --quiet redis 2>/dev/null; then
success "Redis is running (redis)"
else
error "Redis service is not running."
echo " Install and start Redis:"
echo " Debian/Ubuntu: sudo apt install redis-server && sudo systemctl enable --now redis-server"
echo " RHEL/Fedora: sudo dnf install redis && sudo systemctl enable --now redis"
exit 1
fi
echo ""
success "All pre-flight checks passed"
# ===========================================
# Deploy
# ===========================================
echo ""
echo "============================================"
echo " Deploying systemd services"
echo "============================================"
echo ""
# Copy unit files
info "Installing systemd unit files..."
cp "$UNIT_DIR/server-monitoring.service" /etc/systemd/system/
cp "$UNIT_DIR/server-monitoring-celery.service" /etc/systemd/system/
success "Unit files installed"
# Reload systemd
info "Reloading systemd daemon..."
systemctl daemon-reload
success "systemd reloaded"
# Run migrations and collectstatic as www-data
info "Running migrations..."
sudo -u www-data bash -c "cd $INSTALL_DIR && set -a && source $ENV_FILE && set +a && .venv/bin/python manage.py migrate --noinput"
success "Migrations applied"
info "Collecting static files..."
sudo -u www-data bash -c "cd $INSTALL_DIR && set -a && source $ENV_FILE && set +a && .venv/bin/python manage.py collectstatic --noinput"
success "Static files collected"
# Enable and start services
info "Enabling and starting services..."
systemctl enable --now server-monitoring server-monitoring-celery
success "Services enabled and started"
# ===========================================
# Health Verification
# ===========================================
echo ""
echo "============================================"
echo " Health Verification"
echo "============================================"
echo ""
info "Verifying service health (timeout: 60s)..."
TIMEOUT=60
INTERVAL=5
ELAPSED=0
WEB_OK=false
CELERY_OK=false
while [ "$ELAPSED" -lt "$TIMEOUT" ]; do
if [ "$WEB_OK" = false ]; then
if systemctl is-active --quiet server-monitoring 2>/dev/null; then
WEB_OK=true
success "server-monitoring — active"
fi
fi
if [ "$CELERY_OK" = false ]; then
if systemctl is-active --quiet server-monitoring-celery 2>/dev/null; then
CELERY_OK=true
success "server-monitoring-celery — active"
fi
fi
if [ "$WEB_OK" = true ] && [ "$CELERY_OK" = true ]; then
info "Both services active — confirming stability..."
sleep "$INTERVAL"
if ! systemctl is-active --quiet server-monitoring 2>/dev/null; then
WEB_OK=false
warn "server-monitoring was active but is now failing (crash loop detected)"
fi
if ! systemctl is-active --quiet server-monitoring-celery 2>/dev/null; then
CELERY_OK=false
warn "server-monitoring-celery was active but is now failing (crash loop detected)"
fi
if [ "$WEB_OK" = true ] && [ "$CELERY_OK" = true ]; then
break
fi
fi
sleep "$INTERVAL"
ELAPSED=$((ELAPSED + INTERVAL))
done
# Report failures
FAILED=false
if [ "$WEB_OK" = false ]; then
error "server-monitoring failed to start. Check: journalctl -u server-monitoring"
FAILED=true
fi
if [ "$CELERY_OK" = false ]; then
error "server-monitoring-celery failed to start. Check: journalctl -u server-monitoring-celery"
FAILED=true
fi
if [ "$FAILED" = true ]; then
exit 1
fi
echo ""
success "All services are healthy"
# ===========================================
# Summary
# ===========================================
echo ""
echo "============================================"
printf " %b systemd Deployment Complete!%b\n" "$GREEN" "$NC"
echo "============================================"
echo ""
echo "Services:"
echo " - server-monitoring (gunicorn on unix socket)"
echo " - server-monitoring-celery (celery worker)"
echo ""
echo "Useful commands:"
echo " systemctl status server-monitoring"
echo " systemctl status server-monitoring-celery"
echo " journalctl -u server-monitoring -f"
echo " journalctl -u server-monitoring-celery -f"
echo " systemctl restart server-monitoring server-monitoring-celery"
echo ""
Step 2: Make executable
chmod +x bin/deploy-systemd.sh
Step 3: Verify syntax: bash -n bin/deploy-systemd.sh
Step 4: Commit
git add bin/deploy-systemd.sh
git commit -m "feat: add deploy-systemd.sh for automated systemd deployment"
Task 7: Add systemd prompt to install.sh prod mode
Files:
- Modify:
bin/install.sh
What to change:
After the aliases prompt block (currently near the end of the prod path), before success "Setup complete!", add:
# Offer systemd deployment (prod only)
if [ "$INSTALL_MODE" = "prod" ]; then
echo ""
read -p "Would you like to deploy with systemd now? [y/N] " -n 1 -r
echo ""
if [[ $REPLY =~ ^[Yy]$ ]]; then
info "Handing off to deploy-systemd.sh..."
echo " Note: This requires root privileges."
exec sudo "$SCRIPT_DIR/deploy-systemd.sh"
fi
fi
Step 1: Make the change Step 2: Verify syntax: bash -n bin/install.sh Step 3: Commit
git add bin/install.sh
git commit -m "feat: add systemd deployment prompt to install.sh prod mode"
Task 8: Add smoke tests for all scripts
Files:
- Create:
bin/tests/test_install.bats - Create:
bin/tests/test_deploy_docker.bats - Create:
bin/tests/test_deploy_systemd.bats - Create:
bin/tests/test_setup_cron.bats - Create:
bin/tests/test_setup_aliases.bats - Create:
bin/tests/test_check_system.bats
Step 1: Create all smoke tests
bin/tests/test_install.bats:
#!/usr/bin/env bats
setup() {
load 'test_helper/common-setup'
_common_setup
}
@test "install.sh passes syntax check" {
run bash -n "$BIN_DIR/install.sh"
assert_success
}
@test "install.sh exists and is executable" {
[ -x "$BIN_DIR/install.sh" ]
}
bin/tests/test_deploy_docker.bats:
#!/usr/bin/env bats
setup() {
load 'test_helper/common-setup'
_common_setup
}
@test "deploy-docker.sh passes syntax check" {
run bash -n "$BIN_DIR/deploy-docker.sh"
assert_success
}
@test "deploy-docker.sh exits 1 without .env" {
local tmpdir
tmpdir="$(mktemp -d)"
# Override PROJECT_DIR so .env won't be found
run bash -c 'export PROJECT_DIR="'"$tmpdir"'" && source "'"$BIN_DIR/lib/paths.sh"'" && bash "'"$BIN_DIR/deploy-docker.sh"'"'
rm -rf "$tmpdir"
assert_failure
}
bin/tests/test_deploy_systemd.bats:
#!/usr/bin/env bats
setup() {
load 'test_helper/common-setup'
_common_setup
}
@test "deploy-systemd.sh passes syntax check" {
run bash -n "$BIN_DIR/deploy-systemd.sh"
assert_success
}
@test "deploy-systemd.sh exits 1 when not root" {
if [ "$(id -u)" -eq 0 ]; then
skip "Running as root, cannot test non-root failure"
fi
run bash "$BIN_DIR/deploy-systemd.sh"
assert_failure
assert_output --partial "root"
}
bin/tests/test_setup_cron.bats:
#!/usr/bin/env bats
setup() {
load 'test_helper/common-setup'
_common_setup
}
@test "setup_cron.sh passes syntax check" {
run bash -n "$BIN_DIR/setup_cron.sh"
assert_success
}
bin/tests/test_setup_aliases.bats:
#!/usr/bin/env bats
setup() {
load 'test_helper/common-setup'
_common_setup
}
@test "setup_aliases.sh passes syntax check" {
run bash -n "$BIN_DIR/setup_aliases.sh"
assert_success
}
@test "setup_aliases.sh --help shows usage" {
run "$BIN_DIR/setup_aliases.sh" --help
assert_success
assert_output --partial "Usage"
}
@test "setup_aliases.sh --list without aliases file warns" {
# Ensure no aliases file exists
rm -f "$BIN_DIR/aliases.sh"
run "$BIN_DIR/setup_aliases.sh" --list
assert_failure
assert_output --partial "No aliases file"
}
bin/tests/test_check_system.bats:
#!/usr/bin/env bats
setup() {
load 'test_helper/common-setup'
_common_setup
}
@test "check_system.sh passes syntax check" {
run bash -n "$BIN_DIR/check_system.sh"
assert_success
}
@test "check_system.sh --help shows usage" {
run "$BIN_DIR/check_system.sh" --help
assert_success
assert_output --partial "Usage"
assert_output --partial "--shell-only"
}
Step 2: Update CI to also run smoke tests
Modify .github/workflows/ci.yml — change the BATS run command from:
run: ./bin/tests/test_helper/bats-core/bin/bats bin/tests/lib/
to:
run: ./bin/tests/test_helper/bats-core/bin/bats bin/tests/lib/ bin/tests/
This runs both unit tests (bin/tests/lib/) and smoke tests (bin/tests/).
Step 3: Run all tests
Run: ./bin/tests/test_helper/bats-core/bin/bats bin/tests/lib/ bin/tests/ Expected: All tests pass (36 unit + ~12 smoke)
Step 4: Commit
git add bin/tests/ .github/workflows/ci.yml
git commit -m "test: add smoke tests for all bin/ scripts"
Task 9: Update docs
Files:
- Modify:
docs/Deployment.md - Modify:
bin/README.md
Step 1: Update Deployment.md
In the systemd section (after “### 2.5 Install systemd units”), add the same kind of quick-start note used for Docker:
Quick start: Run
./bin/install.shin prod mode — it offers to deploy systemd units automatically at the end.
Also add after the manual systemctl enable --now step:
Or run
sudo ./bin/deploy-systemd.shdirectly if.envand dependencies are already set up.
Step 2: Update bin/README.md
Add deploy-systemd.sh to the scripts reference, described as: “systemd deployment — installs unit files, runs migrations, starts and verifies services. Called by install.sh (prod mode) or run standalone with sudo.”
Step 3: Commit
git add docs/Deployment.md bin/README.md
git commit -m "docs: add deploy-systemd.sh to Deployment.md and bin/README.md"