AkurAI Build
Menu

Public repository / d7eb6622a4697643ecf906b39e64b71c72e8a69b

AkurAI-EC2 / bin/akurai-ec2

Latest change d7eb6622a4697643ecf906b39e64b71c72e8a69b - fix: harden v2 manifest install and parsing by Claude Code

#!/usr/bin/env bash
# akurai-ec2 — reusable ops for the AkurAI AWS platform box (mail.olibuijr.com).
#
# One place for the things we kept re-deriving: SSH, file shipping, deploying a
# single-binary app behind nginx+systemd, TLS, and status. Designed to oneshot
# future deploys instead of guessing.
#
#   akurai-ec2 ssh '<cmd>'                 run a command on the box
#   akurai-ec2 ship <local> <remote>       scp a file up (creates parent dir)
#   akurai-ec2 status                       services, ports, nginx, disk
#   akurai-ec2 processes [pattern]          list the running AkurAI fleet: unit,
#   akurai-ec2 ps        [pattern]          state, PID, memory, uptime, port.
#                                           Optional pattern overrides the fleet
#                                           match (an egrep alternation).
#   akurai-ec2 deploy-binary <name> <bin> <port> [appdir]
#                                           push a single binary, scaffold/serve it
#                                           via systemd on 127.0.0.1:<port>.
#                                           Backs up the previous binary to
#                                           <name>.prev, health-gates the new one
#                                           (must answer HTTP on :<port>), and
#                                           auto-rolls-back if it doesn't.
#   akurai-ec2 nginx-proxy <domain> <port>  reverse-proxy vhost + reload; re-asserts
#                                           HTTPS if a cert already exists (so a
#                                           redeploy never drops TLS)
#   akurai-ec2 tls <domain>                 obtain/renew Let's Encrypt cert (certbot)
#   akurai-ec2 release [patch|minor|major]  unified release engine — reads the
#                                           repo's akurai-deploy.toml and runs the
#                                           whole release (gate → bump VERSION +
#                                           Cargo.toml → hybrid changelog → tag +
#                                           push → deploy-binary + nginx + tls).
#                                           Replaces each project's bespoke deploy.sh.
#     [publish|ec2]                          ship current binary, no version bump
#     [--dry-run]                            preview bump + changelog, ship nothing
#     [--changelog "text"]                   one-line changelog (skip the git draft)
#     [--yes] [-C <dir>]                     accept draft unattended / run in <dir>
#   akurai-ec2 rollback <name>              restore <name>.prev (the previous binary)
#   akurai-ec2 logs <service>               journalctl -u <service> -n 80
#   akurai-ec2 mail-autoconfig verify [domain...]
#                                           probe email client auto-setup for a domain:
#                                           Mozilla autoconfig (GET) + MS autodiscover
#                                           (POST, must not 405) + TLS host-match + SRV.
#                                           Defaults to olibuijr.com + golfsetridak.is.
#   akurai-ec2 ports [list]                 registry vs live: every port, who owns
#                                           it, and whether it's bound (the index)
#   akurai-ec2 ports free [n]               next n free port(s) in the app pool
#                                           (8094-8199). Claim it in the registry
#                                           BEFORE deploy-binary to avoid collisions.
#   akurai-ec2 ports check <port>           is <port> free? who holds it (registry+live)?
#   akurai-ec2 ports drift                  flag collisions, crash-loops, and nginx
#                                           vhosts pointing at the wrong upstream.
#   akurai-ec2 allow-ssh [ip]               re-authorize SSH (port 22) from an IP
#                                           (default: your current public IP). The
#                                           SG locks 22 to /32s; a dynamic-IP change
#                                           locks you out — this re-adds you.
set -euo pipefail

SSH_HOST="${AKURAI_EC2_SSH:-akurai-mail}"   # ~/.ssh/config alias
PUBLIC_IP="3.94.46.219"
# Intended port assignments (SSOT) — see the _VMPORTS skill. `ports` reads this.
PORTS_REGISTRY="${AKURAI_PORTS_REGISTRY:-$HOME/.claude/skills/_VMPORTS/ports.registry}"
APP_POOL_LO=8094
APP_POOL_HI=8199
SCRIPT_SOURCE="${BASH_SOURCE[0]}"
if command -v readlink >/dev/null 2>&1; then
  resolved_script="$(readlink -f "$SCRIPT_SOURCE" 2>/dev/null || true)"
  [ -n "$resolved_script" ] && SCRIPT_SOURCE="$resolved_script"
fi
SCRIPT_DIR="$(cd "$(dirname "$SCRIPT_SOURCE")" && pwd)"
MANIFEST_HELPER="$SCRIPT_DIR/../libexec/akurai-ec2-manifest.py"

declare -Ag RELEASE_MANIFEST=()
RELEASE_MANIFEST_KIND="flat"

ssh_run() { ssh -o ConnectTimeout=15 "$SSH_HOST" "$@"; }
say()     { printf '\033[1;34m▸ %s\033[0m\n' "$*"; }
die()     { printf '\033[1;31m✗ %s\033[0m\n' "$*" >&2; exit 1; }

release_load_manifest_flat() {
  local manifest_path="$1" ln t k v
  RELEASE_MANIFEST=()
  RELEASE_MANIFEST_KIND="flat"
  while IFS= read -r ln; do
    t="$(printf '%s' "$ln" | sed -E 's/^[[:space:]]+//')"
    case "$t" in \#*|"") continue ;; esac
    case "$ln" in *=*) ;; *) continue ;; esac
    k="$(printf '%s' "${ln%%=*}" | sed -E 's/^[[:space:]]+//; s/[[:space:]]+$//')"
    v="$(printf '%s' "${ln#*=}" | sed -E 's/[[:space:]]+#.*$//; s/^[[:space:]]+//; s/[[:space:]]+$//')"
    v="${v%\"}"; v="${v#\"}"
    RELEASE_MANIFEST["$k"]="$v"
  done < "$manifest_path"
}

