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 test:e2e` runs Playwright browser tests against a mocked BFF API.
|
||||||
- `npm run build` type-checks and builds both client and server.
|
- `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
|
## Platform Notes
|
||||||
|
|
||||||
The server runs on Windows, macOS, and Linux. The parts that differ are
|
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