435 lines
15 KiB
Bash
Executable file
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
|