release_load_manifest_toml() {
  local manifest_path="$1" helper_output key value
  local -A manifest_tmp=()
  [ -f "$MANIFEST_HELPER" ] || die "manifest helper missing: $MANIFEST_HELPER"
  command -v python3 >/dev/null 2>&1 || die "python3 is required for nested release manifests"
  if ! helper_output="$(python3 "$MANIFEST_HELPER" "$manifest_path")"; then
    die "manifest helper failed: $manifest_path"
  fi
  while IFS=$'\t' read -r key value; do
    [ -n "$key" ] || continue
    manifest_tmp["$key"]="$value"
  done <<< "$helper_output"
  RELEASE_MANIFEST=()
  RELEASE_MANIFEST_KIND="toml"
  for key in "${!manifest_tmp[@]}"; do
    RELEASE_MANIFEST["$key"]="${manifest_tmp["$key"]}"
  done
}

release_load_manifest() {
  local manifest_path="$1"
  if grep -qE '^[[:space:]]*\[' "$manifest_path"; then
    release_load_manifest_toml "$manifest_path"
  else
    release_load_manifest_flat "$manifest_path"
  fi
}

mf_get() {
  local key
  for key in "$@"; do
    if [[ -v RELEASE_MANIFEST["$key"] ]]; then
      printf '%s\n' "${RELEASE_MANIFEST["$key"]}"
      return 0
    fi
  done
  return 1
}

mf_get_bool() {
  local default_value="$1" value key_hint
  shift
  key_hint="${1:-value}"
  value="$(mf_get "$@" || true)"
  if [ -z "$value" ]; then
    printf '%s\n' "$default_value"
    return 0
  fi
  case "${value,,}" in
    1|true|yes|on) printf 'true\n' ;;
    0|false|no|off) printf 'false\n' ;;
    *) die "manifest: expected boolean for '$key_hint', got '$value'" ;;
  esac
}

mf_require() {
  local label="$1" value
  shift
  value="$(mf_get "$@" || true)"
  [ -n "$value" ] || die "manifest: '$label' required"
  printf '%s\n' "$value"
}

mf_get_string_array_lines() {
  local label="$1" value decoded
  shift
  value="$(mf_get "$@" || true)"
  [ -n "$value" ] || return 0
  if [[ "$value" != \[* ]]; then
    printf '%s\n' "$value"
    return 0
  fi
  command -v python3 >/dev/null 2>&1 || die "python3 is required for array manifest fields"
  if ! decoded="$(VALUE="$value" python3 - "$label" <<'PY'
import json
import os
import sys

label = sys.argv[1]
raw = os.environ["VALUE"]

try:
    data = json.loads(raw)
except json.JSONDecodeError as exc:
    print(f"manifest: invalid array for '{label}': {exc}", file=sys.stderr)
    raise SystemExit(1)

if not isinstance(data, list):
    print(f"manifest: expected array for '{label}'", file=sys.stderr)
    raise SystemExit(1)

for idx, item in enumerate(data):
    if not isinstance(item, str):
        print(
            f"manifest: '{label}' array item {idx + 1} must be a string",
            file=sys.stderr,
        )
        raise SystemExit(1)
    if any(ch in item for ch in ("\0", "\r", "\n")):
        print(
            f"manifest: '{label}' array item {idx + 1} contains unsupported control characters",
            file=sys.stderr,
        )
        raise SystemExit(1)
    print(item)
PY
)"; then
    die "manifest: invalid array field '$label'"
  fi
  printf '%s' "$decoded"
}

cmd="${1:-help}"; shift || true

case "$cmd" in
  ssh)
    ssh_run "$@"
    ;;

  ship)
    [ $# -eq 2 ] || die "usage: akurai-ec2 ship <local> <remote>"
    local_file="$1"; remote="$2"
    ssh_run "mkdir -p \"\$(dirname '$remote')\""
    scp -o ConnectTimeout=15 "$local_file" "$SSH_HOST:$remote"
    say "shipped $local_file → $remote"
    ;;

  status)
    ssh_run 'bash -s' <<'EOF'
