Files
hermes-ui/deploy.sh
T
Jack 90c759c760
Build / build (push) Canceled after 0s
Playwright / e2e (push) Canceled after 0s
add a one-click deploy script for the self-hosted build
2026-10-21 17:53:25 +08:00

333 lines
11 KiB
Bash
Executable File
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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)"
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
HEALTH_URL="http://127.0.0.1:$PORT/health"
AUTH_URL="http://127.0.0.1:$PORT/api/auth/status"
# --- 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
# --- 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 -----------------------------------------------------------------
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 ! git fetch --quiet origin main; 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)"
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" <<EOF
[Unit]
Description=Hermes UI Web Dashboard (self-hosted Vue + Node, port $PORT)
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
User=root
WorkingDirectory=$APP_DIR
ExecStart=$node_bin $APP_DIR/dist/server/index.js
Restart=always
RestartSec=5
TimeoutStopSec=30
Environment=HERMES_HOME=/root/.hermes
Environment=HOME=/root
Environment=NODE_ENV=production
Environment=PORT=$PORT
EOF
if [ -n "$hermes_bin" ]; then
echo "Environment=HERMES_BIN=$hermes_bin" >> "$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
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"