add a one-click deploy script for the self-hosted build
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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"
|
||||
Reference in New Issue
Block a user