echo "=== app services ==="
systemctl list-units --type=service --state=running | grep -iE 'akurai|golfset|mail|idp' || true
echo "=== listening ports ==="
sudo ss -tlnp 2>/dev/null | grep -E 'LISTEN' | awk '{print $4}' | sort -u | tail -20
echo "=== nginx vhosts ==="
ls /etc/nginx/sites-enabled/
echo "=== disk ==="
df -h / | tail -1
EOF
    ;;

  deploy-binary)
    # deploy-binary <name> <local-bin> <port> [appdir-to-upload]
    [ $# -ge 3 ] || die "usage: akurai-ec2 deploy-binary <name> <local-bin> <port> [appdir]"
    name="$1"; bin="$2"; port="$3"; appdir="${4:-}"
    base="/opt/$name"
    say "deploying '$name' on port $port"
    ssh_run "sudo mkdir -p $base/bin && sudo chown -R ubuntu:ubuntu $base"
    # Stop the service first — a running binary is busy and can't be overwritten.
    ssh_run "sudo systemctl stop $name.service 2>/dev/null || true"
    # Back up the currently-deployed binary before overwriting it — the rollback
    # target if the new build turns out unhealthy (see the health-gate below).
    ssh_run "[ -f $base/bin/$name ] && cp -p $base/bin/$name $base/bin/$name.prev || true"
    scp -o ConnectTimeout=15 "$bin" "$SSH_HOST:$base/bin/$name"
    ssh_run "chmod +x $base/bin/$name"
    if [ -n "$appdir" ]; then
      say "uploading app dir $appdir"
      ( cd "$appdir" && tar czf - . ) | ssh_run "mkdir -p $base/app && tar xzf - -C $base/app"
    fi
    say "installing systemd unit $name.service"
    # Optional overrides (set by `akurai-ec2 release` from the app manifest) let
    # apps that DON'T follow the Framework `serve --dir frontend` convention keep
    # their own ExecStart / EnvironmentFile / data dirs. All unset → the default
    # Framework unit below, so existing callers are unchanged.
    #   AKURAI_EXEC_START  custom ExecStart; tokens {bin}→$base/bin/$name,
    #                      {port}→$port, {app}→$base/app are expanded
    #   AKURAI_ENV_FILE    systemd EnvironmentFile= (must already exist on the box)
    #   AKURAI_DATA_DIR    space-separated dirs to create + chown ubuntu before start
    #   AKURAI_USER        systemd User= (default ubuntu) — for apps with a dedicated
    #                      service account; data dirs are chowned to it
    di_user="${AKURAI_USER:-ubuntu}"
    di_exec="${AKURAI_EXEC_START:-$base/bin/$name serve --dir $base/app/frontend --host 127.0.0.1 --port $port}"
    di_exec="${di_exec//\{bin\}/$base/bin/$name}"; di_exec="${di_exec//\{port\}/$port}"; di_exec="${di_exec//\{app\}/$base/app}"
    di_envline=""; if [ -n "${AKURAI_ENV_FILE:-}" ]; then di_envline="EnvironmentFile=${AKURAI_ENV_FILE}"; fi
    if [ -n "${AKURAI_DATA_DIR:-}" ]; then
      while IFS= read -r d || [ -n "$d" ]; do
        [ -n "$d" ] || continue
        ssh_run "sudo mkdir -p '$d' && sudo chown $di_user:$di_user '$d'" </dev/null
      done <<< "$AKURAI_DATA_DIR"
    fi
    ssh_run "sudo tee /etc/systemd/system/$name.service >/dev/null" <<EOF
[Unit]
Description=$name (AkurAI single-binary app)
After=network.target

[Service]
Type=simple
User=$di_user
WorkingDirectory=$base/app
${di_envline}
ExecStart=$di_exec
Restart=on-failure
RestartSec=2

[Install]
WantedBy=multi-user.target
EOF
    ssh_run "sudo systemctl daemon-reload && sudo systemctl enable --now $name.service && sleep 1 && systemctl is-active $name.service"
    # Health-gate: confirm the app actually answers HTTP on its port — 'active'
    # alone can't tell a serving app from one that crashes on first request. Any
    # HTTP status means it's serving; 000 = connection refused / crashed.
    health_ok=0; code=000
    for _ in 1 2 3 4 5 6; do
      code=$(ssh_run "curl -s -o /dev/null -w '%{http_code}' --max-time 4 http://127.0.0.1:$port/ 2>/dev/null || echo 000")
      [ "$code" != "000" ] && { health_ok=1; break; }
      sleep 2
    done
    if [ "$health_ok" = 1 ]; then
      say "health: $name answering on :$port (HTTP $code)"
    else
      say "$name failed its health check on :$port — auto-rolling back"
      if ssh_run "[ -f $base/bin/$name.prev ]"; then
        ssh_run "sudo systemctl stop $name.service; cp -p $base/bin/$name.prev $base/bin/$name; sudo systemctl start $name.service; sleep 2; systemctl is-active $name.service"
        die "rolled back $name to the previous binary (the new build was unhealthy on :$port)"
      else
        die "$name is unhealthy on :$port and there is no .prev backup to roll back to — investigate"
      fi
    fi
    say "deployed: $name on 127.0.0.1:$port"
    ;;

  nginx-proxy)
    [ $# -eq 2 ] || die "usage: akurai-ec2 nginx-proxy <domain> <port>"
    domain="$1"; port="$2"
    say "nginx reverse proxy: $domain → 127.0.0.1:$port"
    ssh_run "sudo tee /etc/nginx/sites-available/$domain >/dev/null" <<EOF
# Map the client's Upgrade header to the Connection value nginx must forward for
# a WebSocket handshake: "upgrade" when upgrading, "close" otherwise.
map \$http_upgrade \$connection_upgrade {
    default upgrade;
    ''      close;
}

server {
    listen 80;
    listen [::]:80;
    server_name $domain;

    location / {
        proxy_pass http://127.0.0.1:$port;
        proxy_http_version 1.1;
        proxy_set_header Host \$host;
        proxy_set_header X-Real-IP \$remote_addr;
        proxy_set_header X-Forwarded-For \$proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto \$scheme;

        # WebSocket upgrade + long-lived realtime streams (WS / SSE).
        proxy_set_header Upgrade \$http_upgrade;
        proxy_set_header Connection \$connection_upgrade;
        proxy_read_timeout 3600s;
        proxy_send_timeout 3600s;
        proxy_buffering off;          # stream SSE/WS frames immediately
    }
}
EOF
    ssh_run "sudo ln -sf /etc/nginx/sites-available/$domain /etc/nginx/sites-enabled/$domain && sudo nginx -t && sudo systemctl reload nginx"
    say "nginx vhost live for $domain (HTTP). Public IP: $PUBLIC_IP"
    # TLS idempotency: the HTTP vhost written above clobbers any certbot-managed
    # 443 block, which is why every redeploy used to silently drop HTTPS. If a
    # cert already exists for this domain, re-assert HTTPS so it never regresses.
    if ssh_run "sudo test -f /etc/letsencrypt/live/$domain/fullchain.pem"; then
      say "existing cert found — re-asserting HTTPS for $domain"
      ssh_run "sudo certbot --nginx -d $domain --non-interactive --agree-tos -m olibuijr@olibuijr.com --redirect >/dev/null 2>&1 && sudo nginx -t && sudo systemctl reload nginx"
      say "HTTPS re-asserted for $domain"
    fi
    ;;

  tls)
    [ $# -eq 1 ] || die "usage: akurai-ec2 tls <domain>"
    domain="$1"
    say "requesting Let's Encrypt cert for $domain"
    ssh_run "sudo certbot --nginx -d $domain --non-interactive --agree-tos -m olibuijr@olibuijr.com --redirect"
    ;;

  release)
    # release [patch|minor|major|publish] [flags] — unified release engine.
    #
    # Reads ./akurai-deploy.toml (or -C <dir>) and runs the whole release ONCE,
    # here, instead of in each project's bespoke deploy.sh:
    #   gate (fmt/clippy/test) → bump VERSION + Cargo.toml (lockstep, keyed on the
    #   old value) → changelog (hybrid: git-draft since last tag + manual
    #   [Unreleased] + curate) → git commit/tag/push → deploy-binary + nginx-proxy
    #   + tls. `publish` (alias `ec2`) ships the current binary with NO bump.
    #   --dry-run previews bump+changelog and writes/ships/pushes NOTHING.
    #   --changelog "text" uses one-line text; --yes accepts the draft unattended.
    # Manifest schema:
    #   v1 flat TOML stays supported as-is (name=, domain=, port=, build=, artifact=, ...)
    #   v2 nested TOML can use dotted groups such as app.name, build.command,
    #   deploy.port, release.gates.fmt, and service.exec_start.
    SELF="$(command -v akurai-ec2 2>/dev/null || true)"; [ -n "$SELF" ] || SELF="$0"
    level="patch"; act_explicit=0; dry=0; cl_text=""; have_cl_flag=0; assume_yes=0; workdir="."
    while [ $# -gt 0 ]; do
      case "$1" in
        patch|minor|major) level="$1"; act_explicit=1 ;;
        publish|ec2)       level="publish"; act_explicit=1 ;;
        --dry-run)         dry=1 ;;
        --yes|-y)          assume_yes=1 ;;
        --changelog)       shift; cl_text="${1:-}"; have_cl_flag=1 ;;
        -C)                shift; workdir="${1:-.}" ;;
        *) die "release: bad arg '$1' (usage: release [patch|minor|major|publish] [--dry-run] [--changelog TEXT] [--yes] [-C DIR])" ;;
      esac
      shift || true
    done
    cd "$workdir" || die "cannot cd to $workdir"
    [ -f akurai-deploy.toml ] || die "no akurai-deploy.toml in $(pwd) — every app needs one (schema in this command's header / the _AWSEC2 'release' workflow)"

    release_load_manifest "akurai-deploy.toml"

    name="$(mf_require "name (systemd unit)" name app.name)"
    domain="$(mf_get domain app.domain || true)"; port="$(mf_get port deploy.port || true)"
    buildcmd="$(mf_get build build.command || true)"; artifact="$(mf_get artifact build.artifact || true)"; appdir="$(mf_get appdir build.appdir || true)"
    artifact="${artifact/#\~/$HOME}"   # allow ~ in artifact path (e.g. shared Framework binary)
    vfile="$(mf_get version_file release.version_file || true)"; vfile="${vfile:-VERSION}"
    cargover="$(mf_get cargo_version release.cargo_version || true)"; cargover="${cargover:-workspace}"
    clfile="$(mf_get changelog release.changelog || true)"; clfile="${clfile:-CHANGELOG.md}"
    clmode="$(mf_get changelog_mode release.changelog_mode || true)"; clmode="${clmode:-hybrid}"
    g_fmt="$(mf_get_bool true gate_fmt release.gates.fmt)"
    g_clippy="$(mf_get_bool true gate_clippy release.gates.clippy)"
    g_test="$(mf_get_bool true gate_test release.gates.test)"
    dbsnap="$(mf_get db_snapshot deploy.db_snapshot || true)"
    dbkeep="$(mf_get db_keep deploy.db_keep || true)"; dbkeep="${dbkeep:-7}"
    repo="$(mf_get repo app.repo || true)"
    # custom-unit passthrough (non-Framework-convention apps) + default action
    execstart="$(mf_get exec_start service.exec_start || true)"
    envfile="$(mf_get env_file service.env_file || true)"
    appuser="$(mf_get user service.user || true)"
    udatadir_raw="$(mf_get data_dir service.data_dir || true)"
    udatadir="$(mf_get_string_array_lines "service.data_dir" data_dir service.data_dir || true)"
    mng_nginx="$(mf_get_bool true manage_nginx deploy.manage_nginx)"   # false → leave the app's existing nginx vhost untouched on redeploy
    defaction="$(mf_get default_action release.default_action || true)"
    if [ "$act_explicit" = 0 ] && [ -n "$defaction" ]; then case "$defaction" in ec2) level="publish" ;; *) level="$defaction" ;; esac; fi

    if [ "$dry" = 1 ]; then say "release: $name @ $(pwd) [DRY-RUN]"; else say "release: $name @ $(pwd)"; fi

    musl_build() {
      if [ -z "$buildcmd" ]; then
        say "build: (none — using prebuilt artifact ${artifact:-<unset>})"
      else
        say "build: $buildcmd"
        if [ "$dry" = 0 ]; then rustup target add x86_64-unknown-linux-musl >/dev/null 2>&1 || true; eval "$buildcmd" || die "build failed"; fi
      fi
      if [ "$dry" = 1 ]; then say "  [dry-run] skipping build + artifact check"; return 0; fi
      if [ -z "$artifact" ] || [ ! -f "$artifact" ]; then die "build artifact not found: ${artifact:-<unset>}"; fi
    }

    do_publish() {
      musl_build
      [ -n "$port" ] || die "manifest: 'port' required to publish"
      if [ "$dry" = 1 ]; then
        say "  [dry-run] would: deploy-binary $name $artifact $port ${appdir:+$appdir}"
        if [ -n "$execstart" ]; then say "  [dry-run]   custom unit: ExecStart='$execstart' User='${appuser:-ubuntu}'${envfile:+ EnvironmentFile='$envfile'}${udatadir_raw:+ data='$udatadir_raw'}"; fi
        if [ -n "$domain" ] && [ "$domain" != "-" ] && [ "$mng_nginx" = true ]; then say "  [dry-run] would: nginx-proxy $domain $port; tls $domain"; elif [ -n "$domain" ] && [ "$domain" != "-" ]; then say "  [dry-run] nginx: leaving existing $domain vhost untouched (manage_nginx=false)"; fi
        return 0
      fi
      if [ -n "$dbsnap" ]; then
        say "db-snapshot: $dbsnap (keep $dbkeep)"
        "$SELF" ssh "ts=\$(date +%Y%m%d-%H%M%S); sudo systemctl stop $name 2>/dev/null||true; sudo mkdir -p /var/backups/$name; [ -d '$dbsnap' ] && sudo cp -a '$dbsnap' /var/backups/$name/data-\$ts || true; sudo systemctl start $name 2>/dev/null||true; ls -1dt /var/backups/$name/data-* 2>/dev/null | tail -n +$((dbkeep+1)) | xargs -r sudo rm -rf" || say "  snapshot warning (continuing)"
      fi
      AKURAI_EXEC_START="$execstart" AKURAI_ENV_FILE="$envfile" AKURAI_USER="$appuser" AKURAI_DATA_DIR="$udatadir" "$SELF" deploy-binary "$name" "$artifact" "$port" ${appdir:+"$appdir"}
      if [ -n "$domain" ] && [ "$domain" != "-" ] && [ "$mng_nginx" = true ]; then
        "$SELF" nginx-proxy "$domain" "$port"
        "$SELF" tls "$domain" || say "  TLS deferred — run 'akurai-ec2 tls $domain' once DNS resolves"
      fi
      if [ -n "$domain" ] && [ "$domain" != "-" ]; then say "published → https://$domain"; else say "published (internal: 127.0.0.1:$port)"; fi
    }

    if [ "$level" = "publish" ]; then do_publish; say "publish: $name ✓"; exit 0; fi

    if [ "$g_fmt" = true ];    then say "gate: rustfmt";          if [ "$dry" = 0 ]; then cargo fmt --all -- --check         || die "fmt failed (run: cargo fmt --all)"; fi; fi
    if [ "$g_clippy" = true ]; then say "gate: clippy -D warnings"; if [ "$dry" = 0 ]; then cargo clippy --all-targets -- -D warnings || die "clippy failed"; fi; fi
    if [ "$g_test" = true ];   then say "gate: tests";            if [ "$dry" = 0 ]; then cargo test --workspace           || die "tests failed"; fi; fi

    [ -f "$vfile" ] || die "no $vfile (manifest version_file)"
    cur="$(tr -d '[:space:]' < "$vfile")"
    IFS='.' read -r MA MI PA <<< "$cur"
    case "$level" in major) MA=$((MA+1)); MI=0; PA=0 ;; minor) MI=$((MI+1)); PA=0 ;; patch) PA=$((PA+1)) ;; esac
    new="$MA.$MI.$PA"
    say "version: $cur → $new ($level)"
    if [ "$dry" = 0 ]; then
      printf '%s\n' "$new" > "$vfile"
      if [ "$cargover" != none ] && [ -f Cargo.toml ]; then sed -i -E "s/^version = \"$cur\"/version = \"$new\"/" Cargo.toml; fi
    fi

    draft=".akurai-changelog.draft"
    rm -f "$draft" 2>/dev/null || true
    trap 'rm -f "$draft" 2>/dev/null || true' EXIT
    unreleased_body() { [ -f "$clfile" ] || return 0; awk '/^## \[Unreleased\]$/{f=1;next} /^## \[/{f=0} f' "$clfile" | sed '/^[[:space:]]*$/d'; }
    git_draft() {
      local range="HEAD" lt feats fixes chgd any=0
      lt="$(git describe --tags --abbrev=0 2>/dev/null || true)"; if [ -n "$lt" ]; then range="$lt..HEAD"; fi
      feats="$(git log --no-merges --pretty=%s "$range" 2>/dev/null | sed -nE 's/^feat(\([^)]*\))?!?: /- /p' || true)"
      fixes="$(git log --no-merges --pretty=%s "$range" 2>/dev/null | sed -nE 's/^fix(\([^)]*\))?!?: /- /p' || true)"
      chgd="$(git log --no-merges --pretty=%s "$range" 2>/dev/null | sed -nE 's/^(refactor|perf|docs|chore|build|ci|style|test)(\([^)]*\))?!?: /- /p' || true)"
      if [ -n "$feats" ]; then printf '### Added\n%s\n\n' "$feats"; any=1; fi
      if [ -n "$fixes" ]; then printf '### Fixed\n%s\n\n' "$fixes"; any=1; fi
      if [ -n "$chgd" ];  then printf '### Changed\n%s\n\n' "$chgd"; any=1; fi
      if [ "$any" = 0 ];  then printf -- '- maintenance release\n'; fi
    }
    if [ "$have_cl_flag" = 1 ]; then
      printf -- '- %s\n' "$cl_text" > "$draft"
    elif [ "$clmode" = manual ]; then
      printf -- '- \n' > "$draft"; if [ -t 0 ] && [ "$dry" = 0 ]; then "${EDITOR:-vi}" "$draft" || true; fi
    else
      { mu="$(unreleased_body)"; if [ -n "$mu" ]; then printf '%s\n\n' "$mu"; fi; git_draft; } > "$draft"
    fi
    if [ "$clmode" = hybrid ] && [ "$have_cl_flag" = 0 ] && [ "$assume_yes" = 0 ] && [ "$dry" = 0 ] && [ -t 0 ]; then
      say "draft changelog for [$new] (commits since $(git describe --tags --abbrev=0 2>/dev/null || echo start)):"
      sed 's/^/    /' "$draft"
      printf '  [e]dit / [a]ccept / [s]kip ? '; read -r ans || ans=a
      case "${ans:-a}" in e|E) "${EDITOR:-vi}" "$draft" || true ;; s|S) : > "$draft" ;; esac
    fi
    if [ "$dry" = 1 ]; then
      say "[dry-run] changelog [$new] would be:"; sed 's/^/    /' "$draft"
    else
      hdr="## [$new] - $(date +%Y-%m-%d)"
      if [ ! -f "$clfile" ]; then
        { printf '# Changelog\n\nAll notable changes to this project are documented here. Format: Keep a Changelog; SemVer.\n\n## [Unreleased]\n\n%s\n\n' "$hdr"; cat "$draft"; printf '\n'; } > "$clfile"
      elif grep -q '^## \[Unreleased\]$' "$clfile"; then
        awk -v hdr="$hdr" -v bf="$draft" '/^## \[Unreleased\]$/{print "## [Unreleased]"; print ""; print hdr; print ""; while ((getline l < bf) > 0) print l; print ""; next} {print}' "$clfile" > "$clfile.x" && mv "$clfile.x" "$clfile"
      else
        { printf '%s\n\n' "$hdr"; cat "$draft"; printf '\n'; cat "$clfile"; } > "$clfile.x" && mv "$clfile.x" "$clfile"
      fi
      say "changelog: cut [$new] into $clfile"
    fi
    rm -f "$draft" 2>/dev/null || true

    if [ "$dry" = 0 ]; then
      say "git: commit + tag v$new"
      git add -A
      git commit -q -m "release: v$new" || die "git commit failed (nothing to commit?)"
      git tag -a "v$new" -m "v$new" || die "git tag v$new failed"
      if ! git remote get-url origin >/dev/null 2>&1 && [ -n "$repo" ]; then
        if gh repo view "$repo" >/dev/null 2>&1; then git remote add origin "https://github.com/$repo.git"; else say "creating GitHub repo $repo (private)"; gh repo create "$repo" --private --source=. --remote=origin || true; fi
      fi
      if git remote get-url origin >/dev/null 2>&1; then say "git: push"; { git push -u origin HEAD && git push origin "v$new"; } || say "  push warning (continuing)"; fi
    fi

    do_publish
    if [ "$dry" = 1 ]; then say "release (dry-run): $name → v$new — nothing written, pushed, or deployed ✓"; else say "release: $name v$new ✓"; fi
    ;;

  rollback)
    # rollback <name> — restore the previous binary saved by deploy-binary.
    [ $# -eq 1 ] || die "usage: akurai-ec2 rollback <name>"
    name="$1"; base="/opt/$name"
    ssh_run "[ -f $base/bin/$name.prev ]" || die "no $base/bin/$name.prev backup found for $name"
    say "rolling back $name to its previous binary"
    ssh_run "sudo systemctl stop $name.service; cp -p $base/bin/$name.prev $base/bin/$name; sudo systemctl start $name.service; sleep 2; systemctl is-active $name.service"
    say "rolled back: $name"
    ;;

  logs)
    [ $# -eq 1 ] || die "usage: akurai-ec2 logs <service>"
    ssh_run "journalctl -u $1 -n 80 --no-pager"
    ;;

  mail-autoconfig)
    # Email client auto-setup lives in nginx vhost 'mail-autoconfig' + snippets
    # mail-autoconfig-<domain>.conf / mail-autodiscover.conf, payloads under
    # /var/www/autoconfig/<domain>/, certs autoconfig.<domain> (+autodiscover SAN).
    # Advertises mail.olibuijr.com IMAP 993 SSL / SMTP 587 STARTTLS for every domain.
    action="${1:-verify}"; shift || true
    case "$action" in
      verify)
        domains="olibuijr.com golfsetridak.is"
        [ $# -gt 0 ] && domains="$*"
        rc=0
        for d in $domains; do
          say "autoconfig: $d"
          code=$(curl -s -o /dev/null -w '%{http_code}' "https://autoconfig.$d/mail/config-v1.1.xml" || echo 000)
          printf '  mozilla   autoconfig.%s/mail/config-v1.1.xml -> HTTP %s\n' "$d" "$code"; [ "$code" = 200 ] || rc=1
          code=$(curl -s -o /dev/null -w '%{http_code}' -X POST "https://autodiscover.$d/autodiscover/autodiscover.xml" || echo 000)
          printf '  ms        autodiscover.%s (POST) -> HTTP %s\n' "$d" "$code"; [ "$code" = 200 ] || rc=1
          for h in "autoconfig.$d" "autodiscover.$d"; do
            if echo | timeout 10 openssl s_client -servername "$h" -connect "$h:443" 2>/dev/null | openssl x509 -noout -checkhost "$h" >/dev/null 2>&1; then
              printf '  tls       %s -> cert matches\n' "$h"
            else printf '  tls       %s -> MISMATCH/UNREACHABLE\n' "$h"; rc=1; fi
          done
          for s in _imaps._tcp _submission._tcp _autodiscover._tcp; do
            ans=$(dig +short SRV "$s.$d" 2>/dev/null | tr '\n' ' ')
            printf '  srv       %s.%s -> %s\n' "$s" "$d" "${ans:-<none yet>}"
          done
        done
        [ $rc -eq 0 ] && say "autoconfig OK (SRV may lag on 1984.is rebuild)" || die "autoconfig has failures (see above)"
        ;;
      *) die "usage: akurai-ec2 mail-autoconfig verify [domain...]" ;;
    esac
    ;;

  allow-ssh)
    # Re-authorize SSH from an IP (default: current public IP). The box's SG locks
    # port 22 to specific /32s, so a dynamic-IP change times out SSH — this re-adds you.
    ip="${1:-}"
    [ -z "$ip" ] && ip=$(curl -s --max-time 8 https://api.ipify.org || true)
    [ -n "$ip" ] || die "could not determine your IP — pass it: akurai-ec2 allow-ssh <ip>"
    region="${AWS_DEFAULT_REGION:-us-east-1}"
    sg=$(AWS_DEFAULT_REGION="$region" aws ec2 describe-instances \
           --filters "Name=ip-address,Values=$PUBLIC_IP" \
           --query "Reservations[].Instances[].SecurityGroups[].GroupId" --output text 2>/dev/null || true)
    [ -n "$sg" ] || die "could not find the security group for $PUBLIC_IP (aws creds / region?)"
    say "allowing SSH from $ip/32 on $sg"
    if out=$(AWS_DEFAULT_REGION="$region" aws ec2 authorize-security-group-ingress \
               --group-id "$sg" \
               --ip-permissions "IpProtocol=tcp,FromPort=22,ToPort=22,IpRanges=[{CidrIp=$ip/32,Description=\"akurai-ec2 allow-ssh\"}]" 2>&1); then
      say "SSH allowed from $ip"
    elif printf '%s' "$out" | grep -q Duplicate; then
      say "already allowed ($ip)"
    else
      die "$out"
    fi
    ;;

  ports)
    [ -f "$PORTS_REGISTRY" ] || die "registry not found: $PORTS_REGISTRY (see the _VMPORTS skill)"
    sub="${1:-list}"; shift || true

    # ---- fetch live state once (port->unit via cgroup, nginx vhost->port) ----
    live="$(ssh_run 'bash -s' <<'RFETCH'
