diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index 312309a..6dabc4d 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -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 diff --git a/deploy.sh b/deploy.sh new file mode 100755 index 0000000..f1a9674 --- /dev/null +++ b/deploy.sh @@ -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" <> "$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"