Files
toor a54913efd2
Build / build (push) Canceled after 0s
Playwright / e2e (push) Canceled after 0s
fix false missing Hermes CLI detection after deployment
2026-10-24 20:59:08 +08:00

413 lines
14 KiB
Bash
Executable File
Raw Permalink 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)"
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" <<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
# 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"