echo "### PORTS"
sudo ss -ltnpH 2>/dev/null | while read -r line; do
  laddr=$(echo "$line" | awk '{print $4}'); port=${laddr##*:}
  case "$port" in (*[!0-9]*|"") continue;; esac
  pid=$(echo "$line" | grep -oE 'pid=[0-9]+' | head -1 | grep -oE '[0-9]+'); unit=""
  [ -n "$pid" ] && unit=$(grep -aoE '[A-Za-z0-9_.@-]+\.service' /proc/$pid/cgroup 2>/dev/null | head -1)
  printf '%s|%s\n' "$port" "${unit%.service}"
done | sort -t'|' -k1,1n -u
echo "### VHOSTS"
for f in /etc/nginx/sites-enabled/*; do
  n=$(basename "$f")
  # Resolve the ROOT "location / " upstream (trailing space avoids matching /mcp etc).
  # Handle both direct (proxy_pass http://127.0.0.1:PORT) and named upstreams
  # (proxy_pass http://name; upstream name { server 127.0.0.1:PORT; }).
  tgt=$(grep -A4 'location / ' "$f" 2>/dev/null | grep -oE 'proxy_pass http://[^;]+' | head -1 | sed 's#proxy_pass http://##')
  case "$tgt" in
    127.0.0.1:*) p=${tgt##*:} ;;
    "")          p=static ;;
    *) p=$(grep -E "upstream[[:space:]]+${tgt}[[:space:]]*\{" -A3 "$f" 2>/dev/null | grep -oE 'server[[:space:]]+127\.0\.0\.1:[0-9]+' | grep -oE '[0-9]+$' | head -1)
       [ -z "$p" ] && p=static ;;
  esac
  printf '%s|%s\n' "$n" "${p:-static}"
done
RFETCH
)"

    # ---- parse: live_unit[port]=unit ; vhost_port[domain]=port ----------------
    declare -A live_unit vhost_port
    section=""
    while IFS= read -r ln; do
      case "$ln" in
        "### PORTS")  section=p; continue;;
        "### VHOSTS") section=v; continue;;
        "") continue;;
      esac
      k="${ln%%|*}"; v="${ln#*|}"
      [ "$section" = p ] && live_unit["$k"]="$v"
      [ "$section" = v ] && vhost_port["$k"]="$v"
    done <<< "$live"

    # ---- parse registry: reg_unit[port], reg_domain[port], reg_pool[port] -----
    declare -A reg_unit reg_domain reg_pool reg_note
    reg_ports=()
    trim() { local s="$1"; s="${s#"${s%%[![:space:]]*}"}"; s="${s%"${s##*[![:space:]]}"}"; printf '%s' "$s"; }
    while IFS='|' read -r c_port c_unit c_dom c_repo c_pool c_note; do
      [ -z "${c_port// }" ] && continue
      case "$(trim "$c_port")" in (\#*) continue;; esac
      p=$(trim "$c_port"); case "$p" in (*[!0-9]*|"") continue;; esac
      reg_ports+=("$p")
      reg_unit["$p"]="$(trim "$c_unit")"
      reg_domain["$p"]="$(trim "$c_dom")"
      reg_pool["$p"]="$(trim "$c_pool")"
      reg_note["$p"]="$(trim "$c_note")"
    done < "$PORTS_REGISTRY"

    is_taken() {  # taken if claimed in registry OR bound live
      local p="$1"; [ -n "${reg_unit[$p]:-}" ] && return 0; [ -n "${live_unit[$p]:-}" ] && return 0; return 1
    }

    case "$sub" in
      list)
        printf '\033[1m%-6s %-20s %-7s %-30s %s\033[0m\n' PORT UNIT POOL DOMAIN LIVE
        for p in $(printf '%s\n' "${reg_ports[@]}" | sort -n); do
          want="${reg_unit[$p]}"; got="${live_unit[$p]:-}"
          # system services carry a descriptive label, not an exact unit name —
          # any live binding counts as bound for them.
          if [ "$want" = "$got" ] || { [ "${reg_pool[$p]}" = system ] && [ -n "$got" ]; }; then
            mark=$'\033[0;32m●\033[0m bound'
          elif [ -n "$got" ]; then mark=$'\033[0;31m✗\033[0m '"$got"
          else                     mark=$'\033[0;33m○\033[0m not bound'; fi
          printf '%-6s %-20s %-7s %-30s %b\n' "$p" "$want" "${reg_pool[$p]}" "${reg_domain[$p]}" "$mark"
        done
        # live app-pool ports with no registry row = unregistered
        unreg=""
        for p in "${!live_unit[@]}"; do
          [ "$p" -ge "$APP_POOL_LO" ] && [ "$p" -le "$APP_POOL_HI" ] || continue
          [ -n "${reg_unit[$p]:-}" ] || unreg+=$'\n'"  $p -> ${live_unit[$p]}"
        done
        [ -n "$unreg" ] && { printf '\n\033[1;31munregistered live ports (app pool):\033[0m'; printf '%b\n' "$unreg"; }
        ;;

      free)
        n="${1:-1}"; found=0
        for ((p=APP_POOL_LO; p<=APP_POOL_HI; p++)); do
          is_taken "$p" || { echo "$p"; found=$((found+1)); [ "$found" -ge "$n" ] && break; }
        done
        [ "$found" -gt 0 ] || die "no free ports in $APP_POOL_LO-$APP_POOL_HI"
        ;;

      check)
        [ $# -ge 1 ] || die "usage: akurai-ec2 ports check <port>"
        p="$1"
        say "port $p"
        if [ -n "${reg_unit[$p]:-}" ]; then echo "  registry: ${reg_unit[$p]} (${reg_pool[$p]}) ${reg_domain[$p]}  — ${reg_note[$p]}"
        else echo "  registry: (unclaimed)"; fi
        if [ -n "${live_unit[$p]:-}" ]; then echo "  live:     bound by ${live_unit[$p]}"
        else echo "  live:     not bound"; fi
        is_taken "$p" && printf '\033[1;31m  => TAKEN\033[0m\n' || printf '\033[1;32m  => FREE\033[0m\n'
        ;;

      drift)
        issues=0
        # (a) registry port expected unit U, but live differs / missing
        for p in $(printf '%s\n' "${reg_ports[@]}" | sort -n); do
          [ "${reg_pool[$p]}" = system ] && continue
          want="${reg_unit[$p]}"; got="${live_unit[$p]:-}"
          [ -z "$want" ] && continue
          if [ -z "$got" ]; then
            printf '\033[0;33m○ %-18s NOT BOUND on its port %s\033[0m  (%s)\n' "$want" "$p" "${reg_note[$p]}"; issues=$((issues+1))
          elif [ "$got" != "$want" ]; then
            printf '\033[0;31m✗ port %s: expected %s, live is %s\033[0m\n' "$p" "$want" "$got"; issues=$((issues+1))
          fi
        done
        # (b) nginx vhost points at a port other than what the registry intends.
        # A port may carry comma-separated domain aliases; a "host/path" entry is
        # path-routed (e.g. notes /mcp) and is not its own vhost — skip it.
        declare -A flagged_dom
        for p in "${!reg_domain[@]}"; do
          raw="${reg_domain[$p]}"; [ "$raw" = "-" ] && continue
          IFS=',' read -ra _doms <<< "$raw"
          for d in "${_doms[@]}"; do
            case "$d" in (*/*) continue;; esac
            lv="${vhost_port[$d]:-}"; [ -z "$lv" ] && continue
            [ "$lv" = static ] && { printf '\033[0;33m○ vhost %s is static — registry routes it to %s (%s)\033[0m\n' "$d" "$p" "${reg_unit[$p]}"; flagged_dom[$d]=1; issues=$((issues+1)); continue; }
            [ "$lv" != "$p" ] && { printf '\033[0;31m✗ vhost %s -> %s, registry intends %s (%s)\033[0m\n' "$d" "$lv" "$p" "${reg_unit[$p]}"; flagged_dom[$d]=1; issues=$((issues+1)); }
          done
        done
        # (c) live app-pool listeners with no registry row
        for p in "${!live_unit[@]}"; do
          [ "$p" -ge "$APP_POOL_LO" ] && [ "$p" -le "$APP_POOL_HI" ] || continue
          [ -n "${reg_unit[$p]:-}" ] || { printf '\033[0;31m✗ port %s bound by %s — not in registry\033[0m\n' "$p" "${live_unit[$p]}"; issues=$((issues+1)); }
        done
        # (d) live vhost serving an app-pool port that belongs to a different app
        # (the port's registered domain — or any of its comma aliases — isn't this one)
        for d in "${!vhost_port[@]}"; do
          [ -n "${flagged_dom[$d]:-}" ] && continue   # already reported in (b)
          lv="${vhost_port[$d]}"; case "$lv" in (*[!0-9]*|"") continue;; esac
          [ "$lv" -ge "$APP_POOL_LO" ] && [ "$lv" -le "$APP_POOL_HI" ] || continue
          raw="${reg_domain[$lv]:-}"; { [ -z "$raw" ] || [ "$raw" = "-" ]; } && continue
          owner=0; IFS=',' read -ra _owners <<< "$raw"
          for o in "${_owners[@]}"; do [ "$o" = "$d" ] && owner=1; done
          if [ "$owner" = 0 ]; then
            printf '\033[0;31m✗ vhost %s -> %s, but %s is %s (%s)\033[0m\n' "$d" "$lv" "$lv" "${reg_unit[$lv]}" "$raw"
            issues=$((issues+1))
          fi
        done
        if [ "$issues" -eq 0 ]; then say "no drift — registry matches the box"; else die "$issues drift issue(s) — see above"; fi
        ;;

      *) die "usage: akurai-ec2 ports [list|free [n]|check <port>|drift]" ;;
    esac
    ;;

  processes|ps)
    # List the running AkurAI fleet on the box: unit, state, PID, memory, uptime,
    # and listening port(s). Optional [pattern] overrides the default fleet match
    # (an egrep alternation). Goes beyond `status`: per-unit PID/mem/uptime + ports.
    pat="${1:-akurai|golfset|idp|mail|router|drive|fulltrui|platform|notes|passvault|crm|vpn|monitor}"
    say "AkurAI processes on $SSH_HOST (match: $pat)"
    ssh_run "bash -s -- '$pat'" <<'EOF'
