413 lines
14 KiB
Bash
Executable File
413 lines
14 KiB
Bash
Executable File
#!/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:web; 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"
|