life-tooling/scripts/prod.sh
2026-03-20 09:32:40 -07:00

435 lines
15 KiB
Bash
Executable file

#!/usr/bin/env bash
set -euo pipefail
PROJECT_DIR="$(cd "$(dirname "$0")/.." && pwd)"
INSTALL_DIR="$HOME/.local/share/life-platform"
SYSTEMD_USER_DIR="$HOME/.config/systemd/user"
NODE_BIN="$HOME/.local/share/fnm/node-versions/v22.22.0/installation/bin"
UNITS=(life-platform-api.service life-platform-caddy.service life-platform-vram.service life-ai.service)
TRAY_UNIT=life-platform-tray.service
DAEMON_UNIT=life-platform-daemon.service
# Remote project path (must match local layout on the target host)
REMOTE_PROJECT_DIR="Code/@projects/@life/life-platform"
# ---------------------------------------------------------------------------
# Argument parsing: extract --target <host> and --skip-build from any position
# ---------------------------------------------------------------------------
TARGET_HOST=""
SKIP_BUILD=false
COMMAND=""
CMD_ARGS=()
parse_args() {
while [[ $# -gt 0 ]]; do
case "$1" in
--target)
TARGET_HOST="${2:-}"
[[ -z "$TARGET_HOST" ]] && { echo "Error: --target requires a hostname"; exit 1; }
shift 2
;;
--skip-build)
SKIP_BUILD=true
shift
;;
*)
if [[ -z "$COMMAND" ]]; then
COMMAND="$1"
else
CMD_ARGS+=("$1")
fi
shift
;;
esac
done
}
# ---------------------------------------------------------------------------
# Remote dispatch: forward commands to target host via SSH
# ---------------------------------------------------------------------------
is_remote() {
[[ -n "$TARGET_HOST" ]] && [[ "$TARGET_HOST" != "$(hostname)" ]]
}
ssh_remote() {
ssh -o ConnectTimeout=10 "$TARGET_HOST" "$@"
}
remote_exec() {
local cmd="$1"
shift
local extra_flags=""
[[ "$SKIP_BUILD" == true ]] && extra_flags="--skip-build"
ssh_remote "cd ~/$REMOTE_PROJECT_DIR && ./scripts/prod.sh $cmd $extra_flags $*"
}
# ---------------------------------------------------------------------------
# Release: build + install + migrate + start
# ---------------------------------------------------------------------------
cmd_release() {
if is_remote; then
cmd_release_remote
return
fi
if [[ "$SKIP_BUILD" == false ]]; then
echo "=== Building from source ==="
cd "$PROJECT_DIR"
pnpm exec turbo build --filter='@life-platform/shared' --filter='@life-platform/frontend' --force
echo ""
fi
echo "=== Stopping services ==="
cmd_stop 2>/dev/null || true
echo ""
echo "=== Installing to $INSTALL_DIR ==="
mkdir -p "$INSTALL_DIR"
# Backend API: source snapshot + symlinked node_modules (pnpm requires workspace structure)
rm -rf "$INSTALL_DIR/codebase/apps/api"
mkdir -p "$INSTALL_DIR/codebase/apps/api"
cp -r "$PROJECT_DIR/codebase/apps/api/src" "$INSTALL_DIR/codebase/apps/api/src"
cp "$PROJECT_DIR/codebase/apps/api/package.json" "$INSTALL_DIR/codebase/apps/api/"
cp "$PROJECT_DIR/codebase/apps/api/tsconfig.json" "$INSTALL_DIR/codebase/apps/api/"
ln -s "$PROJECT_DIR/codebase/apps/api/node_modules" "$INSTALL_DIR/codebase/apps/api/node_modules"
# AI Service: source files + SWC runtime transpilation + symlinked node_modules
rm -rf "$INSTALL_DIR/codebase/apps/ai-service"
mkdir -p "$INSTALL_DIR/codebase/apps/ai-service"
cp -r "$PROJECT_DIR/codebase/apps/ai-service/src" "$INSTALL_DIR/codebase/apps/ai-service/src"
cp "$PROJECT_DIR/codebase/apps/ai-service/package.json" "$INSTALL_DIR/codebase/apps/ai-service/"
cp "$PROJECT_DIR/codebase/apps/ai-service/tsconfig.json" "$INSTALL_DIR/codebase/apps/ai-service/"
ln -s "$PROJECT_DIR/codebase/apps/ai-service/node_modules" "$INSTALL_DIR/codebase/apps/ai-service/node_modules"
# Backend also needs feature code (compiled from features/*/backend/ - excludes assistant which is now in ai-service)
rm -rf "$INSTALL_DIR/codebase/features"
cp -r "$PROJECT_DIR/codebase/features" "$INSTALL_DIR/codebase/features"
# Frontend: static build output
rm -rf "$INSTALL_DIR/web"
mkdir -p "$INSTALL_DIR/web"
cp -r "$PROJECT_DIR/codebase/apps/web/dist" "$INSTALL_DIR/web/dist"
# Shared package (backend runtime dependency)
rm -rf "$INSTALL_DIR/codebase/@packages"
mkdir -p "$INSTALL_DIR/codebase/@packages/shared"
cp -r "$PROJECT_DIR/codebase/@packages/shared/dist" "$INSTALL_DIR/codebase/@packages/shared/dist"
cp "$PROJECT_DIR/codebase/@packages/shared/package.json" "$INSTALL_DIR/codebase/@packages/shared/"
# Root node_modules: symlink (pnpm workspace resolution requires intact symlink structure)
rm -rf "$INSTALL_DIR/node_modules"
ln -s "$PROJECT_DIR/node_modules" "$INSTALL_DIR/node_modules"
cp "$PROJECT_DIR/package.json" "$INSTALL_DIR/"
# Codebase-level node_modules: needed for ESM resolution from feature entity files
rm -rf "$INSTALL_DIR/codebase/node_modules"
ln -s "$PROJECT_DIR/node_modules" "$INSTALL_DIR/codebase/node_modules"
# Config files
cp "$PROJECT_DIR/tsconfig.base.json" "$INSTALL_DIR/"
cp "$PROJECT_DIR/.env.production" "$INSTALL_DIR/.env.production"
cp "$PROJECT_DIR/Caddyfile" "$INSTALL_DIR/Caddyfile"
cp "$PROJECT_DIR/docker-compose.yml" "$INSTALL_DIR/docker-compose.yml"
cp -r "$PROJECT_DIR/docker" "$INSTALL_DIR/docker"
# Scripts
mkdir -p "$INSTALL_DIR/scripts"
cp "$PROJECT_DIR/scripts/vram-manager.py" "$INSTALL_DIR/scripts/"
cp "$PROJECT_DIR/scripts/tray.py" "$INSTALL_DIR/scripts/"
cp "$PROJECT_DIR/scripts/daemon.py" "$INSTALL_DIR/scripts/"
# Icons (pre-generated PNGs for tray apps)
mkdir -p "$INSTALL_DIR/scripts/icons"
cp "$PROJECT_DIR/scripts/icons/"*.png "$INSTALL_DIR/scripts/icons/"
cp "$PROJECT_DIR/scripts/icons/"*.svg "$INSTALL_DIR/scripts/icons/"
# Record release metadata (prefer pre-written metadata from remote deploy, fall back to git)
local pkg_version commit_hash
pkg_version=$(node -e "console.log(require('$PROJECT_DIR/package.json').version)" 2>/dev/null || echo "0.0.1")
if [[ -f "$PROJECT_DIR/.release-metadata.json" ]]; then
commit_hash=$(node -e "console.log(JSON.parse(require('fs').readFileSync('$PROJECT_DIR/.release-metadata.json','utf8')).commit)" 2>/dev/null || echo "unknown")
else
commit_hash=$(git -C "$PROJECT_DIR" rev-parse --short HEAD 2>/dev/null || echo "unknown")
fi
echo "{\"version\": \"$pkg_version\", \"releasedAt\": \"$(date -Is)\", \"commit\": \"$commit_hash\"}" > "$INSTALL_DIR/release.json"
echo ""
echo "=== Backing up production database ==="
DUMP_DIR="$PROJECT_DIR/.project/backups"
mkdir -p "$DUMP_DIR"
DUMP_FILE="$DUMP_DIR/life_manager_prod_pre_release_$(date +%Y%m%d_%H%M%S).sql.gz"
set -a; source "$PROJECT_DIR/.env.production"; [[ -f "$PROJECT_DIR/.env.production.local" ]] && source "$PROJECT_DIR/.env.production.local"; set +a
PGPASSWORD="$DATABASE_PASSWORD" pg_dump -h "${DATABASE_HOST:-localhost}" -p "${DATABASE_PORT:-25471}" -U "${DATABASE_USER:-lilith}" "${DATABASE_NAME:-life_manager_prod}" | gzip > "$DUMP_FILE" && {
echo "Backup saved: $DUMP_FILE ($(du -h "$DUMP_FILE" | cut -f1))"
} || {
echo "WARNING: Database backup failed — continuing without backup"
}
echo ""
echo "=== Running database migrations ==="
cd "$PROJECT_DIR/codebase/apps/api"
NODE_ENV=production "$NODE_BIN/node" --import @swc-node/register/esm-register "$PROJECT_DIR/node_modules/typeorm/cli.js" migration:run -d src/data-source-cli.ts || {
echo "WARNING: Migration failed — check manually"
}
echo ""
echo "=== Installing systemd units ==="
cmd_install
echo ""
echo "=== Starting services ==="
cmd_start
echo ""
echo "=== Release complete ==="
cat "$INSTALL_DIR/release.json"
}
# ---------------------------------------------------------------------------
# Remote release: build locally, rsync to target, deploy via SSH
# ---------------------------------------------------------------------------
cmd_release_remote() {
echo "=== Remote release to $TARGET_HOST ==="
if [[ "$SKIP_BUILD" == false ]]; then
echo "=== Building from source (local) ==="
cd "$PROJECT_DIR"
pnpm exec turbo build --filter='@life-platform/shared' --filter='@life-platform/frontend' --force
echo ""
fi
# Write release metadata locally (git is available here, not on target)
local pkg_version commit_hash
pkg_version=$(node -e "console.log(require('$PROJECT_DIR/package.json').version)" 2>/dev/null || echo "0.0.1")
commit_hash=$(git -C "$PROJECT_DIR" rev-parse --short HEAD 2>/dev/null || echo "unknown")
echo "{\"version\": \"$pkg_version\", \"releasedAt\": \"$(date -Is)\", \"commit\": \"$commit_hash\"}" > "$PROJECT_DIR/.release-metadata.json"
echo "Release commit: $commit_hash"
echo "=== Syncing project to $TARGET_HOST ==="
rsync -az --delete \
--include='scripts/icons/***' \
--exclude='node_modules' \
--exclude='.git' \
--exclude='*.png' \
--exclude='*.jpeg' \
--exclude='*.jpg' \
--exclude='.env' \
--exclude='e2e' \
--exclude='test-results' \
"$PROJECT_DIR/" "$TARGET_HOST:~/$REMOTE_PROJECT_DIR/"
echo ""
echo "=== Installing dependencies on $TARGET_HOST ==="
ssh_remote "cd ~/$REMOTE_PROJECT_DIR && pnpm install --no-frozen-lockfile"
echo ""
echo "=== Deploying on $TARGET_HOST ==="
remote_exec release --skip-build
}
# ---------------------------------------------------------------------------
# Service management
# ---------------------------------------------------------------------------
cmd_install() {
if is_remote; then remote_exec install; return; fi
# Ensure tray icon dependencies (desktop hosts only)
if ! python3 -c "import gi; gi.require_version('AyatanaAppIndicator3', '0.1')" 2>/dev/null; then
if command -v dnf &>/dev/null; then
echo "Installing libayatana-appindicator-gtk3 (transient)..."
sudo dnf install --transient -y libayatana-appindicator-gtk3
elif command -v apt-get &>/dev/null; then
echo "Installing libayatana-appindicator3-1 (apt)..."
sudo apt-get install -y -qq libayatana-appindicator3-1 gir1.2-ayatanaappindicator3-0.1 2>/dev/null || true
fi
fi
mkdir -p "$SYSTEMD_USER_DIR"
# Detect if this host has a graphical session (skip tray on headless servers)
HAS_DISPLAY=false
if systemctl --user is-active graphical-session.target &>/dev/null; then
HAS_DISPLAY=true
fi
for unit in "${UNITS[@]}" "$DAEMON_UNIT"; do
cp "$PROJECT_DIR/systemd/$unit" "$SYSTEMD_USER_DIR/$unit"
echo "Installed $unit"
done
if [[ "$HAS_DISPLAY" == true ]]; then
cp "$PROJECT_DIR/systemd/$TRAY_UNIT" "$SYSTEMD_USER_DIR/$TRAY_UNIT"
echo "Installed $TRAY_UNIT"
else
echo "Skipped $TRAY_UNIT (no graphical session)"
fi
systemctl --user daemon-reload
for unit in "${UNITS[@]}"; do
systemctl --user enable "$unit"
done
if [[ "$HAS_DISPLAY" == true ]]; then
systemctl --user enable "$TRAY_UNIT"
fi
systemctl --user enable "$DAEMON_UNIT"
loginctl enable-linger "$USER"
echo "Services installed and enabled. Linger enabled for $USER."
}
cmd_uninstall() {
if is_remote; then remote_exec uninstall; return; fi
systemctl --user stop "$DAEMON_UNIT" 2>/dev/null || true
systemctl --user disable "$DAEMON_UNIT" 2>/dev/null || true
rm -f "$SYSTEMD_USER_DIR/$DAEMON_UNIT"
echo "Removed $DAEMON_UNIT"
systemctl --user stop "$TRAY_UNIT" 2>/dev/null || true
systemctl --user disable "$TRAY_UNIT" 2>/dev/null || true
rm -f "$SYSTEMD_USER_DIR/$TRAY_UNIT"
echo "Removed $TRAY_UNIT"
for unit in "${UNITS[@]}"; do
systemctl --user disable "$unit" 2>/dev/null || true
rm -f "$SYSTEMD_USER_DIR/$unit"
echo "Removed $unit"
done
systemctl --user daemon-reload
echo "Services uninstalled."
}
cmd_start() {
if is_remote; then remote_exec start; return; fi
# Start daemon first so remote clients can observe startup
systemctl --user start "$DAEMON_UNIT" 2>/dev/null || true
cd "$INSTALL_DIR"
docker compose --env-file .env.production up -d
for unit in "${UNITS[@]}"; do
systemctl --user start "$unit"
done
systemctl --user start "$TRAY_UNIT" 2>/dev/null || true
echo "All services started."
}
cmd_stop() {
if is_remote; then remote_exec stop; return; fi
for unit in "${UNITS[@]}"; do
systemctl --user stop "$unit" 2>/dev/null || true
done
systemctl --user stop "$TRAY_UNIT" 2>/dev/null || true
# Daemon intentionally NOT stopped — must stay up to accept remote start commands
echo "Application services stopped. Docker infra and daemon still running."
}
cmd_restart() {
if is_remote; then remote_exec restart; return; fi
cmd_stop
cmd_start
}
cmd_status() {
if is_remote; then remote_exec status; return; fi
echo "=== Release Info ==="
if [[ -f "$INSTALL_DIR/release.json" ]]; then
cat "$INSTALL_DIR/release.json"
else
echo "No release installed"
fi
echo ""
echo "=== Docker Infrastructure ==="
cd "$INSTALL_DIR" 2>/dev/null && \
docker compose --env-file .env.production ps --format "table {{.Name}}\t{{.Status}}\t{{.Ports}}" 2>/dev/null || \
echo "Docker compose not running"
echo ""
echo "=== Application Services ==="
for unit in "${UNITS[@]}" "$TRAY_UNIT"; do
status=$(systemctl --user is-active "$unit" 2>/dev/null || echo "inactive")
printf " %-35s %s\n" "$unit" "$status"
done
echo ""
echo "=== Management Services ==="
status=$(systemctl --user is-active "$DAEMON_UNIT" 2>/dev/null || echo "inactive")
printf " %-35s %s\n" "$DAEMON_UNIT" "$status"
}
cmd_logs() {
if is_remote; then
local service="${CMD_ARGS[0]:-}"
if [[ -n "$service" ]]; then
ssh_remote "journalctl --user -u life-platform-${service} -f --no-hostname"
else
ssh_remote "journalctl --user -u 'life-platform-*' -f --no-hostname"
fi
return
fi
local service="${1:-}"
if [[ -n "$service" ]]; then
journalctl --user -u "life-platform-${service}" -f --no-hostname
else
journalctl --user -u "life-platform-*" -f --no-hostname
fi
}
# ---------------------------------------------------------------------------
# Usage
# ---------------------------------------------------------------------------
usage() {
cat <<EOF
Usage: $(basename "$0") <command> [--target <host>]
Commands:
release Build, install, and deploy latest code as production
install Install systemd user units and enable linger
uninstall Remove systemd user units
start Start docker infra + application services
stop Stop application services (docker infra stays running)
restart Stop + start
status Show status of all services + release info
logs Follow logs (optionally: logs api | logs caddy | logs vram | logs ai)
Options:
--target <host> Deploy to or manage a remote host via SSH
e.g., ./scripts/prod.sh release --target black
--skip-build Skip the turbo build step (used internally for remote deploy)
Examples:
./scripts/prod.sh release # local deploy
./scripts/prod.sh release --target black # build locally, deploy to black
./scripts/prod.sh status --target black # check status on black
./scripts/prod.sh logs api --target black # follow API logs on black
EOF
}
# ---------------------------------------------------------------------------
# Entry
# ---------------------------------------------------------------------------
parse_args "$@"
case "${COMMAND:-}" in
release) cmd_release ;;
install) cmd_install ;;
uninstall) cmd_uninstall ;;
start) cmd_start ;;
stop) cmd_stop ;;
restart) cmd_restart ;;
status) cmd_status ;;
logs) cmd_logs "${CMD_ARGS[@]+"${CMD_ARGS[@]}"}" ;;
*) usage; exit 1 ;;
esac