pat="$1"
# pid -> listening port(s), so each unit can show what it's actually serving.
declare -A pid_ports
while read -r line; do
  laddr=$(echo "$line" | awk '{print $4}'); port=${laddr##*:}
  case "$port" in (*[!0-9]*|"") continue;; esac
  pid=$(echo "$line" | grep -oE 'pid=[0-9]+' | head -1 | grep -oE '[0-9]+')
  [ -n "$pid" ] && pid_ports[$pid]+="$port "
done < <(sudo ss -ltnpH 2>/dev/null)

printf '%-24s %-9s %-7s %-8s %-19s %s\n' UNIT STATE PID MEM SINCE PORTS
n=0
for unit in $(systemctl list-units --type=service --state=running --no-legend --plain 2>/dev/null | awk '{print $1}' | grep -iE "$pat" | sort -u); do
  pid=$(systemctl show "$unit" -p MainPID --value 2>/dev/null)
  sub=$(systemctl show "$unit" -p SubState --value 2>/dev/null)
  memraw=$(systemctl show "$unit" -p MemoryCurrent --value 2>/dev/null)
  since=$(systemctl show "$unit" -p ActiveEnterTimestamp --value 2>/dev/null | awk '{print $2" "$3}')
  mem="-"
  case "$memraw" in (''|'[not set]'|18446744073709551615) ;; (*) mem=$(numfmt --to=iec "$memraw" 2>/dev/null || echo "$memraw");; esac
  ports="${pid_ports[$pid]:-—}"
  printf '%-24s %-9s %-7s %-8s %-19s %s\n' "${unit%.service}" "$sub" "$pid" "$mem" "$since" "$ports"
  n=$((n+1))
done
echo
echo "total running: $n"
EOF
    ;;

  help|*)
    grep -E '^#( |$)' "$0" | sed 's/^# \{0,1\}//'
    ;;
esac