add a one-click deploy script for the self-hosted build
Build / build (push) Canceled after 0s
Playwright / e2e (push) Canceled after 0s

This commit is contained in:
Jack
2026-10-21 17:53:25 +08:00
parent 421ffa0714
commit 90c759c760
2 changed files with 353 additions and 0 deletions
+21
View File
@@ -33,6 +33,27 @@ npm run build
- `npm run test:e2e` runs Playwright browser tests against a mocked BFF API.
- `npm run build` type-checks and builds both client and server.
## One-Click Deploy
`deploy.sh` deploys a checkout to a host that runs the built server under
systemd:
```bash
./deploy.sh # pull, install, build, restart, verify
./deploy.sh --dry-run # print the plan and exit
./deploy.sh --help # all options
```
It pulls `origin/main` with `--ff-only`, reinstalls dependencies only when
`package-lock.json` changed, rebuilds the `node-pty` native module, builds the
client and the server, restarts the `hermes-ui.service` unit, and verifies the
result against both `/health` and `/api/auth/status`. The previous `dist/` is
backed up to `~/.local/state/hermes-ui-deploy/backups/` first and restored
automatically when the build or the health check fails.
`start.sh` is the development launcher (foreground Vite + Koa); `deploy.sh` is
the production path. Do not mix them.
## Platform Notes
The server runs on Windows, macOS, and Linux. The parts that differ are
Executable
+332
View File
@@ -0,0 +1,332 @@
#!/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"