#!/usr/bin/env bash # # One-click deploy for hermes-ui. # # Pulls the latest source, installs dependencies, builds the production # bundles, restarts the systemd service, and verifies the result. # # ./deploy.sh # full deploy # ./deploy.sh --skip-pull # build what is already checked out # ./deploy.sh --skip-deps # reuse the installed node_modules # ./deploy.sh --skip-build # only restart the service # ./deploy.sh --no-restart # build only, do not touch the running service # ./deploy.sh --dry-run # print the plan and exit # # The script is idempotent: running it twice in a row is safe, and the second # run is a no-op except for the build and the restart. # # Environment overrides: # HERMES_UI_SERVICE systemd unit name (default hermes-ui.service) # HERMES_UI_PORT port the service listens on (default 8648) # HERMES_UI_STATE_DIR backups and state files (default ~/.local/state/hermes-ui-deploy) # HERMES_UI_NODE node binary to run (default the one on PATH) # set -euo pipefail APP_DIR="$(cd "$(dirname "$0")" && pwd)" SCRIPT_PATH="$APP_DIR/$(basename "$0")" cd "$APP_DIR" SERVICE="${HERMES_UI_SERVICE:-hermes-ui.service}" PORT="${HERMES_UI_PORT:-8648}" STATE_DIR="${HERMES_UI_STATE_DIR:-$HOME/.local/state/hermes-ui-deploy}" BACKUP_DIR="$STATE_DIR/backups" LOCK_HASH_FILE="$STATE_DIR/lockfile.sha256" KEEP_BACKUPS=3 # gitea is not directly reachable from every host; when a plain fetch fails # the script retries through the local mihomo proxy. GIT_PROXY="${HERMES_UI_GIT_PROXY:-http://127.0.0.1:7890}" HEALTH_URL="http://127.0.0.1:$PORT/health" AUTH_URL="http://127.0.0.1:$PORT/api/auth/status" CLI_URL="http://127.0.0.1:$PORT/api/hermes/cli-status?refresh=1" # --- Output helpers ------------------------------------------------------- if [ -t 1 ]; then BOLD="$(printf '\033[1m%s\033[0m\n')" BLUE="$(printf '\033[36m%s\033[0m\n')" GREEN="$(printf '\033[32m%s\033[0m\n')" YELLOW="$(printf '\033[33m%s\033[0m\n')" RED="$(printf '\033[31m%s\033[0m\n')" else BOLD="%s\n"; BLUE="%s\n"; GREEN="%s\n"; YELLOW="%s\n"; RED="%s\n" fi step() { printf "$BLUE" "› $1"; } ok() { printf "$GREEN" " ✓ $1"; } warn() { printf "$YELLOW" " ! $1"; } die() { printf "$RED" " ✗ $1" >&2; exit 1; } usage() { cat <<'EOF' One-click deploy for hermes-ui. Pulls the latest source, installs dependencies, builds the production bundles, restarts the systemd service, and verifies the result. ./deploy.sh # full deploy ./deploy.sh --skip-pull # build what is already checked out ./deploy.sh --skip-deps # reuse the installed node_modules ./deploy.sh --skip-build # only restart the service ./deploy.sh --no-restart # build only, do not touch the running service ./deploy.sh --dry-run # print the plan and exit The script is idempotent: running it twice in a row is safe, and the second run is a no-op except for the build and the restart. Environment overrides: HERMES_UI_SERVICE systemd unit name (default hermes-ui.service) HERMES_UI_PORT port the service listens on (default 8648) HERMES_UI_STATE_DIR backups and state files (default ~/.local/state/hermes-ui-deploy) HERMES_UI_NODE node binary to run (default the one on PATH) EOF } # --- Arguments ------------------------------------------------------------ SKIP_PULL=0 SKIP_DEPS=0 SKIP_BUILD=0 NO_RESTART=0 DRY_RUN=0 while [ $# -gt 0 ]; do case "$1" in --skip-pull) SKIP_PULL=1 ;; --skip-deps) SKIP_DEPS=1 ;; --skip-build) SKIP_BUILD=1 ;; --no-restart) NO_RESTART=1 ;; --dry-run) DRY_RUN=1 ;; -h|--help) usage exit 0 ;; *) die "unknown option: $1 (try --help)" ;; esac shift done # --- Escape the service cgroup -------------------------------------------- # # This script restarts $SERVICE. When it is launched from inside that # service's cgroup — e.g. from the agent bridge that the web UI itself # runs — the restart SIGTERMs the whole cgroup, including this script. # The build succeeds, the service comes back, but the script dies before it # can run the post-restart health checks. # # Re-executing under a transient systemd scope moves the script into its # own cgroup, so the restart no longer takes it down with it. A script # started from a normal shell is not affected. if [ "${HERMES_UI_DEPLOY_SCOPED:-0}" != "1" ] \ && [ -r /proc/self/cgroup ] \ && grep -q "$SERVICE" /proc/self/cgroup 2>/dev/null; then if command -v systemd-run >/dev/null 2>&1; then scope_unit="hermes-ui-deploy-$(date +%s)-$$" export HERMES_UI_DEPLOY_SCOPED=1 echo "Running inside the $SERVICE cgroup — re-executing in scope $scope_unit" exec systemd-run --scope --quiet --unit="$scope_unit" "$SCRIPT_PATH" "$@" else warn "systemd-run not found — the restart may kill this script early." fi fi # --- Preflight ------------------------------------------------------------ step "Preflight" command -v node >/dev/null 2>&1 || die "node not found on PATH. Install Node.js >= 23." node_version="$(node -v)" node_major="${node_version#v}"; node_major="${node_major%%.*}" [ "$node_major" -ge 23 ] || die "Node.js >= 23 required, found $node_version." command -v npm >/dev/null 2>&1 || die "npm not found on PATH." command -v git >/dev/null 2>&1 || die "git not found on PATH." command -v curl >/dev/null 2>&1 || die "curl not found on PATH." ok "node $node_version, npm $(npm -v)" [ -f package.json ] || die "$APP_DIR does not look like the hermes-ui repository." [ -d packages/server/src ] || die "$APP_DIR is missing packages/server/src." mkdir -p "$BACKUP_DIR" # --- Plan ----------------------------------------------------------------- if [ "$DRY_RUN" -eq 1 ]; then step "Dry run — nothing will be changed" echo " repo $APP_DIR" echo " service $SERVICE" echo " port $PORT" echo " pull $([ "$SKIP_PULL" -eq 1 ] && echo no || echo yes)" echo " deps $([ "$SKIP_DEPS" -eq 1 ] && echo no || echo yes)" echo " build $([ "$SKIP_BUILD" -eq 1 ] && echo no || echo yes)" echo " restart $([ "$NO_RESTART" -eq 1 ] && echo no || echo yes)" exit 0 fi # --- Pull ----------------------------------------------------------------- # # The deploy must always end up building the newest code on origin/main. # Two things get in the way of that: # # * The branch diverged — the operator pushed from a dev machine while # this host also had local commits. A merge commit would be recorded # for every deploy; a rebase replays the local commits on top of # origin/main and keeps the history linear. # * origin is unreachable directly — gitea sits behind the network, so # the fetch has to go through the local proxy. # # If the rebase hits a conflict the script stops instead of deploying a # half-merged tree. fetch_origin() { if git fetch --quiet origin main 2>/dev/null; then return 0 fi if [ -n "$GIT_PROXY" ] && git -c "http.proxy=$GIT_PROXY" -c "https.proxy=$GIT_PROXY" \ fetch --quiet origin main 2>/dev/null; then return 0 fi return 1 } if [ "$SKIP_PULL" -eq 1 ]; then step "Skipping git pull (--skip-pull)" else step "Fetching the latest source" if ! git diff --quiet || ! git diff --cached --quiet; then warn "Tracked files have local changes; skipping git pull to avoid a conflict." elif ! git rev-parse --abbrev-ref HEAD | grep -qx 'main'; then warn "Not on the main branch ($(git rev-parse --abbrev-ref HEAD)); skipping git pull." else # A failed fetch must not block a deploy: the point of the script is to # build and restart what is already checked out. if ! fetch_origin; then warn "Could not reach origin — continuing with the local checkout." elif git merge --ff-only --quiet origin/main; then ok "Updated to $(git rev-parse --short HEAD)" elif [ -n "$(git rev-list --count origin/main..HEAD)" ]; then # Diverged with local commits on top — rebase them onto origin/main. step "Local commits found, rebasing onto origin/main" if git rebase origin/main; then ok "Rebased onto $(git rev-parse --short origin/main), now at $(git rev-parse --short HEAD)" else git rebase --abort || true die "Rebase onto origin/main hit a conflict. Resolve it by hand and re-run." fi else die "Fast-forward to origin/main failed. Resolve the branch by hand and re-run." fi fi fi # --- Dependencies --------------------------------------------------------- # # `npm ci --ignore-scripts` installs exactly what package-lock.json pins, but # it also skips every install script — and node-pty's install script is what # puts the loadable native module in place. On Linux node-pty ships no # prebuilt binary, so without `npm rebuild node-pty` the terminal feature is # silently disabled. The rebuild is therefore mandatory, not optional. if [ "$SKIP_DEPS" -eq 1 ]; then step "Skipping dependency install (--skip-deps)" else step "Installing dependencies" current_lock_hash="$(sha256sum package-lock.json | cut -d' ' -f1)" stored_lock_hash="" if [ -f "$LOCK_HASH_FILE" ]; then stored_lock_hash="$(cat "$LOCK_HASH_FILE")" fi if [ ! -d node_modules ]; then warn "node_modules is missing — running npm ci (this takes a few minutes)." npm ci --ignore-scripts elif [ "$current_lock_hash" != "$stored_lock_hash" ]; then warn "package-lock.json changed since the last deploy — running npm ci." npm ci --ignore-scripts else ok "Dependencies are already in sync with package-lock.json." fi echo "$current_lock_hash" > "$LOCK_HASH_FILE" fi step "Rebuilding the node-pty native module" npm rebuild node-pty >/dev/null 2>&1 || warn "node-pty rebuild failed — the terminal feature may not work." if node -e "require('node-pty')" >/dev/null 2>&1; then ok "node-pty native module loads" else warn "node-pty still does not load — the terminal feature will be disabled." fi # --- Backup --------------------------------------------------------------- BACKUP_PATH="" if [ "$SKIP_BUILD" -eq 0 ] && [ -d dist ]; then step "Backing up the current build" BACKUP_PATH="$BACKUP_DIR/dist-$(date +%Y%m%d-%H%M%S)" mkdir -p "$BACKUP_PATH" cp -a dist/. "$BACKUP_PATH/" ok "Saved to $BACKUP_PATH" # Keep only the newest few backups; the oldest ones are worthless once a # newer deploy has succeeded. ( cd "$BACKUP_DIR" && ls -1dt dist-* 2>/dev/null | tail -n +"$((KEEP_BACKUPS + 1))" | xargs -r rm -rf ) fi # --- Build ---------------------------------------------------------------- if [ "$SKIP_BUILD" -eq 1 ]; then step "Skipping the build (--skip-build)" else step "Building the client and the server" if npm run build; then ok "Build finished" else warn "The build failed." if [ -n "$BACKUP_PATH" ]; then warn "Restoring the previous build from $BACKUP_PATH" rm -rf dist mkdir -p dist cp -a "$BACKUP_PATH/." dist/ ok "Previous build restored" fi die "Build failed. The running service was not restarted." fi fi # --- Service unit --------------------------------------------------------- install_unit() { local unit_path="/etc/systemd/system/$SERVICE" local node_bin="${HERMES_UI_NODE:-$(command -v node)}" local hermes_bin="/root/.local/bin/hermes" [ -x "$hermes_bin" ] || hermes_bin="$(command -v hermes || true)" step "Installing the systemd unit $SERVICE" cat > "$unit_path" <> "$unit_path" fi echo "StandardOutput=journal" >> "$unit_path" echo "StandardError=journal" >> "$unit_path" echo "SyslogIdentifier=hermes-ui" >> "$unit_path" echo "" >> "$unit_path" echo "[Install]" >> "$unit_path" echo "WantedBy=multi-user.target" >> "$unit_path" systemctl daemon-reload systemctl enable "$SERVICE" >/dev/null 2>&1 || true ok "Unit written to $unit_path" } if [ "$NO_RESTART" -eq 1 ]; then step "Skipping the service restart (--no-restart)" ok "Done. The running service was left untouched." exit 0 fi if ! systemctl cat "$SERVICE" >/dev/null 2>&1; then install_unit fi # --- Restart -------------------------------------------------------------- step "Restarting $SERVICE" systemctl restart "$SERVICE" step "Waiting for the service to answer" ready=0 for _ in $(seq 1 60); do if [ "$(curl -s -o /dev/null -w '%{http_code}' "$HEALTH_URL" 2>/dev/null)" = "200" ]; then ready=1 break fi sleep 1 done if [ "$ready" -ne 1 ]; then warn "The service did not become healthy in time." if [ -n "$BACKUP_PATH" ]; then warn "Restoring the previous build from $BACKUP_PATH" rm -rf dist mkdir -p dist cp -a "$BACKUP_PATH/." dist/ systemctl restart "$SERVICE" sleep 5 ok "Previous build restored and the service restarted" fi journalctl -u "$SERVICE" -n 30 --no-pager || true die "Deploy failed: $HEALTH_URL did not return 200." fi # A 200 on /health only proves the main process is up. /api/auth/status also # exercises the agent bridge and the auth chain, so both have to pass before # the deploy counts as good. auth_code="$(curl -s -o /dev/null -w '%{http_code}' "$AUTH_URL" 2>/dev/null || true)" if [ "$auth_code" != "200" ]; then warn "$AUTH_URL returned '$auth_code' — the agent bridge or the auth chain is not healthy." journalctl -u "$SERVICE" -n 30 --no-pager || true die "Deploy finished but the second health check failed." fi # Probe from the running service so its user, PATH and HERMES_BIN are authoritative. step "Checking Hermes CLI in the service environment" if ! cli_status="$(curl -fsS --connect-timeout 5 --max-time 25 "$CLI_URL")"; then die "Deploy finished but the Hermes CLI probe could not be reached. Check the service logs." fi if ! node -e ' const result = JSON.parse(process.argv[1]); if (result.available !== true) { console.error("Hermes CLI check failed: " + (result.reason || "unknown") + " (" + (result.bin || "hermes") + ")"); process.exit(1); } ' "$cli_status"; then die "Deploy finished but Hermes CLI is not ready. Check HERMES_BIN and the service logs." fi ok "Hermes CLI is executable by the service" ok "health $HEALTH_URL -> 200" ok "auth $AUTH_URL -> 200" ok "Deployed $(git rev-parse --short HEAD 2>/dev/null || echo 'unknown commit') on port $PORT"