#!/usr/bin/env bash
# ===== goose: one-box Gooseplicant deployment -----
# Caddy (automatic HTTPS via Cloudflare DNS-01) → Authelia (local users, 2FA) → Gooseplicant, all internal;
# optional public intake through a Cloudflare Tunnel that reaches only the intake-only port.
# Every command is idempotent: re-run it after fixing whatever it told you to fix.
# Requires bash ≥ 3.2, docker + docker compose v2, openssl, curl. Reference: deploy/README.md.
# ===== #
set -euo pipefail

# Every path goose works on hangs off the deploy/ directory. `update-apply` and `rollback-apply` run from a copy
# of goose outside the install (so the files they replace are never the script bash is reading) and point
# these at the install with --install.
set_dirs() {
  GOOSE_DIR=$1
  KIT_DIR="$(dirname "$GOOSE_DIR")"
  ENV_FILE="$GOOSE_DIR/.env"
  CONFIG_DIR="$GOOSE_DIR/config"
  SECRETS_DIR="$GOOSE_DIR/secrets"
  TEMPLATES="$GOOSE_DIR/templates"
  USERS_FILE="$CONFIG_DIR/authelia/users_database.yml"
  BACKUP_DIR="$GOOSE_DIR/backups"
  UPDATE_LOG="$BACKUP_DIR/update.log"
}
SELF="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd -P)/$(basename "${BASH_SOURCE[0]}")"
set_dirs "$(dirname "$SELF")"
AUTHELIA_IMAGE="authelia/authelia:4.39.28"
AUTHELIA_VERSION="4.39.28"
CADDY_IMAGE="gooseplicant-caddy:2.11.4-cf0.2.4"
NODE_IMAGE="node:26-slim" # password hashing fallback when the host has no Node ≥ 24.7 (same base as the app image)
CF_API="${GOOSE_CF_API:-https://api.cloudflare.com/client/v4}"
DOWNLOAD_BASE="${GOOSE_DOWNLOAD_BASE:-https://gooseplicant.goosenotes.dev/downloads}"
CHANGELOG_URL="${GOOSE_CHANGELOG_URL:-${DOWNLOAD_BASE%/}/}" # release list; each kit also ships CHANGELOG.md
TUNNEL_NAME_DEFAULT="gooseplicant-intake"
SECRET_FILES="authelia_session_secret authelia_storage_encryption_key authelia_reset_password_jwt_secret"
umask 077
export COPYFILE_DISABLE=1 # macOS tar: no ._ AppleDouble files in backups

# ---------- output ----------
if [ -t 1 ] && [ -z "${NO_COLOR:-}" ] && [ "${TERM:-dumb}" != "dumb" ]; then
  C_R=$'\033[31m' C_G=$'\033[32m' C_Y=$'\033[33m' C_B=$'\033[34m' C_M=$'\033[35m' C_BOLD=$'\033[1m' C_DIM=$'\033[2m' C_0=$'\033[0m'
else
  C_R="" C_G="" C_Y="" C_B="" C_M="" C_BOLD="" C_DIM="" C_0=""
fi
say() { printf '%s\n' "$*"; }
step() { printf '\n%s==> %s%s\n' "$C_B$C_BOLD" "$*" "$C_0"; }
ok() { printf '  %sok%s    %s\n' "$C_G" "$C_0" "$*"; }
note() { printf '  %s-%s     %s\n' "$C_DIM" "$C_0" "$*"; }
warn() { printf '  %swarn%s  %s\n' "$C_Y" "$C_0" "$*" >&2; }
die() {
  printf '%serror:%s %s\n' "$C_R$C_BOLD" "$C_0" "$*" >&2
  exit 1
}

# Numbered walkthrough for the steps only a human can do (dashboard clicks, DNS records, tokens).
WT_N=0
wt_begin() {
  WT_N=0
  printf '\n%s┌─ ACTION NEEDED: %s%s\n' "$C_M$C_BOLD" "$*" "$C_0"
}
wt() {
  WT_N=$((WT_N + 1))
  printf '%s│%s %2d. %s\n' "$C_M" "$C_0" "$WT_N" "$1"
  shift
  local line
  for line in "$@"; do printf '%s│%s       %s\n' "$C_M" "$C_0" "$line"; done
}
wt_text() { printf '%s│%s     %s\n' "$C_M" "$C_0" "$*"; }
wt_end() { printf '%s└─%s\n' "$C_M" "$C_0"; }

# The only places a secret is printed: values the person must copy somewhere else.
secret_box() {
  printf '\n%s┌─ SECRET: %s%s\n' "$C_Y$C_BOLD" "$1" "$C_0"
  printf '%s│%s  %s\n' "$C_Y" "$C_0" "$2"
  printf '%s└─ %s%s\n' "$C_Y" "${3:-Store it in a password manager. It is not shown again.}" "$C_0"
}

# ---------- small helpers ----------
have() { command -v "$1" >/dev/null 2>&1; }
lower() { printf '%s' "$1" | tr '[:upper:]' '[:lower:]'; }
gen_hex() { openssl rand -hex "${1:-32}"; }
gen_password() { openssl rand -base64 30 | tr -d '/+=\n' | cut -c1-24; }
now_utc() { date -u +%Y-%m-%dT%H:%M:%SZ; }
sha256_of() { openssl dgst -sha256 -r "$1" | cut -d' ' -f1 | tr -d '*'; }
TMP_ROOT="" LOCK_DIR="" CLEANUP_EXTRA=""
on_exit() {
  [ -z "$TMP_ROOT" ] || rm -rf "$TMP_ROOT"
  [ -z "$CLEANUP_EXTRA" ] || rm -rf "$CLEANUP_EXTRA"
  [ -z "$LOCK_DIR" ] || rm -rf "$LOCK_DIR"
}
tmpdir() {
  if [ -z "$TMP_ROOT" ]; then
    TMP_ROOT="$(mktemp -d "${TMPDIR:-/tmp}/goose.XXXXXX")"
    trap on_exit EXIT
  fi
  printf '%s' "$TMP_ROOT"
}

RE_HOST='^([a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z][a-z0-9-]{0,61}[a-z0-9]$'
RE_IPV4='^([0-9]{1,3}\.){3}[0-9]{1,3}$'
RE_EMAIL='^[A-Za-z0-9._%+-]+@([A-Za-z0-9-]+\.)+[A-Za-z]{2,}$'
RE_USER='^[a-z0-9][a-z0-9._-]{0,63}$'
is_host() { [[ $1 =~ $RE_HOST ]]; }
is_ipv4() { [[ $1 =~ $RE_IPV4 ]]; }
is_email() { [[ $1 =~ $RE_EMAIL ]]; }
is_user() { [[ $1 =~ $RE_USER ]]; }
is_project() { [[ $1 =~ ^[a-z0-9][a-z0-9_-]*$ ]]; }
is_edition() { [ "$1" = full ] || [ "$1" = lite ]; }
is_port() { [[ $1 =~ ^[0-9]{1,5}$ ]] && [ "$1" -ge 1 ] && [ "$1" -le 65535 ]; }
is_any() { :; }
is_nonempty() { [ -n "$1" ]; }
is_yesno() { case "$(lower "$1")" in y | yes | n | no | true | false | 1 | 0) return 0 ;; *) return 1 ;; esac; }
truthy() { case "$(lower "${1:-}")" in y | yes | true | 1) return 0 ;; *) return 1 ;; esac; }
under() { [ "$1" = "$2" ] || case "$1" in *."$2") return 0 ;; *) return 1 ;; esac; } # host under domain
parent_of() { printf '%s' "${1#*.}"; }
is_private_ip() {
  case "$1" in
    10.* | 192.168.* | 127.*) return 0 ;;
    172.1[6-9].* | 172.2[0-9].* | 172.3[01].*) return 0 ;;
    100.6[4-9].* | 100.[7-9][0-9].* | 100.1[01][0-9].* | 100.12[0-7].*) return 0 ;; # CGNAT / Tailscale
    *) return 1 ;;
  esac
}
# JSON string literal (for the small request bodies built here).
json_str() { printf '"%s"' "$(printf '%s' "$1" | sed -e 's/\\/\\\\/g' -e 's/"/\\"/g')"; }
# YAML double-quoted scalar.
yaml_str() { printf '"%s"' "$(printf '%s' "$1" | sed -e 's/\\/\\\\/g' -e 's/"/\\"/g')"; }
urlencode() {
  local s=$1 out="" c i
  for ((i = 0; i < ${#s}; i++)); do
    c=${s:i:1}
    case "$c" in [A-Za-z0-9._~-]) out="$out$c" ;; *) out="$out$(printf '%%%02X' "'$c")" ;; esac
  done
  printf '%s' "$out"
}

# ---------- JSON reading (jq, python3, node or a node container: whichever exists) ----------
# json_get <path> < json   path: dotted keys / array indexes, e.g. result.0.id ; a trailing "#" gives a length.
json_get() {
  local path=$1 use=${GOOSE_JSON:-} # GOOSE_JSON=jq|python3|node|docker forces one (tests)
  if { [ -z "$use" ] && have jq; } || [ "$use" = jq ]; then
    local filter="" seg rest=$path len=0
    while [ -n "$rest" ]; do
      seg=${rest%%.*}
      if [ "$seg" = "$rest" ]; then rest=""; else rest=${rest#*.}; fi
      if [ "$seg" = "#" ]; then len=1; elif [[ $seg =~ ^[0-9]+$ ]]; then filter="${filter}[$seg]?"; else filter="${filter}[\"$seg\"]?"; fi
    done
    if [ "$len" = 1 ]; then
      jq -r "(.$filter) | if . == null then 0 else length end" 2>/dev/null || printf '\n'
    else
      jq -r "(.$filter) | if . == null then \"\" elif type == \"object\" or type == \"array\" then tojson else tostring end" 2>/dev/null || printf '\n'
    fi
    return 0
  fi
  local prog='const p=process.argv[1]?process.argv[1].split("."):[];let s="";process.stdin.on("data",d=>s+=d).on("end",()=>{let v;try{v=JSON.parse(s)}catch{v=null}for(const k of p){if(k==="#"){v=v==null?0:(v.length??Object.keys(v).length);break}v=v==null?null:(Array.isArray(v)?v[+k]:v[k]);if(v===undefined)v=null}console.log(v==null?"":typeof v==="object"?JSON.stringify(v):String(v))})'
  if { [ -z "$use" ] && have python3; } || [ "$use" = python3 ]; then
    python3 -c 'import json,sys
p=sys.argv[1].split(".") if sys.argv[1] else []
try: v=json.load(sys.stdin)
except Exception: v=None
for k in p:
    if k=="#":
        v=len(v) if isinstance(v,(list,dict,str)) else 0
        break
    try: v=v[int(k)] if isinstance(v,list) else v[k]
    except Exception:
        v=None
        break
if v is None: print("")
elif isinstance(v,bool): print("true" if v else "false")
elif isinstance(v,(dict,list)): print(json.dumps(v))
else: print(v)' "$path"
  elif { [ -z "$use" ] && have node; } || [ "$use" = node ]; then
    node -e "$prog" "$path"
  else
    docker run --rm -i "$NODE_IMAGE" node -e "$prog" "$path"
  fi
}

# ---------- deploy/.env (settings + secrets; Compose reads it for ${...}) ----------
env_get() {
  [ -f "$ENV_FILE" ] || return 0
  sed -n "s/^$1=//p" "$ENV_FILE" | tail -n 1
}
env_set() {
  local key=$1 val=$2
  case "$val" in *[[:space:]\#\$\"\'\`\\]*) die "internal: refusing to write $key with whitespace, #, \$, quotes or backslashes to .env" ;; esac
  [ -f "$ENV_FILE" ] || : >"$ENV_FILE"
  local tmp="$ENV_FILE.tmp.$$"
  # Values travel in the environment, not in awk's argv (visible to `ps`).
  GOOSE_K=$key GOOSE_V=$val awk 'BEGIN { done = 0; k = ENVIRON["GOOSE_K"]; v = ENVIRON["GOOSE_V"] }
    index($0, k "=") == 1 { if (!done) { print k "=" v; done = 1 } ; next }
    { print }
    END { if (!done) print k "=" v }' "$ENV_FILE" >"$tmp"
  chmod 600 "$tmp"
  mv "$tmp" "$ENV_FILE"
}
cur() { env_get "$1"; }
require_setup() {
  [ -f "$ENV_FILE" ] && [ -f "$CONFIG_DIR/caddy/Caddyfile" ] || die "not set up yet: run ./goose setup first (in $GOOSE_DIR)"
  load_settings
}

load_settings() {
  GOOSE_MODE=$(env_get GOOSE_MODE)
  GOOSE_MODE=${GOOSE_MODE:-production}
  BASE_DOMAIN=$(env_get BASE_DOMAIN)
  APP_HOST=$(env_get APP_HOST)
  AUTH_HOST=$(env_get AUTH_HOST)
  INTAKE_HOST=$(env_get INTAKE_HOST)
  EXTRA_DOMAINS=$(env_get EXTRA_DOMAINS)
  BIND_IP=$(env_get BIND_IP)
  HTTPS_PORT=$(env_get HTTPS_PORT)
  HTTPS_PORT=${HTTPS_PORT:-443}
  HTTP_PORT=$(env_get HTTP_PORT)
  HTTP_PORT=${HTTP_PORT:-80}
  EDITION=$(env_get EDITION)
  EDITION=${EDITION:-full}
  ADMIN_USER=$(env_get ADMIN_USER)
  ADMIN_EMAIL=$(env_get ADMIN_EMAIL)
  ACME_EMAIL=$(env_get ACME_EMAIL)
  AUTH_POLICY=$(env_get AUTH_POLICY)
  AUTH_POLICY=${AUTH_POLICY:-two_factor}
  SMTP_HOST=$(env_get SMTP_HOST)
  SMTP_PORT=$(env_get SMTP_PORT)
  SMTP_USER=$(env_get SMTP_USER)
  SMTP_SENDER=$(env_get SMTP_SENDER)
  APP_MAIL=$(env_get APP_MAIL)
  CF_API_TOKEN=$(env_get CF_API_TOKEN)
  CF_ACCOUNT_ID=$(env_get CF_ACCOUNT_ID)
  TUNNEL_ID=$(env_get TUNNEL_ID)
  TUNNEL_TOKEN=$(env_get TUNNEL_TOKEN)
  INGEST_SECRET=$(env_get INGEST_SECRET)
  PROXY_SECRET=$(env_get PROXY_SECRET)
  GOOSEPLICANT_VERSION=$(env_get GOOSEPLICANT_VERSION)
  COMPOSE_PROJECT_NAME=$(env_get COMPOSE_PROJECT_NAME)
  COMPOSE_PROJECT_NAME=${COMPOSE_PROJECT_NAME:-gooseplicant}
}

kit_version() {
  sed -n 's/^  "version": "\([^"]*\)".*/\1/p' "$KIT_DIR/package.json" 2>/dev/null | head -n 1
}

# ---------- hostnames ----------
# EXTRA_DOMAINS = comma-separated host/authhost/cookiedomain entries added by `goose add-domain`.
app_hosts() {
  printf '%s\n' "$APP_HOST"
  local e
  for e in $(printf '%s' "$EXTRA_DOMAINS" | tr ',' ' '); do printf '%s\n' "${e%%/*}"; done
}
# cookie|authhost|first app host, one line per cookie domain, primary first.
cookie_domains() {
  printf '%s|%s|%s\n' "$BASE_DOMAIN" "$AUTH_HOST" "$APP_HOST"
  local e host auth cookie seen="|$BASE_DOMAIN|"
  for e in $(printf '%s' "$EXTRA_DOMAINS" | tr ',' ' '); do
    host=${e%%/*}
    auth=${e#*/}
    auth=${auth%%/*}
    cookie=${e##*/}
    case "$seen" in *"|$cookie|"*) continue ;; esac
    seen="$seen$cookie|"
    printf '%s|%s|%s\n' "$cookie" "$auth" "$host"
  done
}
auth_hosts() { cookie_domains | cut -d'|' -f2; }
internal_hosts() { { app_hosts; auth_hosts; } | awk '!seen[$0]++'; }
port_suffix() { [ "$HTTPS_PORT" = 443 ] || printf ':%s' "$HTTPS_PORT"; }
https_url() { printf 'https://%s%s' "$1" "$(port_suffix)"; }
join_by() { local sep=$1 out="" x; while IFS= read -r x; do [ -n "$x" ] && out="${out:+$out$sep}$x"; done; printf '%s' "$out"; }

# ---------- answers (interactive prompts or --config answers.env) ----------
INTERACTIVE=0
ANSWERS_FILE=""
ans_has() { [ -n "$ANSWERS_FILE" ] && grep -q "^$1=" "$ANSWERS_FILE"; }
ans_get() {
  local v
  v=$(sed -n "s/^$1=//p" "$ANSWERS_FILE" | tail -n 1)
  v=${v%$'\r'}
  case "$v" in \"*\") v=${v#\"} v=${v%\"} ;; \'*\') v=${v#\'} v=${v%\'} ;; esac
  printf '%s' "$v"
}
# ask KEY "Question" default validator "what is valid"  → prints the answer (prompts go to stderr)
ask() {
  local key=$1 q=$2 def=${3-} check=${4:-is_any} what=${5:-a valid value} v
  if ans_has "$key"; then
    v=$(ans_get "$key")
    [ -n "$v" ] || v=$def
    "$check" "$v" || die "answers file: $key=$v is not $what"
    printf '%s' "$v"
    return 0
  fi
  if [ "$INTERACTIVE" != 1 ]; then
    "$check" "$def" || die "answers file needs $key ($q)"
    printf '%s' "$def"
    return 0
  fi
  while :; do
    printf '%s?%s %s%s: ' "$C_BOLD" "$C_0" "$q" "${def:+ [$def]}" >&2
    IFS= read -r v </dev/tty || v=""
    [ -n "$v" ] || v=$def
    if "$check" "$v"; then printf '%s' "$v"; return 0; fi
    printf '  %sPlease enter %s.%s\n' "$C_Y" "$what" "$C_0" >&2
  done
}
ask_yn() { # ask_yn KEY "Question" y|n → exit status
  local v
  v=$(ask "$1" "$2 (y/n)" "$3" is_yesno "yes or no")
  truthy "$v"
}
ask_secret() { # ask_secret KEY "Question" → prints value (may be empty)
  local key=$1 q=$2 v
  if ans_has "$key"; then ans_get "$key"; return 0; fi
  [ "$INTERACTIVE" = 1 ] || return 0
  printf '%s?%s %s: ' "$C_BOLD" "$C_0" "$q" >&2
  IFS= read -r -s v </dev/tty || v=""
  printf '\n' >&2
  printf '%s' "$v"
}
# A new password: typed twice (hidden), or empty → generated. Sets NEW_PASSWORD and NEW_PASSWORD_GENERATED.
read_new_password() { # read_new_password KEY label
  local key=$1 label=$2 a b
  NEW_PASSWORD="" NEW_PASSWORD_GENERATED=0
  if ans_has "$key"; then
    NEW_PASSWORD=$(ans_get "$key")
  elif [ "$INTERACTIVE" = 1 ]; then
    while :; do
      printf '%s?%s Password for %s (min. 12 characters; leave empty to generate one): ' "$C_BOLD" "$C_0" "$label" >&2
      IFS= read -r -s a </dev/tty || a=""
      printf '\n' >&2
      [ -n "$a" ] || break
      if [ "${#a}" -lt 12 ]; then printf '  %sAt least 12 characters, please.%s\n' "$C_Y" "$C_0" >&2; continue; fi
      printf '%s?%s Repeat it: ' "$C_BOLD" "$C_0" >&2
      IFS= read -r -s b </dev/tty || b=""
      printf '\n' >&2
      [ "$a" = "$b" ] && { NEW_PASSWORD=$a; break; }
      printf '  %sThey differ; try again.%s\n' "$C_Y" "$C_0" >&2
    done
  fi
  if [ -z "$NEW_PASSWORD" ]; then
    NEW_PASSWORD=$(gen_password)
    NEW_PASSWORD_GENERATED=1
  fi
  [ "${#NEW_PASSWORD}" -ge 12 ] || die "the password for $label must have at least 12 characters"
}

# ---------- docker ----------
dc() { docker compose --project-directory "$GOOSE_DIR" -f "$GOOSE_DIR/compose.yml" --env-file "$ENV_FILE" "$@"; }
docker_ok() { [ -z "${GOOSE_NO_DOCKER:-}" ] && have docker && docker info >/dev/null 2>&1; }
need_docker() {
  [ -z "${GOOSE_NO_DOCKER:-}" ] || die "this needs Docker, and GOOSE_NO_DOCKER is set"
  have docker || die "docker is not installed (https://docs.docker.com/engine/install/)"
  docker info >/dev/null 2>&1 || die "docker is installed but the daemon isn't reachable (is it running? are you in the docker group?)"
  docker compose version >/dev/null 2>&1 || die "docker compose v2 is missing (install the docker-compose-plugin package)"
}
running() { [ -n "$(dc ps -q --status running "$1" 2>/dev/null)" ]; }

# ---------- passwords (argon2id, the format Authelia expects) ----------
# The password goes in on stdin only, never on a command line (where `ps` would show it).
ARGON2_JS='const c=require("node:crypto");let d="";process.stdin.on("data",x=>d+=x).on("end",()=>{const nonce=c.randomBytes(16);const tag=c.argon2Sync("argon2id",{message:Buffer.from(d,"utf8"),nonce,parallelism:4,tagLength:32,memory:65536,passes:3});const b=x=>x.toString("base64").replace(/=+$/,"");process.stdout.write("$argon2id$v=19$m=65536,t=3,p=4$"+b(nonce)+"$"+b(tag)+"\n")})'
hash_password() { # stdin: password → stdout: $argon2id$...
  local out
  if have node && node -e 'process.exit(typeof require("node:crypto").argon2Sync==="function"?0:1)' 2>/dev/null; then
    out=$(node -e "$ARGON2_JS")
  else
    need_docker
    out=$(docker run --rm -i "$NODE_IMAGE" node -e "$ARGON2_JS")
  fi
  case "$out" in '$argon2id$v=19$'*) printf '%s' "$out" ;; *) die "could not hash the password" ;; esac
}

# ---------- Authelia users file ----------
users_indent() { # indentation of the user keys under `users:` (2 by default)
  awk '/^users:[[:space:]]*$/ { u = 1; next }
    u && /^[[:space:]]*(#|$)/ { next }
    u { match($0, /^ */); print RLENGTH; exit }' "$USERS_FILE" 2>/dev/null | grep . || printf '2\n'
}
users_list() {
  [ -f "$USERS_FILE" ] || return 0
  local n
  n=$(users_indent)
  awk -v n="$n" '/^users:[[:space:]]*$/ { u = 1; next }
    /^[^[:space:]#]/ { u = 0 }
    u { match($0, /^ */); if (RLENGTH == n && $0 ~ /:[[:space:]]*$/) { s = substr($0, n + 1); sub(/:[[:space:]]*$/, "", s); gsub(/["'"'"']/, "", s); print s } }' "$USERS_FILE"
}
user_exists() { users_list | grep -qx "$1"; }
users_write() { # stdin → users file, replaced in place (same inode) so Authelia's watch and bind mounts see it
  local tmp="$USERS_FILE.tmp.$$"
  cat >"$tmp"
  grep -q '^users:' "$tmp" || { rm -f "$tmp"; die "internal: users file would lose its users: key"; }
  if [ -f "$USERS_FILE" ]; then cat "$tmp" >"$USERS_FILE"; rm -f "$tmp"; else mv "$tmp" "$USERS_FILE"; fi
  chmod 600 "$USERS_FILE"
}
user_block() { # user_block name displayname email groups(comma) hash indent
  local n=$6 p="" q="" g
  p=$(printf '%*s' "$n" '')
  q=$(printf '%*s' "$((n * 2))" '')
  printf '%s%s:\n' "$p" "$1"
  printf '%sdisabled: false\n' "$q"
  printf '%sdisplayname: %s\n' "$q" "$(yaml_str "$2")"
  printf '%spassword: %s\n' "$q" "$(yaml_str "$5")"
  printf '%semail: %s\n' "$q" "$(yaml_str "$3")"
  if [ -n "$4" ]; then
    printf '%sgroups:\n' "$q"
    for g in $(printf '%s' "$4" | tr ',' ' '); do printf '%s  - %s\n' "$q" "$(yaml_str "$g")"; done
  else
    printf '%sgroups: []\n' "$q"
  fi
}
user_add() { # name displayname email groups hash
  local n
  [ -f "$USERS_FILE" ] || printf '# Authelia users (local accounts). Managed by goose add-user / passwd / remove-user.\nusers:\n' >"$USERS_FILE"
  n=$(users_indent)
  if grep -Eq '^users:[[:space:]]*\{\}[[:space:]]*$' "$USERS_FILE"; then
    { sed 's/^users:.*/users:/' "$USERS_FILE"; user_block "$1" "$2" "$3" "$4" "$5" "$n"; } | users_write
  else
    { cat "$USERS_FILE"; user_block "$1" "$2" "$3" "$4" "$5" "$n"; } | users_write
  fi
}
user_remove() {
  local n
  n=$(users_indent)
  awk -v n="$n" -v name="$1" '
    /^users:[[:space:]]*$/ { u = 1; print; next }
    /^[^[:space:]#]/ { u = 0; skip = 0 }
    u { match($0, /^ */); ind = RLENGTH
        if ($0 !~ /^[[:space:]]*$/ && ind <= n) {
          s = substr($0, n + 1); sub(/:[[:space:]]*$/, "", s); gsub(/["'"'"']/, "", s)
          skip = (ind == n && s == name) } }
    !skip { print }' "$USERS_FILE" | users_write
}
user_set_password() { # name hash
  local n
  n=$(users_indent)
  GOOSE_HASH=$2 awk -v n="$n" -v name="$1" 'BEGIN { hash = ENVIRON["GOOSE_HASH"] }
    /^users:[[:space:]]*$/ { u = 1; print; next }
    /^[^[:space:]#]/ { u = 0; inuser = 0 }
    u { match($0, /^ */); ind = RLENGTH
        if ($0 !~ /^[[:space:]]*$/ && ind <= n) { s = substr($0, n + 1); sub(/:[[:space:]]*$/, "", s); gsub(/["'"'"']/, "", s); inuser = (ind == n && s == name) }
        else if (inuser && ind == 2 * n && $0 ~ /^[[:space:]]*password:/) { printf "%*spassword: \"%s\"\n", 2 * n, "", hash; done = 1; next } }
    { print }
    END { if (!done) exit 3 }' "$USERS_FILE" | users_write || die "user $1 has no password line to replace"
}
user_email() {
  local n
  n=$(users_indent)
  awk -v n="$n" -v name="$1" '
    /^users:[[:space:]]*$/ { u = 1; next }
    /^[^[:space:]#]/ { u = 0 }
    u { match($0, /^ */); ind = RLENGTH
        if ($0 !~ /^[[:space:]]*$/ && ind <= n) { s = substr($0, n + 1); sub(/:[[:space:]]*$/, "", s); gsub(/["'"'"']/, "", s); inuser = (ind == n && s == name) }
        else if (inuser && $0 ~ /^[[:space:]]*email:/) { v = $0; sub(/^[[:space:]]*email:[[:space:]]*/, "", v); gsub(/["'"'"']/, "", v); print v; exit } }' "$USERS_FILE"
}
# After the users file changed: Authelia reloads it by itself (watch: true); confirm that, else restart it.
authelia_reload_users() {
  docker_ok && [ -f "$ENV_FILE" ] && running authelia || {
    note "Authelia isn't running; it reads the users file when it starts (./goose up)."
    return 0
  }
  local since i
  since=$1
  for i in 1 2 3 4 5 6 7 8 9 10; do
    if dc logs --no-log-prefix --since "$since" authelia 2>/dev/null | grep -q 'Reloaded successfully.*users_database'; then
      ok "Authelia reloaded the users file"
      return 0
    fi
    sleep 1
  done
  warn "Authelia didn't report a reload within 10s (file events don't always cross bind mounts); restarting it"
  dc restart authelia >/dev/null
  wait_healthy authelia 60 && ok "Authelia restarted with the new users file"
}
wait_healthy() { # service seconds; gives up early on a container that is unhealthy, exited or crash-looping
  local cid s i=0
  while [ "$i" -lt "$2" ]; do
    cid=$(dc ps -aq "$1" 2>/dev/null | head -n 1 || true)
    if [ -n "$cid" ]; then
      s=$(docker inspect -f '{{.State.Status}}/{{if .State.Health}}{{.State.Health.Status}}{{end}}' "$cid" 2>/dev/null || true)
      case "$s" in
        running/healthy | running/) return 0 ;;
        exited/* | dead/* | restarting/* | */unhealthy)
          warn "$1 is ${s%/}: ./goose logs $1"
          return 1
          ;;
      esac
    fi
    sleep 1
    i=$((i + 1))
  done
  warn "$1 is not healthy after $2s: ./goose logs $1"
  return 1
}

# ---------- rendering ----------
# render <template> <output>: {{NAME}} ← $T_NAME (exported). Unknown placeholders fail loudly.
render() {
  local tmp="$2.tmp.$$"
  awk '{ line = $0; out = ""
      while (match(line, /\{\{[A-Z0-9_]+\}\}/)) {
        key = substr(line, RSTART + 2, RLENGTH - 4)
        if (!(("T_" key) in ENVIRON)) { printf "unrendered placeholder {{%s}} in %s\n", key, FILENAME > "/dev/stderr"; exit 3 }
        out = out substr(line, 1, RSTART - 1) ENVIRON["T_" key]; line = substr(line, RSTART + RLENGTH) }
      print out line }' "$1" >"$tmp" || { rm -f "$tmp"; die "rendering $1 failed"; }
  chmod 600 "$tmp"
  mv "$tmp" "$2"
}

smtp_scheme() { case "$1" in 465) printf 'submissions' ;; 25) printf 'smtp' ;; *) printf 'submission' ;; esac; }

render_all() {
  load_settings
  mkdir -p "$CONFIG_DIR/authelia" "$CONFIG_DIR/caddy" "$SECRETS_DIR"
  local hosts cookies notifier line cookie auth first
  export T_GENERATED_AT T_GOOSE_MODE T_ACME_EMAIL T_GLOBAL_EXTRA T_TLS_BLOCK T_APP_SITES T_AUTH_SITES T_INTERNAL_INGEST
  export T_AUTHELIA_VERSION T_ACCESS_RULES T_SESSION_COOKIES T_NOTIFIER T_PUBLIC_URL T_EDITION T_INGEST_SECRET
  export T_PROXY_SECRET T_ADMIN_EMAIL T_MAIL_ENV T_SMTP_PASSWORD_ENV
  T_GENERATED_AT=$(now_utc)
  T_GOOSE_MODE=$GOOSE_MODE
  T_ACME_EMAIL=${ACME_EMAIL:-$ADMIN_EMAIL}
  T_AUTHELIA_VERSION=$AUTHELIA_VERSION
  if [ "$GOOSE_MODE" = test ]; then
    T_GLOBAL_EXTRA=$'\t# test mode: certificates from Caddy\'s own local CA, nothing asks Cloudflare\n\tskip_install_trust'
    T_TLS_BLOCK=$'\ttls internal'
  else
    T_GLOBAL_EXTRA=$'\t# production: Let\'s Encrypt, DNS-01 at Cloudflare (CF_API_TOKEN: Zone.Zone Read + Zone.DNS Edit)'
    T_TLS_BLOCK=$'\ttls {\n\t\tdns cloudflare {env.CF_API_TOKEN}\n\t\tresolvers 1.1.1.1 1.0.0.1\n\t}'
  fi
  T_APP_SITES=$(app_hosts | join_by ', ')
  T_AUTH_SITES=$(auth_hosts | awk '!seen[$0]++' | join_by ', ')
  if [ -n "$INTAKE_HOST" ]; then
    T_INTERNAL_INGEST=$'\t\t# /ats/ingest is not served here: applications arrive through the Cloudflare Tunnel\n\t\t# (https://'"$INTAKE_HOST"$'/ats/ingest → cloudflared → gooseplicant:3001).'
  else
    T_INTERNAL_INGEST=$'\t\t# No public intake hostname: a website on your network posts signed applications here, without\n\t\t# a login (the HMAC signature is the check). Only the intake-only listener (3001) answers it.\n\t\t@ingest {\n\t\t\tmethod POST\n\t\t\tpath /ats/ingest\n\t\t}\n\t\treverse_proxy @ingest gooseplicant:3001'
  fi
  hosts=""
  while IFS= read -r line; do hosts="$hosts        - '$line'"$'\n'; done <<EOF
$(app_hosts)
EOF
  T_ACCESS_RULES="    - domain:"$'\n'"${hosts}      policy: '$AUTH_POLICY'"
  cookies=""
  while IFS='|' read -r cookie auth first; do
    [ -n "$cookie" ] || continue
    cookies="$cookies    - domain: '$cookie'"$'\n'"      authelia_url: '$(https_url "$auth")'"$'\n'"      default_redirection_url: '$(https_url "$first")/ats/'"$'\n'
  done <<EOF
$(cookie_domains)
EOF
  T_SESSION_COOKIES=${cookies%$'\n'}
  T_SMTP_PASSWORD_ENV="# no SMTP password (filesystem notifier or SMTP without login)"
  if [ -n "$SMTP_HOST" ]; then
    notifier="  smtp:"$'\n'"    address: '$(smtp_scheme "$SMTP_PORT")://$SMTP_HOST:$SMTP_PORT'"$'\n'"    sender: $(yaml_str "Gooseplicant <$SMTP_SENDER>")"$'\n'"    subject: '[Gooseplicant] {title}'"
    if [ -n "$SMTP_USER" ]; then
      notifier="$notifier"$'\n'"    username: $(yaml_str "$SMTP_USER")"$'\n'"    # password: AUTHELIA_NOTIFIER_SMTP_PASSWORD_FILE"
      T_SMTP_PASSWORD_ENV="AUTHELIA_NOTIFIER_SMTP_PASSWORD_FILE=/run/secrets/authelia_smtp_password"
    fi
  else
    notifier="  # No SMTP: one-time codes and reset links are written here; read them with ./goose code"$'\n'"  filesystem:"$'\n'"    filename: '/var/lib/authelia/notification.txt'"
  fi
  T_NOTIFIER=$notifier
  T_PUBLIC_URL="$(https_url "$APP_HOST")"
  T_EDITION=$EDITION
  T_INGEST_SECRET=$INGEST_SECRET
  T_PROXY_SECRET=$PROXY_SECRET
  T_ADMIN_EMAIL=$ADMIN_EMAIL
  T_MAIL_ENV="# No outgoing mail configured for Gooseplicant (re-run ./goose setup to add SMTP)."
  if [ -n "$SMTP_HOST" ] && truthy "$APP_MAIL"; then
    local sender_addr=$SMTP_SENDER cred="" pw
    if [ -n "$SMTP_USER" ]; then
      pw=$(cat "$SECRETS_DIR/authelia_smtp_password" 2>/dev/null || true)
      cred="$(urlencode "$SMTP_USER"):$(urlencode "$pw")@"
    fi
    local scheme=smtp
    [ "$SMTP_PORT" = 465 ] && scheme=smtps
    T_MAIL_ENV="MAIL_FROM=$sender_addr"$'\n'"SMTP_URL=$scheme://$cred$SMTP_HOST:$SMTP_PORT"
  fi
  # Directories are bind-mounted (not single files): a file replaced by rename stays stale in a container.
  render "$TEMPLATES/Caddyfile.tmpl" "$CONFIG_DIR/caddy/Caddyfile"
  render "$TEMPLATES/authelia/configuration.yml.tmpl" "$CONFIG_DIR/authelia/configuration.yml"
  render "$TEMPLATES/gooseplicant.env.tmpl" "$CONFIG_DIR/gooseplicant.env"
  render "$TEMPLATES/authelia.env.tmpl" "$CONFIG_DIR/authelia.env"
  # Compose only starts cloudflared (profile public-intake) when there is a tunnel token.
  if [ -n "$INTAKE_HOST" ] && [ -n "$TUNNEL_TOKEN" ]; then env_set COMPOSE_PROFILES public-intake; else env_set COMPOSE_PROFILES ""; fi
}

ensure_secrets() {
  mkdir -p "$SECRETS_DIR"
  chmod 700 "$SECRETS_DIR"
  local f
  for f in $SECRET_FILES; do
    if [ ! -s "$SECRETS_DIR/$f" ]; then
      gen_hex 32 | tr -d '\n' >"$SECRETS_DIR/$f"
      note "generated secrets/$f"
    fi
    chmod 600 "$SECRETS_DIR/$f"
  done
  [ -f "$SECRETS_DIR/authelia_smtp_password" ] || : >"$SECRETS_DIR/authelia_smtp_password"
  chmod 600 "$SECRETS_DIR/authelia_smtp_password"
  local k
  for k in INGEST_SECRET PROXY_SECRET; do
    if [ -z "$(env_get "$k")" ]; then
      env_set "$k" "$(gen_hex 32)"
      note "generated $k"
    fi
  done
}

# caddy-dns/cloudflare checks the token's FORMAT while loading the config (legacy tokens: 35-50 of A-Za-z0-9_-;
# newer ones: cfut_/cfat_ + 32-256 of them). Validation must not see the real token, so it gets a well-formed
# placeholder; nothing contacts Cloudflare during `caddy validate`.
CADDY_VALIDATE_TOKEN=gooseplicantvalidateonly0000000000000000
cf_token_format_ok() { # cf_token_format_ok TOKEN
  [[ $1 =~ ^[A-Za-z0-9_-]{35,50}$ ]] || [[ $1 =~ ^cf(ut|at)_[A-Za-z0-9_-]{32,256}$ ]]
}
# Common paste mistakes: spaces/newlines, surrounding quotes, a copied "Bearer " prefix.
clean_cf_token() { # clean_cf_token RAW → cleaned on stdout
  local t
  t=$(printf '%s' "$1" | tr -d '[:space:]')
  t=${t#Bearer}
  t=${t#\"}; t=${t%\"}; t=${t#\'}; t=${t%\'}
  printf '%s' "$t"
}

validate_configs() {
  docker_ok || { note "docker not reachable: skipped authelia validate-config (./goose up validates on start)"; return 0; }
  local out
  if out=$(docker run --rm -v "$CONFIG_DIR/authelia:/config:ro" -v "$SECRETS_DIR:/run/secrets:ro" \
    --env-file "$CONFIG_DIR/authelia.env" "$AUTHELIA_IMAGE" authelia validate-config --config /config/configuration.yml 2>&1); then
    ok "Authelia configuration valid ($AUTHELIA_IMAGE validate-config)"
  else
    printf '%s\n' "$out" >&2
    die "Authelia rejected config/authelia/configuration.yml (see above)"
  fi
  if docker image inspect "$CADDY_IMAGE" >/dev/null 2>&1; then
    if out=$(docker run --rm -e CF_API_TOKEN="$CADDY_VALIDATE_TOKEN" -e ATS_PROXY_SECRET=x -v "$CONFIG_DIR/caddy:/etc/caddy:ro" "$CADDY_IMAGE" \
      caddy validate --config /etc/caddy/Caddyfile --adapter caddyfile 2>&1); then
      ok "Caddyfile valid (caddy validate)"
    else
      printf '%s\n' "$out" >&2
      die "Caddy rejected config/caddy/Caddyfile (see above)"
    fi
  fi
}

# ---------- Cloudflare API ----------
# cf METHOD PATH [json] → CF_STATUS (HTTP code), CF_BODY. The token goes in a curl config file, not argv.
CF_STATUS="" CF_BODY=""
cf() {
  local t auth
  t=$(tmpdir)
  auth="$t/cf-auth"
  printf 'header = "Authorization: Bearer %s"\n' "$CF_API_TOKEN" >"$auth"
  if [ -n "${3:-}" ]; then
    printf '%s' "$3" >"$t/cf-req.json"
    CF_BODY=$(curl -sS --max-time 30 -K "$auth" -X "$1" -H 'Content-Type: application/json' --data-binary "@$t/cf-req.json" -w '\n%{http_code}' "$CF_API$2" 2>"$t/cf-err") || CF_BODY=$'\n000'
  else
    CF_BODY=$(curl -sS --max-time 30 -K "$auth" -X "$1" -w '\n%{http_code}' "$CF_API$2" 2>"$t/cf-err") || CF_BODY=$'\n000'
  fi
  CF_STATUS=${CF_BODY##*$'\n'}
  CF_BODY=${CF_BODY%$'\n'*}
  [ "$CF_STATUS" != 000 ] || CF_BODY="{\"errors\":[{\"message\":$(json_str "$(head -c 300 "$t/cf-err")")}]}"
  case "$CF_STATUS" in 2*) [ "$(printf '%s' "$CF_BODY" | json_get success)" = true ] ;; *) return 1 ;; esac
}
cf_get() { printf '%s' "$CF_BODY" | json_get "$1"; }
cf_error() {
  local m c
  m=$(cf_get errors.0.message)
  c=$(cf_get errors.0.code)
  printf 'HTTP %s%s%s' "$CF_STATUS" "${c:+, code $c}" "${m:+: $m}"
}
CF_ZONES="" ZONE_ID="" CF_ZONE_NAME="" # cache: ;name=id;
cf_zone_for() { # host → ZONE_ID + CF_ZONE_NAME (walking up the labels); status 1 when none is visible
  local cand=$1 id
  ZONE_ID="" CF_ZONE_NAME=""
  while :; do
    case "$CF_ZONES" in *";$cand="*)
      id=${CF_ZONES#*;"$cand"=}
      ZONE_ID=${id%%;*}
      CF_ZONE_NAME=$cand
      return 0
      ;;
    esac
    if cf GET "/zones?name=$cand&status=active"; then
      id=$(cf_get result.0.id)
      if [ -n "$id" ]; then
        CF_ZONES="$CF_ZONES;$cand=$id;"
        [ -n "$CF_ACCOUNT_ID" ] || CF_ACCOUNT_ID=$(cf_get result.0.account.id)
        ZONE_ID=$id
        CF_ZONE_NAME=$cand
        return 0
      fi
    fi
    case "$cand" in *.*.*) cand=${cand#*.} ;; *) return 1 ;; esac
  done
}

# ----- walkthroughs (the website guide reuses these texts) -----
wt_cloudflare_token() {
  wt_begin "Create a Cloudflare API token"
  wt "Open https://dash.cloudflare.com/profile/api-tokens and click \"Create Token\"."
  wt "Next to \"Edit zone DNS\" click \"Use template\" (or \"Create Custom Token\" → \"Get started\")."
  wt "Permissions: keep or add these rows:" \
    "Zone · DNS · Edit" \
    "Zone · Zone · Read" \
    "For the public intake tunnel (optional) also add:" \
    "Account · Cloudflare Tunnel · Edit"
  wt "Zone Resources: Include · Specific zone · ${1:-example.com}" \
    "(add every zone that holds a Gooseplicant hostname)." \
    "Account Resources (only needed with the tunnel row): Include · your account."
  wt "Optional: Client IP Address Filtering → this server's public IP address."
  wt "\"Continue to summary\" → \"Create Token\". Copy the token: Cloudflare shows it only once."
  wt "Run ./goose setup again and paste it when asked (non-interactive: CF_API_TOKEN=... in the answers file)."
  wt_end
}
wt_dns_records() { # lines "type|name|content|proxy"
  local zone=$1 rows=$2 t n c p
  wt_begin "Add DNS records in Cloudflare (zone $zone)"
  wt "Cloudflare dashboard → $zone → DNS → Records → \"Add record\", once per row:"
  while IFS='|' read -r t n c p; do
    [ -n "$t" ] || continue
    wt_text "      Type $t   Name $n   ${t} content $c   Proxy status: $p   TTL: Auto"
  done <<EOF
$rows
EOF
  wt "Proxy status must be \"DNS only\" (grey cloud) for the internal names: staff connect straight to this" \
    "server, and Cloudflare can't proxy to a private address. The address is private, so nobody outside your" \
    "network or tailnet can connect even though the name is public."
  wt "Prefer not to publish private addresses? Skip this and add the same names to your local DNS instead" \
    "(router, Pi-hole, Tailscale split DNS). Certificates still work: DNS-01 only needs a TXT record at Cloudflare."
  wt "Check with: ./goose doctor"
  wt_end
}
wt_tunnel() {
  local host=$1 sub zone
  sub=${host%%.*}
  zone=${host#*.}
  wt_begin "Create the Cloudflare Tunnel for the public intake hostname $host"
  wt "Open https://one.dash.cloudflare.com → Networks → Tunnels → \"Create a tunnel\"."
  wt "Connector type \"Cloudflared\" → Next. Name it $TUNNEL_NAME_DEFAULT → \"Save tunnel\"."
  wt "Under \"Install and run connectors\" pick \"Docker\". The command shown ends with --token eyJ... :" \
    "copy only that token (the long string after --token). Don't run the command: goose runs cloudflared."
  wt "Next → Public hostname (\"Route traffic\"):" \
    "Subdomain: $sub   Domain: $zone   Path: ^/ats/(ingest|api/health)\$" \
    "Service type: HTTP   URL: gooseplicant:3001" \
    "→ \"Save tunnel\". Cloudflare adds the proxied CNAME $host → <tunnel-id>.cfargotunnel.com itself."
  wt "Paste the token when ./goose setup asks for it (or run ./goose setup again later)." \
    "Then ./goose up starts cloudflared, and ./goose doctor checks the tunnel."
  wt_end
}
cf_dns_plan_and_apply() { # $1 = space-separated hostnames (A → BIND_IP, DNS only)
  local hosts=$1 h zone plan="" manual="" i n typ content proxied rid changes=0 manual_zone=""
  for h in $hosts; do
    cf_zone_for "$h" && zone=$ZONE_ID || {
      warn "no Cloudflare zone for $h visible to this token"
      manual="${manual}A|$h|$BIND_IP|DNS only"$'\n'
      manual_zone=${manual_zone:-$(parent_of "$h")}
      continue
    }
    if ! cf GET "/zones/$zone/dns_records?name=$h&per_page=50"; then
      warn "can't read DNS records of $CF_ZONE_NAME ($(cf_error)): the token needs Zone · DNS · Edit"
      manual="${manual}A|$h|$BIND_IP|DNS only"$'\n'
      manual_zone=${manual_zone:-$CF_ZONE_NAME}
      continue
    fi
    n=$(cf_get result.#)
    rid="" typ=""
    i=0
    while [ "$i" -lt "${n:-0}" ]; do
      typ=$(cf_get "result.$i.type")
      if [ "$typ" = A ]; then
        rid=$(cf_get "result.$i.id")
        content=$(cf_get "result.$i.content")
        proxied=$(cf_get "result.$i.proxied")
        break
      fi
      i=$((i + 1))
    done
    if [ -n "$rid" ]; then
      if [ "$content" = "$BIND_IP" ] && [ "$proxied" = false ]; then
        ok "DNS $h A $BIND_IP (DNS only) already in place"
      else
        plan="${plan}update|$zone|$rid|$h|A $content$([ "$proxied" != true ] || printf ' (proxied)') → A $BIND_IP (DNS only)"$'\n'
      fi
    elif [ "${n:-0}" -gt 0 ]; then
      warn "$h already has a $typ record in Cloudflare; goose won't delete it. Remove it, then re-run (or keep it if it's right)."
      manual="${manual}A|$h|$BIND_IP|DNS only"$'\n'
      manual_zone=${manual_zone:-$CF_ZONE_NAME}
    else
      plan="${plan}create|$zone||$h|A $BIND_IP (DNS only)"$'\n'
    fi
  done
  if [ -n "$plan" ]; then
    say "  goose can make these DNS changes in Cloudflare:"
    printf '%s' "$plan" | while IFS='|' read -r a z r h d; do [ -n "$a" ] && printf '      %-7s %-34s %s\n' "$a" "$h" "$d"; done
    is_private_ip "$BIND_IP" && note "$BIND_IP is a private address: the names become public, the server does not."
    if ask_yn CF_CREATE_DNS "Apply these DNS changes via the Cloudflare API?" n; then
      while IFS='|' read -r a z r h d; do
        [ -n "$a" ] || continue
        local body
        body="{\"type\":\"A\",\"name\":$(json_str "$h"),\"content\":$(json_str "$BIND_IP"),\"ttl\":1,\"proxied\":false,\"comment\":\"Gooseplicant internal hostname (goose)\"}"
        if [ "$a" = create ]; then cf POST "/zones/$z/dns_records" "$body" && ok "created A $h → $BIND_IP" && changes=$((changes + 1)) && continue
        else cf PUT "/zones/$z/dns_records/$r" "$body" && ok "updated A $h → $BIND_IP" && changes=$((changes + 1)) && continue; fi
        warn "Cloudflare refused the change for $h ($(cf_error)); the token needs Zone · DNS · Edit for that zone"
        manual="${manual}A|$h|$BIND_IP|DNS only"$'\n'
        manual_zone=${manual_zone:-$(parent_of "$h")}
      done <<EOF
$plan
EOF
    else
      while IFS='|' read -r a z r h d; do [ -n "$a" ] && manual="${manual}A|$h|$BIND_IP|DNS only"$'\n'; done <<EOF
$plan
EOF
      manual_zone=${manual_zone:-$BASE_DOMAIN}
    fi
  fi
  [ -z "$manual" ] || wt_dns_records "${manual_zone:-$BASE_DOMAIN}" "$manual"
}

cf_tunnel_setup() {
  local name acc id tok zone body rec n i typ rid content
  name=$(ask TUNNEL_NAME "Tunnel name" "$TUNNEL_NAME_DEFAULT" is_user "lowercase letters, digits, . _ -")
  acc=$CF_ACCOUNT_ID
  if [ -z "$acc" ] || ! cf GET "/accounts/$acc/cfd_tunnel?is_deleted=false&name=$name"; then
    warn "this token can't manage tunnels${acc:+ ($(cf_error))}: it needs Account · Cloudflare Tunnel · Edit"
    return 1
  fi
  id=$(cf_get result.0.id)
  say "  goose can set up the public intake in Cloudflare:"
  if [ -n "$id" ]; then printf '      reuse   tunnel %-26s id %s\n' "$name" "$id"; else printf '      create  tunnel %-26s (remotely managed, cloudflared runs here)\n' "$name"; fi
  printf '      set     ingress %s (path ^/ats/(ingest|api/health)$) → http://gooseplicant:3001, anything else → 404\n' "$INTAKE_HOST"
  printf '      upsert  CNAME %s → <tunnel-id>.cfargotunnel.com (proxied)\n' "$INTAKE_HOST"
  ask_yn CF_CREATE_TUNNEL "Create/update the tunnel via the Cloudflare API?" n || return 1
  if [ -z "$id" ]; then
    cf POST "/accounts/$acc/cfd_tunnel" "{\"name\":$(json_str "$name"),\"config_src\":\"cloudflare\"}" ||
      { warn "creating the tunnel failed ($(cf_error))"; return 1; }
    id=$(cf_get result.id)
    ok "created tunnel $name ($id)"
  else
    ok "reusing tunnel $name ($id)"
  fi
  body="{\"config\":{\"ingress\":[{\"hostname\":$(json_str "$INTAKE_HOST"),\"path\":\"^/ats/(ingest|api/health)\$\",\"service\":\"http://gooseplicant:3001\",\"originRequest\":{}},{\"service\":\"http_status:404\"}]}}"
  cf PUT "/accounts/$acc/cfd_tunnel/$id/configurations" "$body" || { warn "setting the tunnel ingress failed ($(cf_error))"; return 1; }
  ok "tunnel ingress: $INTAKE_HOST → http://gooseplicant:3001 (everything else 404)"
  cf_zone_for "$INTAKE_HOST" || { warn "no Cloudflare zone for $INTAKE_HOST"; return 1; }
  zone=$ZONE_ID
  rec="{\"type\":\"CNAME\",\"name\":$(json_str "$INTAKE_HOST"),\"content\":\"$id.cfargotunnel.com\",\"ttl\":1,\"proxied\":true,\"comment\":\"Gooseplicant public intake (goose)\"}"
  cf GET "/zones/$zone/dns_records?name=$INTAKE_HOST&per_page=50" || { warn "reading DNS records failed ($(cf_error))"; return 1; }
  n=$(cf_get result.#)
  rid="" i=0
  while [ "$i" -lt "${n:-0}" ]; do
    typ=$(cf_get "result.$i.type")
    if [ "$typ" = CNAME ]; then rid=$(cf_get "result.$i.id"); content=$(cf_get "result.$i.content"); break; fi
    i=$((i + 1))
  done
  if [ -n "$rid" ] && [ "$content" = "$id.cfargotunnel.com" ]; then
    ok "DNS $INTAKE_HOST CNAME → tunnel already in place"
  elif [ -n "$rid" ]; then
    cf PUT "/zones/$zone/dns_records/$rid" "$rec" || { warn "updating the CNAME failed ($(cf_error))"; return 1; }
    ok "updated CNAME $INTAKE_HOST → $id.cfargotunnel.com (proxied)"
  elif [ "${n:-0}" -gt 0 ]; then
    warn "$INTAKE_HOST already has a $(cf_get result.0.type) record; delete it in the dashboard, then re-run ./goose setup"
    return 1
  else
    cf POST "/zones/$zone/dns_records" "$rec" || { warn "creating the CNAME failed ($(cf_error))"; return 1; }
    ok "created CNAME $INTAKE_HOST → $id.cfargotunnel.com (proxied)"
  fi
  cf GET "/accounts/$acc/cfd_tunnel/$id/token" || { warn "fetching the tunnel token failed ($(cf_error))"; return 1; }
  tok=$(cf_get result)
  [ -n "$tok" ] || { warn "Cloudflare returned no tunnel token"; return 1; }
  env_set TUNNEL_ID "$id"
  env_set TUNNEL_TOKEN "$tok"
  env_set CF_ACCOUNT_ID "$acc"
  ok "tunnel token stored in deploy/.env (TUNNEL_TOKEN)"
  return 0
}

tunnel_id_from_token() { # a tunnel token is base64 JSON {"a":account,"t":tunnel,"s":secret}
  printf '%s' "$1" | openssl base64 -d -A 2>/dev/null | sed -n 's/.*"t":"\([^"]*\)".*/\1/p'
}
manual_tunnel_token() {
  local tok id
  wt_tunnel "$INTAKE_HOST"
  tok=$(ask_secret TUNNEL_TOKEN "Paste the tunnel token (leave empty to do this later)")
  tok=$(printf '%s' "$tok" | tr -d '[:space:]')
  tok=${tok#--token}
  if [ -z "$tok" ]; then
    warn "no tunnel token yet: the public intake stays offline until you re-run ./goose setup with one"
    return 0
  fi
  id=$(tunnel_id_from_token "$tok")
  [ -n "$id" ] || die "that doesn't look like a tunnel token (expected the eyJ... string after --token)"
  env_set TUNNEL_TOKEN "$tok"
  env_set TUNNEL_ID "$id"
  ok "tunnel token stored (tunnel $id)"
}

cloudflare_setup() {
  step "Cloudflare: certificates, DNS and the public intake tunnel"
  local tok
  CF_API_TOKEN=$(env_get CF_API_TOKEN)
  if [ -n "$CF_API_TOKEN" ] && ! ans_has CF_API_TOKEN; then
    note "a Cloudflare API token is already stored (deploy/.env CF_API_TOKEN)"
    if [ "$INTERACTIVE" = 1 ] && ask_yn CF_REPLACE_TOKEN "Replace it?" n; then CF_API_TOKEN=""; fi
  fi
  if [ -z "$CF_API_TOKEN" ] || ans_has CF_API_TOKEN; then
    say "  Caddy gets certificates with the ACME DNS-01 challenge at Cloudflare, so the hostnames can stay internal."
    say "  It needs an API token with Zone · Zone · Read + Zone · DNS · Edit (and optionally Account · Cloudflare Tunnel · Edit)."
    tok=$(ask_secret CF_API_TOKEN "Cloudflare API token (leave empty to see how to create one)")
    tok=$(clean_cf_token "$tok")
    if [ -z "$tok" ]; then
      wt_cloudflare_token "$BASE_DOMAIN"
      warn "no token: Caddy can't get certificates until you add one (re-run ./goose setup)"
      wt_dns_records "$BASE_DOMAIN" "$(for h in $(internal_hosts); do printf 'A|%s|%s|DNS only\n' "$h" "$BIND_IP"; done)"
      [ -z "$INTAKE_HOST" ] || [ -n "$(env_get TUNNEL_TOKEN)" ] || manual_tunnel_token
      return 0
    fi
    case "$tok" in *[!A-Za-z0-9_-]*) die "that token has characters Cloudflare tokens don't use" ;; esac
    cf_token_format_ok "$tok" ||
      warn "that doesn't look like a Cloudflare API token (35-50 characters, or cfut_.../cfat_...): Caddy will refuse it. Copy the token itself, not the token ID or the Global API Key"
    CF_API_TOKEN=$tok
    env_set CF_API_TOKEN "$tok"
  fi
  if cf GET /user/tokens/verify && [ "$(cf_get result.status)" = active ]; then
    ok "token is valid and active"
  else
    warn "token verification failed ($(cf_error)); trying to use it anyway"
  fi
  CF_ACCOUNT_ID=$(env_get CF_ACCOUNT_ID)
  if cf_zone_for "$BASE_DOMAIN"; then
    env_set CF_ZONE_ID "$ZONE_ID"
    ok "zone $CF_ZONE_NAME ($ZONE_ID)"
    [ -z "$CF_ACCOUNT_ID" ] || env_set CF_ACCOUNT_ID "$CF_ACCOUNT_ID"
  else
    warn "the token can't see a zone for $BASE_DOMAIN ($(cf_error)); certificates need Zone · Zone · Read + Zone · DNS · Edit on it"
    wt_cloudflare_token "$BASE_DOMAIN"
  fi
  cf_dns_plan_and_apply "$(internal_hosts | tr '\n' ' ')"
  if [ -n "$INTAKE_HOST" ]; then
    if [ -n "$(env_get TUNNEL_TOKEN)" ] && ! ans_has CF_CREATE_TUNNEL && ! ask_yn CF_RECONFIGURE_TUNNEL "A tunnel is already configured. Check/update it in Cloudflare?" n; then
      ok "keeping the configured tunnel ($(env_get TUNNEL_ID))"
    elif ! cf_tunnel_setup; then
      [ -n "$(env_get TUNNEL_TOKEN)" ] && ! ans_has TUNNEL_TOKEN || manual_tunnel_token
    fi
  fi
}

detect_ips() { # prints "ip label" lines
  local ts=""
  if have tailscale; then ts=$(tailscale ip -4 2>/dev/null | head -n 1 || true); fi
  {
    if have ip; then ip -4 -o addr show scope global 2>/dev/null | awk '{ split($4, a, "/"); print a[1] }'
    elif have ifconfig; then ifconfig 2>/dev/null | awk '$1 == "inet" && $2 !~ /^127\./ { print $2 }'; fi
  } | awk '!seen[$0]++' | while IFS= read -r ip; do
    [ -n "$ip" ] || continue
    if [ "$ip" = "$ts" ]; then continue; fi
    case "$ip" in 172.1[7-9].* | 172.2[0-9].* | 172.3[01].*) continue ;; esac # docker bridges
    printf '%s LAN\n' "$ip"
  done
  [ -z "$ts" ] || printf '%s Tailscale\n' "$ts"
}

# ---------- commands ----------
usage() {
  cat <<EOF
${C_BOLD}goose${C_0}: one-box Gooseplicant (Caddy + Authelia + Gooseplicant [+ Cloudflare Tunnel])

  ./goose setup [--config answers.env] [--import-users users_database.yml] [--test | --production]
  ./goose up [--build] | down [--volumes [--yes]] | restart [service] | logs [service] [-f] | status
  ./goose add-user [username] [--name N] [--email E] [--groups a,b] [--password-stdin]
  ./goose passwd <username> [--password-stdin] | remove-user <username> [--yes] | reset-2fa <username>
  ./goose add-domain <hostname> [--auth-host H] [--cookie-domain D] [--yes]
  ./goose doctor | code | connect | render | validate
  ./goose backup [dir] | restore <file> [--yes]
  ./goose update [--check] [--yes] [--to <version>] [--no-rollback]   in place, rolls back by itself on failure
  ./goose update --schedule daily|weekly|off|status [--yes]           unattended updates (cron, 03:17)
  ./goose rollback [--yes]   back to the kit and backup from before the last update
  ./goose upgrade [version|latest] [--files-only] [--yes]   side by side (../gooseplicant-<version>)
  ./goose upgrade-finish   (run by upgrade in the new kit; by hand after --files-only)

Settings and secrets: deploy/.env (mode 600). Generated config: deploy/config/. Reference: deploy/README.md
EOF
}

cmd_setup() {
  local import_users="" test_mode=0 production=0
  while [ $# -gt 0 ]; do
    case "$1" in
      --config) ANSWERS_FILE=${2:?--config needs a file}; shift 2 ;;
      --import-users) import_users=${2:?--import-users needs a file}; shift 2 ;;
      --test) test_mode=1; shift ;;
      --production) production=1; shift ;; # turn a test install into a real one (keeps secrets and users)
      -h | --help) usage; return 0 ;;
      *) die "setup: unknown option $1" ;;
    esac
  done
  if [ -n "$ANSWERS_FILE" ]; then
    [ -f "$ANSWERS_FILE" ] || die "answers file not found: $ANSWERS_FILE"
    ANSWERS_FILE="$(cd "$(dirname "$ANSWERS_FILE")" && pwd -P)/$(basename "$ANSWERS_FILE")"
  elif [ -t 0 ] && [ -r /dev/tty ]; then
    INTERACTIVE=1
  else
    die "setup needs a terminal, or --config answers.env for a non-interactive run"
  fi
  for t in openssl curl; do have "$t" || die "$t is required"; done
  local existing=0
  [ -s "$ENV_FILE" ] && existing=1
  [ "$(cur GOOSE_MODE)" = test ] && [ "$production" = 0 ] && test_mode=1

  say "${C_BOLD}Gooseplicant one-box setup${C_0}$([ "$test_mode" = 1 ] && printf ' (TEST MODE: local CA, no Cloudflare)')"
  [ "$existing" = 1 ] && say "  Existing install found: answers default to the current settings; secrets and users are kept."
  touch "$ENV_FILE"
  chmod 600 "$ENV_FILE"

  step "Hostnames"
  local base app auth intake def_base
  def_base=$(cur BASE_DOMAIN)
  [ "$test_mode" = 1 ] && def_base=${def_base:-goose.test}
  base=$(lower "$(ask BASE_DOMAIN "Your domain (managed in Cloudflare), e.g. example.com" "$def_base" is_host "a domain like example.com")")
  app=$(lower "$(ask APP_HOST "Hostname staff use for Gooseplicant" "$(cur APP_HOST | grep . || echo "hire.$base")" is_host "a hostname")")
  under "$app" "$base" || die "$app is not under $base: Authelia's sign-in cookie lives on $base (use ./goose add-domain later for other domains)"
  auth=$(lower "$(ask AUTH_HOST "Hostname of the sign-in portal (Authelia)" "$(cur AUTH_HOST | grep . || echo "auth.$base")" is_host "a hostname")")
  under "$auth" "$base" || die "$auth must be under $base (the cookie domain shared with $app)"
  [ "$auth" != "$app" ] || die "the sign-in portal needs its own hostname"
  local intake_def
  intake_def=$(cur INTAKE_HOST)
  if [ "$existing" = 1 ] && [ -z "$intake_def" ]; then intake_def=none; fi
  [ -n "$intake_def" ] || intake_def="apply.$base"
  [ "$test_mode" = 1 ] && [ -z "$(cur INTAKE_HOST)" ] && intake_def=none
  intake=$(lower "$(ask INTAKE_HOST "Public intake hostname for your website's applications (none = no public intake)" "$intake_def" is_any)")
  if [ "$intake" = none ] || [ -z "$intake" ]; then
    intake=""
  else
    is_host "$intake" || die "$intake is not a hostname"
    [ "$intake" != "$app" ] && [ "$intake" != "$auth" ] || die "the intake hostname must differ from the internal ones"
  fi
  env_set BASE_DOMAIN "$base"
  env_set APP_HOST "$app"
  env_set AUTH_HOST "$auth"
  env_set INTAKE_HOST "$intake"
  ok "app https://$app  ·  sign-in https://$auth  ·  intake ${intake:-(none)}"

  step "Network"
  local ips def_ip ip
  if [ "$test_mode" = 1 ]; then
    def_ip=$(cur BIND_IP | grep . || echo 127.0.0.1)
  else
    ips=$(detect_ips || true)
    def_ip=$(cur BIND_IP)
    if [ -n "$ips" ]; then
      say "  Addresses on this machine (Caddy listens on ONE of them, never all):"
      printf '%s\n' "$ips" | awk '{ printf "      %-16s %s\n", $1, $2 }'
      [ -n "$def_ip" ] || def_ip=$(printf '%s\n' "$ips" | awk '$2 == "Tailscale" { t = $1 } NR == 1 { f = $1 } END { print (t != "" ? t : f) }')
    fi
  fi
  ip=$(ask BIND_IP "Internal IP staff reach this server on (LAN or Tailscale)" "$def_ip" is_ipv4 "an IPv4 address")
  [ "$ip" != 0.0.0.0 ] || warn "0.0.0.0 publishes Caddy on every interface; prefer the LAN or Tailscale address"
  env_set BIND_IP "$ip"
  local hp p80
  hp=$(ask HTTPS_PORT "HTTPS port" "$(cur HTTPS_PORT | grep . || { [ "$test_mode" = 1 ] && echo 18443 || echo 443; })" is_port "a port number")
  p80=$(ask HTTP_PORT "HTTP port (redirects to HTTPS)" "$(cur HTTP_PORT | grep . || { [ "$test_mode" = 1 ] && echo 18080 || echo 80; })" is_port "a port number")
  env_set HTTPS_PORT "$hp"
  env_set HTTP_PORT "$p80"
  ok "Caddy will listen on $ip:$hp (and :$p80 for redirects)"

  step "Gooseplicant"
  local edition
  edition=$(ask EDITION "Edition: full (Gooseplicant) or lite (Gosling, the file browser)" "$(cur EDITION | grep . || echo full)" is_edition "full or lite")
  env_set EDITION "$edition"
  env_set GOOSEPLICANT_VERSION "$(kit_version)"
  env_set GOOSE_MODE "$([ "$test_mode" = 1 ] && echo test || echo production)"
  env_set COMPOSE_PROJECT_NAME "$(ask COMPOSE_PROJECT_NAME "Docker Compose project name" "$(cur COMPOSE_PROJECT_NAME | grep . || { [ "$test_mode" = 1 ] && echo goose-test || echo gooseplicant; })" is_project "lowercase letters, digits, - and _")"
  ensure_secrets
  ok "edition $edition, image gooseplicant:$(kit_version)"

  step "Sign-in (Authelia, local users)"
  mkdir -p "$CONFIG_DIR/authelia"
  if [ -n "$import_users" ]; then
    [ -f "$import_users" ] || die "--import-users: $import_users not found"
    grep -q '^users:' "$import_users" || die "$import_users has no top-level users: key (is it an Authelia users_database.yml?)"
    if [ -f "$USERS_FILE" ]; then
      cp "$USERS_FILE" "$USERS_FILE.bak-$(date -u +%Y%m%d%H%M%S)"
      note "previous users file kept as $(basename "$USERS_FILE").bak-*"
    fi
    users_write <"$import_users"
    ok "imported $(users_list | wc -l | tr -d ' ') user(s): $(users_list | join_by ', ')"
    note "invite each of them in Gooseplicant too (by email), or they'll see \"not invited\" after signing in"
  fi
  local user name email
  user=$(lower "$(ask ADMIN_USER "Admin username" "$(cur ADMIN_USER | grep . || echo admin)" is_user "lowercase letters, digits, . _ -")")
  if user_exists "$user"; then
    email=$(user_email "$user")
    ok "user $user exists${email:+ ($email)}; keeping it"
    if ans_has ADMIN_EMAIL; then email=$(ans_get ADMIN_EMAIL); fi
    email=${email:-$(cur ADMIN_EMAIL)}
    is_email "$email" || email=$(ask ADMIN_EMAIL "Admin email (the Gooseplicant admin)" "" is_email "an email address")
    if ans_has ADMIN_PASSWORD || { [ "$INTERACTIVE" = 1 ] && ask_yn ADMIN_RESET "Set a new password for $user?" n; }; then
      read_new_password ADMIN_PASSWORD "$user"
      user_set_password "$user" "$(printf '%s' "$NEW_PASSWORD" | hash_password)"
      ok "password of $user changed"
      [ "$NEW_PASSWORD_GENERATED" = 0 ] || secret_box "password for Authelia user $user" "$NEW_PASSWORD"
    fi
  else
    name=$(ask ADMIN_NAME "Admin display name" "Administrator" is_nonempty "a name")
    email=$(ask ADMIN_EMAIL "Admin email (becomes the first Gooseplicant admin)" "$(cur ADMIN_EMAIL)" is_email "an email address")
    read_new_password ADMIN_PASSWORD "$user"
    user_add "$user" "$name" "$(lower "$email")" admins "$(printf '%s' "$NEW_PASSWORD" | hash_password)"
    ok "created Authelia user $user <$email> (argon2id)"
    [ "$NEW_PASSWORD_GENERATED" = 0 ] || secret_box "password for Authelia user $user" "$NEW_PASSWORD"
  fi
  email=$(lower "$email")
  env_set ADMIN_USER "$user"
  env_set ADMIN_EMAIL "$email"
  env_set ACME_EMAIL "$(ask ACME_EMAIL "Email for Let's Encrypt expiry notices" "$(cur ACME_EMAIL | grep . || echo "$email")" is_email "an email address")"
  local def2fa=y
  [ "$(cur AUTH_POLICY)" = one_factor ] && def2fa=n
  if ask_yn REQUIRE_2FA "Require two-factor sign-in (authenticator app or passkey)?" "$def2fa"; then
    env_set AUTH_POLICY two_factor
  else
    env_set AUTH_POLICY one_factor
  fi
  ok "policy $(env_get AUTH_POLICY)"

  step "Email for Authelia (one-time codes, password resets)"
  local smtp_def=n
  [ -n "$(cur SMTP_HOST)" ] && smtp_def=y
  if ask_yn SMTP "Send them by SMTP? (no: write them to a file, read with ./goose code)" "$smtp_def"; then
    local sh sp su ss pw
    sh=$(ask SMTP_HOST "SMTP server" "$(cur SMTP_HOST)" is_host "a hostname")
    sp=$(ask SMTP_PORT "SMTP port (587 STARTTLS, 465 TLS)" "$(cur SMTP_PORT | grep . || echo 587)" is_port "a port number")
    su=$(ask SMTP_USER "SMTP username (empty: no login)" "$(cur SMTP_USER)" is_any)
    ss=$(ask SMTP_SENDER "Sender address (mails come from \"Gooseplicant <address>\")" "$(cur SMTP_SENDER)" is_email "an email address")
    env_set SMTP_HOST "$sh"
    env_set SMTP_PORT "$sp"
    env_set SMTP_USER "$(printf '%s' "$su" | tr -d '[:space:]')"
    env_set SMTP_SENDER "$ss"
    if [ -n "$su" ]; then
      pw=$(ask_secret SMTP_PASSWORD "SMTP password (empty: keep the stored one)")
      [ -z "$pw" ] || printf '%s' "$pw" >"$SECRETS_DIR/authelia_smtp_password"
      [ -s "$SECRETS_DIR/authelia_smtp_password" ] || warn "no SMTP password stored"
    fi
    if ask_yn APP_MAIL "Use the same SMTP server for Gooseplicant's own emails?" "$([ "$(cur APP_MAIL)" = no ] && echo n || echo y)"; then env_set APP_MAIL yes; else env_set APP_MAIL no; fi
    ok "SMTP $sh:$sp"
  else
    env_set SMTP_HOST ""
    env_set SMTP_PORT ""
    env_set SMTP_USER ""
    env_set SMTP_SENDER ""
    ok "filesystem notifier: ./goose code shows the latest one-time code or reset link"
  fi

  load_settings
  if [ "$test_mode" = 1 ]; then
    step "Cloudflare"
    note "test mode: skipped (Caddy uses its internal CA; no DNS records, no tunnel)"
  else
    cloudflare_setup
  fi

  step "Rendering configuration"
  render_all
  ok "config/caddy/Caddyfile, config/authelia/configuration.yml, config/gooseplicant.env, config/authelia.env"
  validate_configs
  record_kit_files

  connect_website
  step "Next"
  say "  ./goose up        build the images and start everything (first build: a few minutes)"
  say "  ./goose doctor    check DNS, certificates, sign-in, the app and the intake"
  say "  ./goose add-user  more staff accounts (then invite them in Gooseplicant → Settings → Users)"
  say "  Then open $(https_url "$APP_HOST")/ats/ and sign in as $(env_get ADMIN_USER)."
  [ "$(env_get AUTH_POLICY)" = two_factor ] && say "  First sign-in asks you to register an authenticator app; Authelia sends a confirmation code first$([ -z "$SMTP_HOST" ] && printf ': read it with ./goose code')."
  if ask_yn START "Start it now?" n; then cmd_up; fi
}

connect_website() {
  load_settings
  local base_url
  step "Connect your website"
  if [ -n "$INTAKE_HOST" ]; then
    base_url="https://$INTAKE_HOST/ats"
    say "  Your website's server posts each application, HMAC-signed, to $base_url/ingest"
    say "  (through Cloudflare and the tunnel; nothing else of Gooseplicant is reachable from the internet)."
    [ -n "$TUNNEL_TOKEN" ] || warn "the tunnel isn't configured yet, so $INTAKE_HOST doesn't answer until it is"
  else
    base_url="$(https_url "$APP_HOST")/ats"
    say "  No public intake hostname: only a website running inside your network can deliver applications,"
    say "  to $base_url/ingest (signed, no login)."
  fi
  say "  Set two values on the website (server-side only; never in the browser or in git):"
  say "      ATS_URL           = $base_url"
  secret_box "ATS_INGEST_SECRET (the website signs applications with it)" "$INGEST_SECRET" "Shown by ./goose connect whenever you need it again."
  say "  Example, a Cloudflare Pages site (run in the website's project):"
  say "      npx wrangler pages secret put ATS_INGEST_SECRET --project-name <your-pages-project>   # paste the secret"
  say "      # wrangler.toml"
  say "      [vars]"
  say "      ATS_URL = \"$base_url\""
  say "  Request format and a ~40-line reference client: https://gooseplicant.goosenotes.dev/docs/connect-a-form/"
}

cmd_connect() {
  require_setup
  connect_website
}

cmd_render() {
  require_setup
  render_all
  ok "configuration re-rendered from deploy/.env"
  validate_configs
  note "apply it with ./goose up"
}

cmd_up() {
  require_setup
  need_docker
  local build=""
  [ "${1:-}" = --build ] && build="--build"
  [ "$GOOSE_MODE" = test ] || [ -n "$CF_API_TOKEN" ] || warn "no Cloudflare API token: Caddy can't get certificates (re-run ./goose setup)"
  [ -z "$INTAKE_HOST" ] || [ -n "$TUNNEL_TOKEN" ] || warn "public intake $INTAKE_HOST has no tunnel token yet: cloudflared stays off"
  step "Starting (docker compose up -d $build)"
  # Compose recreates containers whose definition changed, but not for a changed mounted file: remember what
  # Caddy and Authelia were last started with and restart them when setup / add-domain / render changed it.
  local was_running=0 applied now
  running caddy && was_running=1
  applied=$(cat "$CONFIG_DIR/.applied" 2>/dev/null || true)
  # (the "Rendered from ... on <time>" header lines don't count: a re-render alone, e.g. by update, restarts nothing)
  now=$(cat "$CONFIG_DIR/caddy/Caddyfile" "$CONFIG_DIR/authelia/configuration.yml" "$CONFIG_DIR/authelia.env" | grep -v '^# Rendered from ' | openssl dgst -sha256 -r | cut -c1-64)
  dc up -d $build --remove-orphans
  if [ "$was_running" = 1 ] && [ "$applied" != "$now" ]; then
    dc restart caddy authelia >/dev/null
    ok "configuration changed: restarted caddy and authelia"
  fi
  printf '%s\n' "$now" >"$CONFIG_DIR/.applied"
  wait_healthy gooseplicant 180 && ok "gooseplicant healthy"
  wait_healthy authelia 90 && ok "authelia healthy"
  running caddy && ok "caddy running on $BIND_IP:$HTTPS_PORT"
  [ -z "$TUNNEL_TOKEN" ] || { running cloudflared && ok "cloudflared running"; }
  say "  Open $(https_url "$APP_HOST")/ats/   ·   check with ./goose doctor"
}

cmd_down() {
  require_setup
  need_docker
  if [ "${1:-}" = --volumes ]; then
    [ "${2:-}" = --yes ] || ask_yn_tty "Delete ALL data (applications, files, Authelia 2FA registrations, certificates)?" || die "cancelled"
    dc down --volumes --remove-orphans
  else
    dc down --remove-orphans
  fi
}
ask_yn_tty() {
  [ -t 0 ] || return 1
  local v
  printf '%s?%s %s (y/N): ' "$C_BOLD" "$C_0" "$1" >&2
  IFS= read -r v </dev/tty || v=""
  truthy "$v"
}
cmd_restart() { require_setup; need_docker; dc restart "$@"; }
cmd_logs() { require_setup; need_docker; dc logs --tail 200 "$@"; }
cmd_status() {
  require_setup
  need_docker
  dc ps
  say ""
  say "  app      $(https_url "$APP_HOST")/ats/   (on $BIND_IP:$HTTPS_PORT)"
  say "  sign-in  $(https_url "$AUTH_HOST")/"
  say "  intake   ${INTAKE_HOST:+https://$INTAKE_HOST/ats/ingest}${INTAKE_HOST:-(none)}"
  say "  mode     $GOOSE_MODE, edition $EDITION, gooseplicant:$GOOSEPLICANT_VERSION"
}

# ---------- users ----------
parse_user_flags() {
  U_NAME="" U_EMAIL="" U_GROUPS="" U_STDIN=0 U_YES=0 U_POS=""
  while [ $# -gt 0 ]; do
    case "$1" in
      --name) U_NAME=${2:?}; shift 2 ;;
      --email) U_EMAIL=${2:?}; shift 2 ;;
      --groups) U_GROUPS=${2:?}; shift 2 ;;
      --password-stdin) U_STDIN=1; shift ;;
      --yes | -y) U_YES=1; shift ;;
      -*) die "unknown option $1" ;;
      *) U_POS=$1; shift ;;
    esac
  done
}
password_from_stdin() {
  IFS= read -r NEW_PASSWORD || true
  NEW_PASSWORD_GENERATED=0
  [ "${#NEW_PASSWORD}" -ge 12 ] || die "the password on stdin must have at least 12 characters"
}
users_url() {
  if [ "$EDITION" = lite ]; then printf '%s/ats/lite/#/admin/users' "$(https_url "$APP_HOST")"; else printf '%s/ats/#/settings/users' "$(https_url "$APP_HOST")"; fi
}
cmd_add_user() {
  require_setup
  parse_user_flags "$@"
  [ -t 0 ] && [ "$U_STDIN" = 0 ] && INTERACTIVE=1
  local user name email since
  user=$(lower "${U_POS:-$(ask _ "Username" "" is_user "lowercase letters, digits, . _ -")}")
  is_user "$user" || die "invalid username $user"
  user_exists "$user" && die "user $user already exists (change its password with ./goose passwd $user)"
  name=${U_NAME:-$(ask _ "Display name" "" is_nonempty "a name")}
  email=$(lower "${U_EMAIL:-$(ask _ "Email (must match the address you invite in Gooseplicant)" "" is_email "an email address")}")
  is_email "$email" || die "invalid email $email"
  [ -n "$U_GROUPS" ] || [ "$INTERACTIVE" != 1 ] || U_GROUPS=$(ask _ "Groups (comma-separated, optional)" "staff" is_any)
  if [ "$U_STDIN" = 1 ]; then password_from_stdin; else read_new_password _ "$user"; fi
  since=$(now_utc)
  user_add "$user" "$name" "$email" "$U_GROUPS" "$(printf '%s' "$NEW_PASSWORD" | hash_password)"
  ok "added Authelia user $user <$email>"
  [ "$NEW_PASSWORD_GENERATED" = 0 ] || secret_box "password for $user (give it to them; they can change it at the sign-in portal)" "$NEW_PASSWORD"
  authelia_reload_users "$since"
  wt_begin "Give $email access inside Gooseplicant"
  wt "Signing in now works, but Gooseplicant answers \"not invited\" until an admin adds the person:" \
    "$(users_url)  → Invite → $email → role (viewer, recruiter, admin) and sites."
  wt_end
}
cmd_passwd() {
  require_setup
  parse_user_flags "$@"
  [ -t 0 ] && [ "$U_STDIN" = 0 ] && INTERACTIVE=1
  local user=$U_POS since
  [ -n "$user" ] || die "usage: ./goose passwd <username> [--password-stdin]"
  user_exists "$user" || die "no such user: $user"
  if [ "$U_STDIN" = 1 ]; then password_from_stdin; else read_new_password _ "$user"; fi
  since=$(now_utc)
  user_set_password "$user" "$(printf '%s' "$NEW_PASSWORD" | hash_password)"
  ok "password of $user changed"
  [ "$NEW_PASSWORD_GENERATED" = 0 ] || secret_box "new password for $user" "$NEW_PASSWORD"
  authelia_reload_users "$since"
}
cmd_remove_user() {
  require_setup
  parse_user_flags "$@"
  local user=$U_POS since email
  [ -n "$user" ] || die "usage: ./goose remove-user <username> [--yes]"
  user_exists "$user" || die "no such user: $user"
  [ "$user" != "$ADMIN_USER" ] || [ "$U_YES" = 1 ] || warn "$user is the admin set up by goose setup"
  [ "$U_YES" = 1 ] || ask_yn_tty "Remove Authelia user $user?" || die "cancelled"
  email=$(user_email "$user")
  since=$(now_utc)
  user_remove "$user"
  user_exists "$user" && die "internal: $user is still in the users file"
  ok "removed Authelia user $user"
  authelia_reload_users "$since"
  note "they can't sign in any more; also remove ${email:-them} in Gooseplicant: $(users_url)"
}

cmd_reset_2fa() { # a lost phone: delete the user's TOTP and WebAuthn registrations; they register again
  require_setup
  need_docker
  local user=${1:-} out
  [ -n "$user" ] || die "usage: ./goose reset-2fa <username>"
  user_exists "$user" || warn "$user is not in the users file (resetting anyway)"
  running authelia || die "Authelia isn't running: ./goose up"
  out=$(dc exec -T authelia authelia storage user totp delete "$user" --config /config/configuration.yml 2>&1) &&
    ok "deleted the authenticator app registration of $user" || note "no authenticator app registered for $user (${out##*$'\n'})"
  out=$(dc exec -T authelia authelia storage user webauthn delete "$user" --all --config /config/configuration.yml 2>&1) &&
    ok "deleted the passkeys/security keys of $user" || note "no passkeys for $user (${out##*$'\n'})"
  say "  At the next sign-in $user registers a new second factor (one-time code first$([ -n "$SMTP_HOST" ] || printf ': ./goose code'))."
}

# ---------- add-domain ----------
cmd_add_domain() {
  require_setup
  local host="" authh="" cookie="" a
  while [ $# -gt 0 ]; do
    case "$1" in
      --auth-host) authh=$(lower "${2:?}"); shift 2 ;;
      --cookie-domain) cookie=$(lower "${2:?}"); shift 2 ;;
      --config) ANSWERS_FILE=${2:?}; shift 2 ;;
      --yes | -y) # apply the DNS records via the API without asking
        ANSWERS_FILE="$(tmpdir)/add-domain.env"
        printf 'CF_CREATE_DNS=yes\n' >"$ANSWERS_FILE"
        shift ;;
      -*) die "unknown option $1" ;;
      *) host=$(lower "$1"); shift ;;
    esac
  done
  [ -t 0 ] && [ -z "$ANSWERS_FILE" ] && INTERACTIVE=1
  [ -n "$host" ] || die "usage: ./goose add-domain <hostname> [--auth-host auth.other.com] [--cookie-domain other.com]"
  is_host "$host" || die "$host is not a hostname"
  app_hosts | grep -qx "$host" && { ok "$host is already an app hostname"; return 0; }
  auth_hosts | grep -qx "$host" && die "$host is a sign-in portal hostname"
  [ "$host" != "$INTAKE_HOST" ] || die "$host is the public intake hostname"
  # Same cookie domain as an existing entry → reuse its sign-in portal; otherwise a new cookie domain + portal.
  local c ah fh
  if [ -z "$cookie" ]; then
    while IFS='|' read -r c ah _; do
      if [ -n "$c" ] && under "$host" "$c"; then cookie=$c authh=${authh:-$ah}; break; fi
    done <<EOF
$(cookie_domains)
EOF
  fi
  if [ -z "$cookie" ]; then
    cookie=$(lower "$(ask COOKIE_DOMAIN "Cookie domain for $host (its parent domain)" "$(parent_of "$host")" is_host "a domain")")
  fi
  under "$host" "$cookie" || die "$host is not under $cookie"
  if [ -z "$authh" ]; then
    while IFS='|' read -r c ah _; do [ "$c" = "$cookie" ] && authh=$ah; done <<EOF
$(cookie_domains)
EOF
  fi
  [ -n "$authh" ] || authh=$(lower "$(ask AUTH_HOST_NEW "Sign-in portal hostname on $cookie" "auth.$cookie" is_host "a hostname")")
  under "$authh" "$cookie" || die "the sign-in portal $authh must be under $cookie"
  local new_cookie=1
  cookie_domains | cut -d'|' -f1 | grep -qx "$cookie" && new_cookie=0
  a="$host/$authh/$cookie"
  env_set EXTRA_DOMAINS "${EXTRA_DOMAINS:+$EXTRA_DOMAINS,}$a"
  load_settings
  step "Adding $host"
  ok "app hostnames: $(app_hosts | join_by ', ')"
  if [ "$new_cookie" = 1 ]; then ok "new sign-in cookie domain $cookie with its portal https://$authh"; else ok "uses the existing sign-in cookie on $cookie (portal $authh)"; fi
  render_all
  validate_configs
  local dns_hosts="$host"
  [ "$new_cookie" = 0 ] || dns_hosts="$host $authh"
  if [ "$GOOSE_MODE" = test ]; then
    note "test mode: no DNS (use curl --resolve $host:$HTTPS_PORT:$BIND_IP)"
  elif [ -n "$CF_API_TOKEN" ]; then
    CF_ACCOUNT_ID=$(env_get CF_ACCOUNT_ID)
    cf_dns_plan_and_apply "$dns_hosts"
    cf_zone_for "$host" || {
      wt_begin "Let Caddy get a certificate for $host"
      wt "The zone holding $host must be in your Cloudflare account, and the API token must include it:" \
        "https://dash.cloudflare.com/profile/api-tokens → your token → Edit → Zone Resources → add the zone."
      wt "Then: ./goose restart caddy && ./goose doctor"
      wt_end
    }
  else
    wt_dns_records "$cookie" "$(for h in $dns_hosts; do printf 'A|%s|%s|DNS only\n' "$h" "$BIND_IP"; done)"
  fi
  if docker_ok && running caddy; then
    dc exec -T caddy caddy reload --config /etc/caddy/Caddyfile --adapter caddyfile >/dev/null 2>"$(tmpdir)/reload.err" ||
      { cat "$(tmpdir)/reload.err" >&2; die "Caddy refused the new configuration (it keeps running the old one)"; }
    ok "Caddy reloaded (certificates for the new names are requested now)"
    if running authelia; then
      dc restart authelia >/dev/null
      wait_healthy authelia 60 && ok "Authelia restarted with the new cookie domain and access rule (signed-in sessions end)"
    fi
  else
    note "not running: ./goose up applies it"
  fi
  note "Gooseplicant's PUBLIC_URL stays $(https_url "$APP_HOST"): links in emails always use the primary hostname."
}

# ---------- code ----------
cmd_code() {
  require_setup
  need_docker
  if [ -n "$SMTP_HOST" ]; then note "Authelia sends codes by email (SMTP $SMTP_HOST); this shows the file notifier only."; fi
  local txt
  txt=$(dc exec -T authelia cat /var/lib/authelia/notification.txt 2>/dev/null || true)
  if [ -z "$txt" ]; then
    say "No notification yet. Start the step in the browser (register 2FA, reset password), then run ./goose code again."
    return 0
  fi
  step "Latest Authelia notification"
  # The file holds the most recent message (older ones may precede it): print from its last "Date:" line.
  txt=$(printf '%s\n' "$txt" | awk '/^Date: / { buf = "" } { buf = buf $0 "\n" } END { printf "%s", buf }')
  printf '%s\n' "$txt" | grep -v '^Powered by Authelia'
  local code link
  code=$(printf '%s\n' "$txt" | grep -E '^[[:space:]]*[A-Z0-9]{8}[[:space:]]*$' | tail -n 1 | tr -d '[:space:]' || true)
  link=$(printf '%s\n' "$txt" | grep -Eo 'https://[^ ]*/reset-password/step2[^ ]*' | tail -n 1 || true)
  [ -z "$code" ] || say "${C_BOLD}One-time code: $code${C_0}"
  [ -z "$link" ] || say "${C_BOLD}Password reset link: $link${C_0}"
}

# ---------- doctor ----------
D_FAIL=0 D_WARN=0
d_pass() { printf '  %sPASS%s  %s\n' "$C_G" "$C_0" "$1"; }
d_warn() { D_WARN=$((D_WARN + 1)); printf '  %sWARN%s  %s\n' "$C_Y" "$C_0" "$1"; [ -z "${2:-}" ] || printf '        %sfix:%s %s\n' "$C_DIM" "$C_0" "$2"; }
d_fail() { D_FAIL=$((D_FAIL + 1)); printf '  %sFAIL%s  %s\n' "$C_R" "$C_0" "$1"; [ -z "${2:-}" ] || printf '        %sfix:%s %s\n' "$C_DIM" "$C_0" "$2"; }
d_skip() { printf '  %sSKIP%s  %s\n' "$C_DIM" "$C_0" "$1"; }
resolve4() { # host → IPv4 addresses (system resolver), one per line
  local h=$1
  if have getent; then getent ahostsv4 "$h" 2>/dev/null | awk '{ print $1 }' | awk '!s[$0]++'
  elif have dscacheutil; then dscacheutil -q host -a name "$h" 2>/dev/null | awk '/^ip_address:/ { print $2 }'
  elif have dig; then dig +short A "$h" | grep -E "$RE_IPV4"
  elif have host; then host -t A "$h" 2>/dev/null | awk '/has address/ { print $4 }'
  fi
}
curl_in() { # curl to Caddy for one of our hostnames, without relying on DNS
  local host=$1
  shift
  local k=""
  [ "$GOOSE_MODE" != test ] || k=-k
  curl -sS --max-time 15 --resolve "$host:$HTTPS_PORT:$BIND_IP" ${k:+"$k"} "$@"
}
cmd_doctor() {
  require_setup
  D_FAIL=0 D_WARN=0
  step "goose doctor ($GOOSE_MODE mode)"
  # config
  local m
  m=$(ls -l "$ENV_FILE" | cut -c1-10)
  if [ "$m" = "-rw-------" ]; then d_pass "deploy/.env is private (600)"; else d_fail "deploy/.env is $m" "chmod 600 $ENV_FILE"; fi
  local f missing=""
  for f in $SECRET_FILES; do [ -s "$SECRETS_DIR/$f" ] || missing="$missing $f"; done
  if [ -z "$missing" ]; then d_pass "Authelia secrets present"; else d_fail "missing secrets:$missing" "./goose setup (keeps everything else)"; fi
  if [ "$GOOSE_MODE" = test ]; then d_skip "Cloudflare API token (test mode)"
  elif [ -n "$CF_API_TOKEN" ] && ! cf_token_format_ok "$CF_API_TOKEN"; then
    d_fail "the stored Cloudflare API token doesn't have a token's format (Caddy refuses it)" "./goose setup → Replace it? y → paste the token itself (not its ID or the Global API Key)"
  elif [ -n "$CF_API_TOKEN" ]; then d_pass "Cloudflare API token configured"
  else d_fail "no Cloudflare API token: no certificates" "./goose setup (it shows how to create one)"; fi

  need_docker
  # containers
  local s want="gooseplicant authelia caddy"
  [ -z "$TUNNEL_TOKEN" ] || want="$want cloudflared"
  for s in $want; do
    if running "$s"; then d_pass "$s is running"; else d_fail "$s is not running" "./goose up, then ./goose logs $s"; fi
  done
  # health inside the network
  if running gooseplicant; then
    local h
    for p in 3000 3001; do
      h=$(dc exec -T gooseplicant node -e "fetch('http://127.0.0.1:$p/ats/api/health').then(async r=>{console.log(r.status+' '+await r.text())},e=>console.log('ERR '+e.message))" 2>/dev/null || true)
      case "$h" in 200*) d_pass "Gooseplicant :$p health $h" ;; *) d_fail "Gooseplicant :$p health: ${h:-no answer}" "./goose logs gooseplicant" ;; esac
    done
  fi
  if running authelia; then
    h=$(dc exec -T authelia wget -qO- http://127.0.0.1:9091/api/health 2>/dev/null || true)
    case "$h" in *OK*) d_pass "Authelia health $h" ;; *) d_fail "Authelia health: ${h:-no answer}" "./goose logs authelia" ;; esac
  fi
  # published ports
  local cid binds
  for s in gooseplicant authelia cloudflared; do
    cid=$(dc ps -q "$s" 2>/dev/null || true)
    [ -n "$cid" ] || continue
    binds=$(docker inspect -f '{{range $p, $b := .HostConfig.PortBindings}}{{$p}} {{end}}' "$cid" 2>/dev/null || true)
    if [ -z "$binds" ]; then d_pass "$s publishes no ports on the host"; else d_fail "$s publishes $binds" "remove ports: from $s in deploy/compose.yml"; fi
  done
  cid=$(dc ps -q caddy 2>/dev/null || true)
  if [ -n "$cid" ]; then
    binds=$(docker inspect -f '{{range $p, $b := .HostConfig.PortBindings}}{{range $b}}{{.HostIp}}:{{.HostPort}} {{end}}{{end}}' "$cid" 2>/dev/null | tr ' ' '\n' | awk 'NF && !s[$0]++' | tr '\n' ' ' || true)
    case " $binds" in *" 0.0.0.0:"* | *" :"* | *" [::]:"*) d_warn "caddy is published on all interfaces: $binds" "set BIND_IP in deploy/.env, then ./goose up" ;; *) d_pass "caddy only on $binds" ;; esac
  fi
  # DNS
  local hst ips
  for hst in $(internal_hosts); do
    if [ "$GOOSE_MODE" = test ]; then d_skip "DNS $hst (test mode: curl --resolve)"; continue; fi
    ips=$(resolve4 "$hst" | tr '\n' ' ')
    if printf ' %s ' "$ips" | grep -q " $BIND_IP "; then d_pass "DNS $hst → $BIND_IP"
    elif [ -z "$ips" ]; then d_fail "DNS $hst doesn't resolve here" "add A $hst → $BIND_IP (DNS only) in Cloudflare or your local DNS; ./goose setup can do it"
    else d_fail "DNS $hst → $ips (expected $BIND_IP)" "point the A record at $BIND_IP; if your router drops private answers (DNS rebinding protection), allow $BASE_DOMAIN or use local DNS"; fi
  done
  # certificates
  local cert issuer
  for hst in $(internal_hosts); do
    cert=$(printf '' | openssl s_client -connect "$BIND_IP:$HTTPS_PORT" -servername "$hst" 2>/dev/null | openssl x509 2>/dev/null || true)
    if [ -z "$cert" ]; then d_fail "no certificate for $hst on $BIND_IP:$HTTPS_PORT" "./goose logs caddy (look for 'obtain' / 'challenge'; check the token's zone permissions)"; continue; fi
    issuer=$(printf '%s\n' "$cert" | openssl x509 -noout -issuer 2>/dev/null | sed 's/^issuer= *//')
    if ! printf '%s\n' "$cert" | openssl x509 -noout -checkend 0 >/dev/null 2>&1; then d_fail "certificate for $hst has expired ($issuer)" "./goose logs caddy"
    elif [ "$GOOSE_MODE" = test ]; then d_pass "certificate for $hst from $issuer (test mode)"
    elif printf '%s' "$issuer" | grep -qi 'caddy local'; then d_fail "certificate for $hst is self-signed ($issuer)" "Caddy couldn't use Let's Encrypt; ./goose logs caddy"
    elif ! printf '%s\n' "$cert" | openssl x509 -noout -checkend 1209600 >/dev/null 2>&1; then d_warn "certificate for $hst expires within 14 days" "Caddy renews automatically; if not: ./goose logs caddy"
    else d_pass "certificate for $hst ($issuer)"; fi
  done
  # sign-in wiring through Caddy
  local code loc
  if running caddy; then
    loc=$(curl_in "$APP_HOST" -o /dev/null -w '%{http_code} %{redirect_url}' "$(https_url "$APP_HOST")/ats/" 2>/dev/null || true)
    case "$loc" in
      30[0-9]*"$(https_url "$AUTH_HOST")"*) d_pass "unauthenticated /ats/ redirects to the sign-in portal" ;;
      *) d_fail "unauthenticated /ats/ answered '$loc' (expected a redirect to $(https_url "$AUTH_HOST"))" "./goose logs caddy authelia" ;;
    esac
    code=$(curl_in "$APP_HOST" -o /dev/null -w '%{http_code}' -H 'Remote-Email: someone@example.com' -H 'Remote-User: someone' -H 'X-Forwarded-Email: someone@example.com' "$(https_url "$APP_HOST")/ats/api/me" 2>/dev/null || true)
    case "$code" in 200) d_fail "a forged Remote-Email header got through to Gooseplicant" "check config/caddy/Caddyfile: forward_auth must run before reverse_proxy" ;; *) d_pass "forged identity headers are refused ($code)" ;; esac
    code=$(curl_in "$AUTH_HOST" -o /dev/null -w '%{http_code}' "$(https_url "$AUTH_HOST")/api/health" 2>/dev/null || true)
    if [ "$code" = 200 ]; then d_pass "sign-in portal answers through Caddy"; else d_fail "sign-in portal through Caddy: $code" "./goose logs caddy authelia"; fi
  fi
  # tunnel + public intake
  if [ -z "$INTAKE_HOST" ]; then
    d_skip "public intake (none configured)"
  elif [ -z "$TUNNEL_TOKEN" ]; then
    d_warn "public intake $INTAKE_HOST has no tunnel token" "./goose setup (Cloudflare step)"
  else
    h=$(dc exec -T caddy wget -qO- -S http://cloudflared:2000/ready 2>&1 | head -n 1 || true)
    case "$h" in *200*) d_pass "cloudflared tunnel connected" ;; *) d_fail "cloudflared not connected (${h:-no answer})" "./goose logs cloudflared (token wrong? outbound 7844 blocked?)" ;; esac
    ips=$(resolve4 "$INTAKE_HOST" | tr '\n' ' ')
    if [ -z "$ips" ]; then d_fail "DNS $INTAKE_HOST doesn't resolve" "proxied CNAME $INTAKE_HOST → $TUNNEL_ID.cfargotunnel.com"
    elif printf ' %s ' "$ips" | grep -q " $BIND_IP "; then d_fail "$INTAKE_HOST points at this server ($ips)" "it must be the proxied CNAME to the tunnel, not an A record"
    else d_pass "DNS $INTAKE_HOST → $ips (Cloudflare)"; fi
    local pub="https://$INTAKE_HOST/ats"
    code=$(curl -sS --max-time 15 -o /dev/null -w '%{http_code}' "$pub/api/health" 2>/dev/null || true)
    if [ "$code" = 200 ]; then d_pass "public $pub/api/health 200"; else d_fail "public $pub/api/health: $code" "tunnel ingress must send $INTAKE_HOST to http://gooseplicant:3001"; fi
    local bad=""
    for path in / /lite/ /api/me /api/applications; do
      code=$(curl -sS --max-time 15 -o /dev/null -w '%{http_code}' "$pub$path" 2>/dev/null || true)
      [ "$code" = 404 ] || bad="$bad $path=$code"
    done
    code=$(curl -sS --max-time 15 -o /dev/null -w '%{http_code}' "$pub/ingest" 2>/dev/null || true)
    [ "$code" = 404 ] || [ "$code" = 405 ] || bad="$bad GET/ingest=$code"
    if [ -z "$bad" ]; then d_pass "public intake host serves nothing but intake (404 for /ats/, /ats/lite/, the API)"; else d_fail "public intake host answers:$bad" "the tunnel must point at :3001 (intake-only), never :3000"; fi
    code=$(curl -sS --max-time 15 -o /dev/null -w '%{http_code}' -X POST -F payload='{}' "$pub/ingest" 2>/dev/null || true)
    if [ "$code" = 401 ] || [ "$code" = 400 ]; then d_pass "unsigned POST /ats/ingest refused ($code)"; else d_fail "unsigned POST /ats/ingest: $code (expected 401)" "./goose logs gooseplicant"; fi
  fi
  # clock
  local remote now skew
  remote=$(curl -sSI --max-time 10 https://www.cloudflare.com/ 2>/dev/null | tr -d '\r' | sed -n 's/^[Dd]ate: //p' | head -n 1)
  now=$(date -u +%s)
  if [ -n "$remote" ]; then
    local rs
    rs=$(date -u -d "$remote" +%s 2>/dev/null || date -u -j -f '%a, %d %b %Y %H:%M:%S GMT' "$remote" +%s 2>/dev/null || true)
    if [ -n "$rs" ]; then
      skew=$((now - rs))
      [ "$skew" -lt 0 ] && skew=$((-skew))
      if [ "$skew" -le 30 ]; then d_pass "clock within ${skew}s of Cloudflare"
      elif [ "$skew" -le 120 ]; then d_warn "clock is ${skew}s off" "enable NTP (timedatectl set-ntp true): 2FA codes and signed intake allow little drift"
      else d_fail "clock is ${skew}s off" "enable NTP: signed applications older than 5 minutes are refused, TOTP codes fail"; fi
    fi
  else
    d_warn "couldn't read the time from https://www.cloudflare.com (offline?)" "check outbound HTTPS"
  fi
  if running gooseplicant; then
    rs=$(dc exec -T gooseplicant date -u +%s 2>/dev/null || true)
    if [ -n "$rs" ]; then
      skew=$((now - rs)); [ "$skew" -lt 0 ] && skew=$((-skew))
      if [ "$skew" -le 5 ]; then d_pass "container clock matches the host"; else d_warn "container clock differs from the host by ${skew}s" "restart Docker (Docker Desktop's VM clock drifts after sleep)"; fi
    fi
  fi
  say ""
  if [ "$D_FAIL" -gt 0 ]; then say "${C_R}${C_BOLD}$D_FAIL failed${C_0}, $D_WARN warning(s)"; return 1; fi
  say "${C_G}${C_BOLD}all checks passed${C_0}$([ "$D_WARN" -gt 0 ] && printf ', %s warning(s)' "$D_WARN")"
}

# ---------- backup / restore ----------
vol() { printf '%s_%s' "$COMPOSE_PROJECT_NAME" "$1"; }
app_image() { printf 'gooseplicant:%s' "$GOOSEPLICANT_VERSION"; }
# Archive format "gooseplicant-backup 1": an update's rollback hands the pre-update backup to the PREVIOUS kit's
# `restore`, so a newer goose must keep writing archives that older kits can read (or ship a compat path).
SNAPSHOT_JS='const {DatabaseSync}=require("node:sqlite");const [src,dst]=process.argv.slice(1);const db=new DatabaseSync(src);db.exec("VACUUM INTO "+JSON.stringify(dst).replace(/^"|"$/g,"\u0027"));db.close()'
cmd_backup() {
  require_setup
  need_docker
  local dest=${1:-$GOOSE_DIR/backups} stamp work name img
  mkdir -p "$dest"
  dest=$(cd "$dest" && pwd -P)
  stamp=$(date -u +%Y%m%d-%H%M%S)
  name="gooseplicant-backup-$stamp.tar.gz"
  img=$(app_image)
  docker image inspect "$img" >/dev/null 2>&1 || die "image $img not found: ./goose up --build first"
  work="$(tmpdir)/backup-$stamp"
  mkdir -p "$work/gooseplicant" "$work/authelia" "$work/deploy"
  step "Backup → $dest/$name"
  # SQLite: VACUUM INTO gives a consistent copy while the app runs (WAL mode; never copy ats.db alone).
  docker run --rm --user 0 --entrypoint sh -v "$(vol gooseplicant-data):/data" "$img" -c \
    'set -e; mkdir -p /tmp/b; if [ -f /data/ats.db ]; then node -e "$0" /data/ats.db /tmp/b/ats.db; fi; cd /tmp/b; [ -d /data/files ] && cp -a /data/files files; tar -cf - .' "$SNAPSHOT_JS" |
    tar -xf - -C "$work/gooseplicant"
  [ -f "$work/gooseplicant/ats.db" ] && ok "Gooseplicant database (consistent snapshot) + $(find "$work/gooseplicant/files" -type f 2>/dev/null | wc -l | tr -d ' ') file(s)" || note "no Gooseplicant database yet"
  docker run --rm --user 0 --entrypoint sh -v "$(vol authelia-data):/a" "$img" -c \
    'set -e; mkdir -p /tmp/b; if [ -f /a/db.sqlite3 ]; then node -e "$0" /a/db.sqlite3 /tmp/b/db.sqlite3; fi; cd /tmp/b; tar -cf - .' "$SNAPSHOT_JS" |
    tar -xf - -C "$work/authelia"
  [ -f "$work/authelia/db.sqlite3" ] && ok "Authelia database (2FA registrations)" || note "no Authelia database yet"
  cp "$ENV_FILE" "$work/deploy/.env"
  cp -R "$CONFIG_DIR" "$work/deploy/config"
  cp -R "$SECRETS_DIR" "$work/deploy/secrets"
  printf 'gooseplicant-backup 1\nversion %s\ncreated %s\nproject %s\n' "$GOOSEPLICANT_VERSION" "$(now_utc)" "$COMPOSE_PROJECT_NAME" >"$work/MANIFEST"
  (cd "$work" && tar -czf "$dest/$name" MANIFEST gooseplicant authelia deploy)
  chmod 600 "$dest/$name"
  ok "deploy/.env, config/, secrets/ (the archive holds secrets: mode 600, keep it safe)"
  say "  $dest/$name  ($(du -h "$dest/$name" | cut -f1 | tr -d ' '))"
  BACKUP_FILE="$dest/$name"
}
cmd_restore() {
  local file="" yes=0
  while [ $# -gt 0 ]; do case "$1" in --yes | -y) yes=1; shift ;; *) file=$1; shift ;; esac; done
  [ -f "$file" ] || die "usage: ./goose restore <gooseplicant-backup-*.tar.gz> [--yes]"
  need_docker
  local work
  work="$(tmpdir)/restore"
  mkdir -p "$work"
  tar -xzf "$file" -C "$work"
  grep -q '^gooseplicant-backup 1' "$work/MANIFEST" 2>/dev/null || die "$file is not a goose backup"
  [ "$yes" = 1 ] || ask_yn_tty "Replace the current data and settings with $(basename "$file")? The stack restarts." || die "cancelled"
  step "Restore from $(basename "$file")"
  [ ! -f "$ENV_FILE" ] || cp "$ENV_FILE" "$ENV_FILE.before-restore"
  cp "$work/deploy/.env" "$ENV_FILE"
  chmod 600 "$ENV_FILE"
  rm -rf "$CONFIG_DIR" "$SECRETS_DIR"
  cp -R "$work/deploy/config" "$CONFIG_DIR"
  cp -R "$work/deploy/secrets" "$SECRETS_DIR"
  ok "deploy/.env, config/, secrets/ restored"
  load_settings
  env_set GOOSEPLICANT_VERSION "$(kit_version)"
  load_settings
  dc down --remove-orphans >/dev/null 2>&1 || true
  local img
  img=$(app_image)
  docker image inspect "$img" >/dev/null 2>&1 || dc build gooseplicant
  docker volume create "$(vol gooseplicant-data)" >/dev/null
  docker volume create "$(vol authelia-data)" >/dev/null
  (cd "$work/gooseplicant" && tar -cf - .) | docker run --rm -i --user 0 --entrypoint sh -v "$(vol gooseplicant-data):/data" "$img" -c \
    'set -e; rm -rf /data/ats.db /data/ats.db-wal /data/ats.db-shm /data/files; tar -xf - -C /data; chown -R node:node /data'
  ok "Gooseplicant data restored"
  (cd "$work/authelia" && tar -cf - .) | docker run --rm -i --user 0 --entrypoint sh -v "$(vol authelia-data):/a" "$img" -c \
    'set -e; rm -f /a/db.sqlite3 /a/db.sqlite3-wal /a/db.sqlite3-shm; tar -xf - -C /a'
  ok "Authelia data restored"
  cmd_up
}

# ---------- versions and release notes ----------
RE_SEMVER='^[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.-]+)?(\+[0-9A-Za-z.-]+)?$'
is_semver() { [[ ${1#v} =~ $RE_SEMVER ]]; }
# semver_cmp a b → prints -1, 0 or 1. SemVer 2.0 precedence: 0.10.0 > 0.9.9; a pre-release sorts before its
# release (1.0.0-rc.1 < 1.0.0); identifiers compare left to right, numbers numerically and below words
# (alpha < alpha.1 < beta < beta.2 < beta.11 < rc.1); build metadata (+...) is ignored.
semver_cmp() {
  local a=${1#v} b=${2#v} LC_ALL=C
  a=${a%%+*}
  b=${b%%+*}
  local ac=${a%%-*} bc=${b%%-*} ap="" bp="" x y
  [ "$ac" = "$a" ] || ap=${a#*-}
  [ "$bc" = "$b" ] || bp=${b#*-}
  while [ -n "$ac" ] || [ -n "$bc" ]; do
    x=${ac%%.*}
    y=${bc%%.*}
    if [ "$x" = "$ac" ]; then ac=""; else ac=${ac#*.}; fi
    if [ "$y" = "$bc" ]; then bc=""; else bc=${bc#*.}; fi
    x=$((10#${x:-0}))
    y=$((10#${y:-0}))
    if [ "$x" -gt "$y" ]; then echo 1; return 0; fi
    if [ "$x" -lt "$y" ]; then echo -1; return 0; fi
  done
  if [ -z "$ap" ] || [ -z "$bp" ]; then
    if [ -z "$ap" ] && [ -z "$bp" ]; then echo 0; elif [ -z "$ap" ]; then echo 1; else echo -1; fi
    return 0
  fi
  while :; do
    if [ -z "$ap" ] && [ -z "$bp" ]; then echo 0; return 0; fi
    if [ -z "$ap" ]; then echo -1; return 0; fi
    if [ -z "$bp" ]; then echo 1; return 0; fi
    x=${ap%%.*}
    y=${bp%%.*}
    if [ "$x" = "$ap" ]; then ap=""; else ap=${ap#*.}; fi
    if [ "$y" = "$bp" ]; then bp=""; else bp=${bp#*.}; fi
    if [[ $x =~ ^[0-9]+$ ]] && [[ $y =~ ^[0-9]+$ ]]; then
      x=$((10#$x))
      y=$((10#$y))
      [ "$x" -ne "$y" ] || continue
      if [ "$x" -gt "$y" ]; then echo 1; else echo -1; fi
      return 0
    fi
    if [[ $x =~ ^[0-9]+$ ]]; then echo -1; return 0; fi
    if [[ $y =~ ^[0-9]+$ ]]; then echo 1; return 0; fi
    [ "$x" != "$y" ] || continue
    if [[ $x < $y ]]; then echo -1; else echo 1; fi
    return 0
  done
}
version_gt() { [ "$(semver_cmp "$1" "$2")" = 1 ]; }

# changelog_between CHANGELOG.md from to → the "## <version>" sections with from < version ≤ to, in file order.
changelog_between() {
  local line v show=0
  while IFS= read -r line || [ -n "$line" ]; do
    case "$line" in
      '## '*)
        v=${line#'## '}
        v=${v%%[[:space:]]*}
        v=${v#v}
        show=0
        if is_semver "$v" && version_gt "$v" "$2" && ! version_gt "$v" "$3"; then show=1; fi
        ;;
    esac
    [ "$show" = 0 ] || printf '%s\n' "$line"
  done <"$1"
}
# print_notes from to [CHANGELOG.md] [manifest.json]: every version's notes from the new kit's CHANGELOG.md,
# else the target manifest's "notes", else where to read them.
print_notes() {
  local from=$1 to=$2 cl=${3:-} mf=${4:-} txt=""
  if [ -n "$cl" ] && [ -f "$cl" ]; then txt=$(changelog_between "$cl" "$from" "$to"); fi
  if [ -n "$txt" ]; then
    printf '%s\n' "$txt" | sed -e 's/^/    /' -e 's/^ *$//'
    return 0
  fi
  if [ -n "$mf" ] && [ -f "$mf" ]; then txt=$(json_get notes <"$mf"); fi
  if [ -n "$txt" ]; then
    printf '    ## %s\n' "$to"
    printf '%s\n' "$txt" | sed -e 's/^/    /' -e 's/^ *$//'
    note "versions between $from and $to, if any: $CHANGELOG_URL (every kit ships CHANGELOG.md)"
  else
    note "no release notes in the manifest: $CHANGELOG_URL"
  fi
}

# ---------- downloads ----------
fetch() { curl -fsSL --max-time 300 -o "$2" "$1" || die "download failed: $1"; }
# fetch_release <version> <dir>: manifest.json, SHA256SUMS and the tar.gz into <dir>. The archive's sha256 must
# match the manifest AND SHA256SUMS, or nothing is installed. Sets REL_TAR and REL_MANIFEST.
REL_TAR="" REL_MANIFEST=""
fetch_release() {
  local ver=$1 d=$2 base i=0 n kind tarname="" sum1="" sum2 got
  base=${DOWNLOAD_BASE%/}
  fetch "$base/$ver/manifest.json" "$d/manifest.json"
  fetch "$base/$ver/SHA256SUMS" "$d/SHA256SUMS"
  [ "$(json_get version <"$d/manifest.json")" = "$ver" ] || die "manifest.json is not for $ver"
  n=$(json_get files.# <"$d/manifest.json")
  while [ "$i" -lt "${n:-0}" ]; do
    kind=$(json_get "files.$i.kind" <"$d/manifest.json")
    if [ "$kind" = tar.gz ]; then
      tarname=$(json_get "files.$i.name" <"$d/manifest.json")
      sum1=$(json_get "files.$i.sha256" <"$d/manifest.json")
    fi
    i=$((i + 1))
  done
  case "$tarname" in gooseplicant-"$ver".tar.gz) ;; *) die "manifest.json lists no gooseplicant-$ver.tar.gz" ;; esac
  sum2=$(awk -v n="$tarname" '$2 == n || $2 == "*" n { print $1 }' "$d/SHA256SUMS")
  [ -n "$sum2" ] && [ "$sum1" = "$sum2" ] || die "manifest.json and SHA256SUMS disagree about $tarname"
  fetch "$base/$ver/$tarname" "$d/$tarname"
  got=$(sha256_of "$d/$tarname")
  [ "$got" = "$sum1" ] || die "checksum mismatch for $tarname (expected $sum1, got $got): not installing it"
  REL_TAR="$d/$tarname"
  REL_MANIFEST="$d/manifest.json"
}
# unpack_kit <tar.gz> <dir> <version> → <dir>/gooseplicant-<version>. Kit files aren't secret: unpacked 644/755
# as released (under goose's umask 077 the image build would copy 600 files the app user can't read).
unpack_kit() {
  (umask 022 && tar -xzf "$1" -C "$2") || die "could not unpack $(basename "$1")"
  [ -x "$2/gooseplicant-$3/deploy/goose" ] || die "$(basename "$1") has no gooseplicant-$3/deploy/goose"
}

# ---------- kit files (what an in-place update replaces) ----------
# deploy/.kit-files lists the files of the installed kit, relative to the kit directory. setup and upgrade-finish
# write it from deploy/kit-files.txt (in every release kit) or, without one, from the directory tree; update
# rewrites it from the new kit's archive. Settings, secrets, config and backups are never kit files, and files
# that aren't listed (yours) are never touched.
STATE_RE='^deploy/(\.env|\.env\..*|\.kit-files|\.kit-files\..*|config/.*|secrets/.*|backups/.*)$'
kit_list_clean() { # stdin: paths → sorted kit-relative file paths (no state, no "..", nothing absolute)
  { sed -e 's|^\./||' -e '/^#/d' -e '/^$/d' -e '/\/$/d' | grep -Ev '^/|(^|/)\.\.(/|$)' | grep -Ev "$STATE_RE" | LC_ALL=C sort -u; } || true
}
kit_files_tree() { # the kit as found on disk, minus state and the usual local-only files
  (cd "$KIT_DIR" && find . \( -path ./deploy/.env -o -path './deploy/.env.*' -o -path './deploy/.kit-files*' \
    -o -path ./deploy/config -o -path ./deploy/secrets -o -path ./deploy/backups -o -path ./data -o -path './apps/*/data' \
    -o -path ./release -o -name node_modules -o -name .git -o -name .wrangler -o -name .DS_Store -o -name .env \
    -o -name .dev.vars -o \( -name '.env.*' ! -name .env.example \) -o \( -name '.dev.vars.*' ! -name .dev.vars.example \) \) \
    -prune -o -type f -print) | kit_list_clean
}
kit_files_known() { # → 0 + the list when this install records (or ships) its file list
  if [ -f "$GOOSE_DIR/.kit-files" ]; then kit_list_clean <"$GOOSE_DIR/.kit-files"
  elif [ -f "$GOOSE_DIR/kit-files.txt" ]; then kit_list_clean <"$GOOSE_DIR/kit-files.txt"
  else return 1; fi
}
write_kit_files() { # "where from" < list
  local tmp="$GOOSE_DIR/.kit-files.tmp.$$"
  {
    printf '# Files of the Gooseplicant %s kit in this directory (%s, %s).\n' "$(kit_version)" "$1" "$(now_utc)"
    printf '# Written by goose: ./goose update replaces exactly these; files not listed here are never touched.\n'
    cat
  } >"$tmp"
  chmod 644 "$tmp"
  mv "$tmp" "$GOOSE_DIR/.kit-files"
}
record_kit_files() { # setup / upgrade-finish: the shipped list wins; a tree walk only when nothing is recorded yet
  if [ -f "$GOOSE_DIR/kit-files.txt" ]; then
    kit_list_clean <"$GOOSE_DIR/kit-files.txt" | write_kit_files "from deploy/kit-files.txt"
  elif [ ! -f "$GOOSE_DIR/.kit-files" ]; then
    kit_files_tree | write_kit_files "from the directory tree"
  fi
}
# goose ≤ 0.4's upgrade unpacked the new kit under umask 077: 600 files and 700 directories, which Docker COPY
# keeps, so the app (user node) couldn't read its own sources. Kit files are public: give them 644/755 back.
kit_fix_modes() {
  local list x
  list="$(tmpdir)/kit-modes"
  kit_files_known >"$list" || kit_files_tree >"$list"
  [ -s "$list" ] || return 0
  (
    cd "$KIT_DIR"
    : >"$list.r"
    : >"$list.x"
    while IFS= read -r x; do
      if [ -f "$x" ]; then
        printf '%s\0' "$x" >>"$list.r"
        if [ -x "$x" ]; then printf '%s\0' "$x" >>"$list.x"; fi
      fi
    done <"$list"
    awk -F/ '{ p = $1; for (i = 2; i < NF; i++) { print p; p = p "/" $i } if (NF > 1) print p }' "$list" | LC_ALL=C sort -u | tr '\n' '\0' >"$list.d"
    if [ -s "$list.r" ]; then xargs -0 chmod go+r <"$list.r"; fi
    if [ -s "$list.x" ]; then xargs -0 chmod go+x <"$list.x"; fi
    if [ -s "$list.d" ]; then xargs -0 chmod go+rx <"$list.d" 2>/dev/null || true; fi
  )
}
kit_delete() { # stdin: kit-relative paths → removed, with the directories that leaves empty
  local f d
  while IFS= read -r f; do
    [ -n "$f" ] || continue
    rm -f "$KIT_DIR/$f"
    d=${f%/*}
    if [ "$d" != "$f" ]; then (cd "$KIT_DIR" && rmdir -p "$d" 2>/dev/null) || true; fi
  done
}
# kit_sync <new kit dir> <new list> <old list>: every new-kit file over the install (modes as released),
# then delete what only the old kit had. Nobody may be executing a script from the install meanwhile.
kit_sync() {
  (cd "$1" && tar -cf - -T "$2") | (umask 022 && cd "$KIT_DIR" && tar -xpf -) || die "copying the new kit into $KIT_DIR failed"
  LC_ALL=C comm -23 "$3" "$2" | kit_delete
}
# kit_backup <version> → $BACKUP_DIR/kit-<version>.tar.gz (KIT_BACKUP): this directory as it is, minus settings,
# secrets, config and backups (those are in the regular backup).
KIT_BACKUP=""
kit_backup() {
  local out="$BACKUP_DIR/kit-$1.tar.gz"
  mkdir -p "$BACKUP_DIR"
  (cd "$KIT_DIR" && tar -czf "$out.tmp" --exclude ./deploy/.env --exclude './deploy/.env.*' --exclude ./deploy/config \
    --exclude ./deploy/secrets --exclude ./deploy/backups --exclude ./node_modules --exclude ./.git .) ||
    die "could not archive the kit to $out"
  chmod 600 "$out.tmp"
  mv "$out.tmp" "$out"
  KIT_BACKUP=$out
}
# kit_restore <kit-<version>.tar.gz> <current list>: the directory as it was when the archive was made (files
# only the newer kit had are deleted first).
kit_restore() {
  local old
  old="$(tmpdir)/kit-restore-list"
  tar -tzf "$1" >"$old.raw" || die "can't read $1"
  kit_list_clean <"$old.raw" >"$old"
  LC_ALL=C comm -23 "$2" "$old" | kit_delete
  rm -f "$GOOSE_DIR/.kit-files"
  (umask 022 && cd "$KIT_DIR" && tar -xpzf "$1") || die "could not unpack $1 into $KIT_DIR"
}
# update_lists <verified tar.gz> <old list out> <new list out>
update_lists() {
  if ! kit_files_known >"$2"; then
    : >"$2"
    warn "no record of this kit's files (deploy/.kit-files): files the new kit dropped stay in place"
  fi
  tar -tzf "$1" >"$3.raw" || die "can't read $1"
  sed 's|^[^/]*/||' "$3.raw" | kit_list_clean >"$3"
  grep -qx deploy/goose "$3" && grep -qx package.json "$3" || die "$(basename "$1") doesn't look like a Gooseplicant kit"
}

# ---------- update log (deploy/backups/update.log) ----------
# One "@update" / "@rollback" line per step, tab-separated key=value fields; scheduled runs append their output
# to the same file, which is why goose's own records start with "@".
log_record() { # kind key=value...
  local line kv
  mkdir -p "$BACKUP_DIR"
  line="@$1 $(now_utc)"
  shift
  for kv in "$@"; do line="$line"$'\t'"$kv"; done
  printf '%s\n' "$line" >>"$UPDATE_LOG"
  chmod 600 "$UPDATE_LOG" 2>/dev/null || true
}
rec_get() { printf '%s\n' "$1" | tr '\t' '\n' | sed -n "s/^$2=//p" | head -n 1; }
# The last run that changed what is installed: its final record. Runs that changed nothing (failed before the
# switch, rolled back) don't count; an interrupted run ("started") or a failed rollback does.
last_change() {
  [ -f "$UPDATE_LOG" ] || return 0
  awk -F'\t' '/^@(update|rollback) / {
      id = ""; res = ""
      for (i = 2; i <= NF; i++) { if ($i ~ /^id=/) id = substr($i, 4); if ($i ~ /^result=/) res = substr($i, 8) }
      if (id == "") next
      if (!(id in last)) ord[++n] = id
      last[id] = $0; r[id] = res; kind[id] = substr($1, 2, index($1, " ") - 2)
    }
    END { for (i = n; i >= 1; i--) { id = ord[i]
        if (kind[id] == "update" && (r[id] == "ok" || r[id] == "failed-kept" || r[id] == "started" || r[id] == "rollback-failed")) { print last[id]; exit }
        if (kind[id] == "rollback" && r[id] == "ok") { print last[id]; exit } } }' "$UPDATE_LOG"
}
lock_update() { # one update/rollback at a time (a scheduled run vs. a person); exec keeps the pid, so it carries over
  local l="$BACKUP_DIR/.update.lock" pid
  mkdir -p "$BACKUP_DIR"
  if ! mkdir "$l" 2>/dev/null; then
    pid=$(cat "$l/pid" 2>/dev/null || true)
    if [ -n "$pid" ] && [ "$pid" != "$$" ] && kill -0 "$pid" 2>/dev/null; then die "another update or rollback is running (pid $pid)"; fi
    rm -rf "$l"
    mkdir "$l" || die "can't create $l"
  fi
  printf '%s\n' "$$" >"$l/pid"
  LOCK_DIR=$l
}
need_tty_or_yes() { # yes "what to add --yes to"
  [ "$1" = 1 ] || { [ -t 0 ] && [ -r /dev/tty ]; } || die "no terminal to confirm on: run $2 --yes to do it unattended"
}

# ---------- upgrade (side by side) ----------
cmd_upgrade() {
  require_setup
  local want="latest" files_only=0 yes=0
  while [ $# -gt 0 ]; do
    case "$1" in
      --files-only) files_only=1; shift ;;
      --yes | -y) yes=1; shift ;;
      -*) die "unknown option $1" ;;
      *) want=$1; shift ;;
    esac
  done
  local t ver cur target
  t=$(tmpdir)
  step "Upgrade side by side (${DOWNLOAD_BASE%/})"
  note "./goose update does this in place, in this folder, and rolls back by itself if the new version fails"
  if [ "$want" = latest ]; then
    fetch "${DOWNLOAD_BASE%/}/latest/manifest.json" "$t/latest.json"
    ver=$(json_get version <"$t/latest.json")
  else
    ver=${want#v}
  fi
  is_semver "$ver" || die "not a version: $ver"
  cur=$(kit_version)
  [ "$ver" != "$cur" ] || { ok "already on $cur"; return 0; }
  fetch_release "$ver" "$t"
  ok "downloaded $(basename "$REL_TAR"), sha256 verified"
  target="$(dirname "$KIT_DIR")/gooseplicant-$ver"
  [ ! -e "$target" ] || die "$target already exists; move it away and re-run"
  mkdir -p "$t/unpack"
  unpack_kit "$REL_TAR" "$t/unpack" "$ver"
  [ "$yes" = 1 ] || [ ! -t 0 ] || ask_yn_tty "Upgrade $cur → $ver (installs into $target; this kit stays for rollback)?" || die "cancelled"
  if [ "$files_only" = 0 ]; then
    need_docker
    cmd_backup
  fi
  mv "$t/unpack/gooseplicant-$ver" "$target"
  cp "$ENV_FILE" "$target/deploy/.env"
  cp -R "$CONFIG_DIR" "$target/deploy/config"
  cp -R "$SECRETS_DIR" "$target/deploy/secrets"
  chmod 600 "$target/deploy/.env"
  ok "unpacked to $target; carried over deploy/.env, config/, secrets/"
  if [ "$files_only" = 1 ]; then
    note "files only: run  $target/deploy/goose upgrade-finish  to rebuild and restart"
    return 0
  fi
  "$target/deploy/goose" upgrade-finish
  note "rollback: cd $GOOSE_DIR && ./goose up --build   (the backup is $BACKUP_FILE)"
}
cmd_upgrade_finish() { # runs from the NEW kit (also when an older goose, e.g. 0.4's, did the upgrade)
  require_setup
  need_docker
  kit_fix_modes
  record_kit_files
  env_set GOOSEPLICANT_VERSION "$(kit_version)"
  render_all
  validate_configs
  dc build gooseplicant caddy
  cmd_up
  cmd_doctor || warn "doctor found problems after the upgrade (see above)"
  note "from now on, update in place from here: cd $GOOSE_DIR && ./goose update"
}

# ---------- update (in place) ----------
# update      (this goose): latest/--to version → compare → [--check: notes, exit 0/10] → download + verify →
#             notes → confirm → exec the NEW kit's goose (from the staging copy) with update-apply.
# update-apply (the new goose): build images + validate configs from the staging copy (nothing here changes;
#             a failure ends with "nothing changed") → backup + kit-<old>.tar.gz → replace the kit files in place
#             → render → up → health → doctor. A failure from the switch on rolls back by itself (do_rollback).
# Exit codes: 0 updated or up to date · 10 --check: an update is available · 1 error, nothing changed ·
#             2 the new version failed and goose rolled back · 3 failed and NOT rolled back (--no-rollback, or the
#             rollback failed too): see the message, then ./goose rollback
cmd_update() {
  local check=0 yes=0 to="" no_rb="" sched=""
  while [ $# -gt 0 ]; do
    case "$1" in
      --check) check=1; shift ;;
      --yes | -y) yes=1; shift ;;
      --to) to=${2:?--to needs a version}; shift 2 ;;
      --to=*) to=${1#--to=}; shift ;;
      --no-rollback) no_rb=--no-rollback; shift ;;
      --schedule) sched=${2:?--schedule needs daily, weekly, off or status}; shift 2 ;;
      --schedule=*) sched=${1#--schedule=}; shift ;;
      -h | --help) usage; return 0 ;;
      *) die "update: unknown option $1" ;;
    esac
  done
  require_setup
  if [ -n "$sched" ]; then
    cmd_update_schedule "$sched" "$yes"
    return $?
  fi
  local t cur ver
  t=$(tmpdir)
  cur=$(kit_version)
  is_semver "$cur" || die "can't read this kit's version from $KIT_DIR/package.json"
  [ -t 1 ] || say "--- goose update $(now_utc) in $KIT_DIR" # scheduled runs: a dated line in update.log
  if [ -n "$to" ]; then
    ver=${to#v}
    is_semver "$ver" || die "not a version: $to"
    fetch "${DOWNLOAD_BASE%/}/$ver/manifest.json" "$t/target.json"
  else
    fetch "${DOWNLOAD_BASE%/}/latest/manifest.json" "$t/target.json"
    ver=$(json_get version <"$t/target.json")
    is_semver "$ver" || die "${DOWNLOAD_BASE%/}/latest/manifest.json has no valid version"
  fi
  [ "$(json_get version <"$t/target.json")" = "$ver" ] || die "manifest.json is not for $ver"
  if ! version_gt "$ver" "$cur"; then
    say "Up to date ($cur$([ "$ver" = "$cur" ] || printf '; %s is not newer' "$ver"))"
    return 0
  fi
  if [ "$check" = 1 ]; then
    say "Update available: $cur → $ver"
    say "  installed  $cur   ($KIT_DIR)"
    say "  available  $ver   (${DOWNLOAD_BASE%/}/$ver/)"
    say ""
    say "What's new:"
    print_notes "$cur" "$ver" "" "$t/target.json"
    say ""
    say "  Install it with ./goose update (in place; backup first, automatic rollback)."
    exit 10
  fi
  need_tty_or_yes "$yes" "./goose update"
  need_docker
  lock_update
  rm -rf "$BACKUP_DIR"/.staging-*
  local stage="$BACKUP_DIR/.staging-$ver"
  mkdir -p "$stage"
  CLEANUP_EXTRA=$stage
  step "Update $cur → $ver (${DOWNLOAD_BASE%/})"
  fetch_release "$ver" "$stage"
  ok "downloaded $(basename "$REL_TAR"), sha256 verified (manifest.json + SHA256SUMS)"
  unpack_kit "$REL_TAR" "$stage" "$ver"
  say ""
  say "What's new since $cur:"
  print_notes "$cur" "$ver" "$stage/gooseplicant-$ver/CHANGELOG.md" "$REL_MANIFEST"
  say ""
  if [ "$yes" != 1 ]; then
    ask_yn_tty "Update $cur → $ver in place ($KIT_DIR)? A backup comes first; if $ver doesn't come up, goose rolls back by itself." ||
      die "cancelled: nothing changed"
  fi
  # The new kit's goose takes over, from the staging copy: it replaces the files here (this script included,
  # which bash must not be reading meanwhile) and owns the rollback. exec skips the EXIT trap: tidy up by hand.
  CLEANUP_EXTRA=""
  rm -rf "$TMP_ROOT"
  exec bash "$stage/gooseplicant-$ver/deploy/goose" update-apply --install "$GOOSE_DIR" --from "$cur" \
    --staging "$stage" --tar "$REL_TAR" ${no_rb:+"$no_rb"}
}

# update-apply: run by the NEW kit's goose from the staging directory. Older kits call it, so keep it stable:
#   update-apply --install <deploy dir> --from <installed version> --staging <dir> --tar <verified archive> [--no-rollback]
cmd_update_apply() {
  local install="" from="" stage="" tarf="" no_rb=0
  while [ $# -gt 0 ]; do
    case "$1" in
      --install) install=$2; shift 2 ;;
      --from) from=$2; shift 2 ;;
      --staging) stage=$2; shift 2 ;;
      --tar) tarf=$2; shift 2 ;;
      --no-rollback) no_rb=1; shift ;;
      *) shift ;; # newer callers may pass more; ignore
    esac
  done
  [ -d "$install" ] && [ -n "$from" ] && [ -d "$stage" ] && [ -f "$tarf" ] || die "update-apply is internal: use ./goose update"
  local newdeploy=$GOOSE_DIR newkit=$KIT_DIR ver
  ver=$(kit_version)
  set_dirs "$(cd "$install" && pwd -P)"
  require_setup
  need_docker
  lock_update
  CLEANUP_EXTRA=$stage
  local t id rc
  t=$(tmpdir)
  id="$(date -u +%Y%m%d-%H%M%S)-$$"

  step "Building $ver beside the running $from (nothing in $KIT_DIR changes yet)"
  cp "$ENV_FILE" "$newdeploy/.env"
  chmod 600 "$newdeploy/.env"
  rm -rf "$newdeploy/config" "$newdeploy/secrets"
  cp -Rp "$CONFIG_DIR" "$newdeploy/config"
  cp -Rp "$SECRETS_DIR" "$newdeploy/secrets"
  set +e
  (
    set -e
    set_dirs "$newdeploy"
    env_set GOOSEPLICANT_VERSION "$ver"
    render_all
    dc build gooseplicant caddy
    validate_configs
    dc pull --quiet authelia || warn "couldn't pull the Authelia image now; up tries again"
    [ -z "$TUNNEL_TOKEN" ] || dc pull --quiet cloudflared || warn "couldn't pull the cloudflared image now; up tries again"
  )
  rc=$?
  set -e
  if [ "$rc" != 0 ]; then
    log_record update "id=$id" "from=$from" "to=$ver" result=failed stage=build
    die "building or validating $ver failed (see above). Nothing changed: $from keeps running."
  fi
  ok "$ver: images built, configuration valid"

  step "Backup before the switch"
  cmd_backup
  kit_backup "$from"
  ok "the $from kit → $KIT_BACKUP"
  update_lists "$tarf" "$t/old-files" "$t/new-files"
  log_record update "id=$id" "from=$from" "to=$ver" result=started "backup=$BACKUP_FILE" "kit=$KIT_BACKUP"

  set +e
  (
    set -e
    printf 'replace files\n' >"$t/stage"
    step "Replacing the kit in $KIT_DIR (settings, secrets, config, backups and your own files stay)"
    kit_sync "$newkit" "$t/new-files" "$t/old-files"
    write_kit_files "from the $ver archive" <"$t/new-files"
    [ "$(kit_version)" = "$ver" ] || die "the kit here reports $(kit_version) after the switch, expected $ver"
    ok "$(wc -l <"$t/new-files" | tr -d ' ') kit files in place, $(LC_ALL=C comm -23 "$t/old-files" "$t/new-files" | wc -l | tr -d ' ') that $ver dropped removed"
    env_set GOOSEPLICANT_VERSION "$ver"
    render_all
    validate_configs
    printf 'start\n' >"$t/stage"
    cmd_up
    printf 'health\n' >"$t/stage"
    wait_healthy gooseplicant 30 || die "gooseplicant $ver did not become healthy"
    wait_healthy authelia 30 || die "authelia did not become healthy"
    running caddy || die "caddy is not running"
    printf 'doctor\n' >"$t/stage"
    cmd_doctor || die "doctor reported FAIL after the switch"
  )
  rc=$?
  set -e
  if [ "$rc" = 0 ]; then
    log_record update "id=$id" "from=$from" "to=$ver" result=ok "backup=$BACKUP_FILE" "kit=$KIT_BACKUP"
    step "Updated $from → $ver in place ($KIT_DIR)"
    say "  backup    $BACKUP_FILE   (data + settings before the update)"
    say "  previous  $KIT_BACKUP"
    say "  undo      ./goose rollback   (back to $from and that backup: later changes are lost)"
    return 0
  fi
  local stage_name flog
  stage_name=$(cat "$t/stage" 2>/dev/null || echo "replace files")
  flog="$BACKUP_DIR/update-failed-$ver-$id.log"
  { printf 'goose update %s → %s failed at "%s" (%s)\n\n' "$from" "$ver" "$stage_name" "$(now_utc)"; dc ps -a; dc logs --no-color --tail 300; } >"$flog" 2>&1 || true
  chmod 600 "$flog" 2>/dev/null || true
  if [ "$no_rb" = 1 ]; then
    log_record update "id=$id" "from=$from" "to=$ver" result=failed-kept "stage=$stage_name" "backup=$BACKUP_FILE" "kit=$KIT_BACKUP" "logs=$flog"
    printf '\n%serror:%s update %s → %s failed at "%s"; --no-rollback: left as it is.\n' "$C_R$C_BOLD" "$C_0" "$from" "$ver" "$stage_name" >&2
    say "  container logs  $flog"
    say "  back to $from     ./goose rollback   (kit $KIT_BACKUP + backup $BACKUP_FILE)"
    exit 3
  fi
  step "Update to $ver failed at \"$stage_name\": rolling back to $from"
  if do_rollback "$KIT_BACKUP" "$BACKUP_FILE" "$t/new-files"; then
    log_record update "id=$id" "from=$from" "to=$ver" result=rolled-back "stage=$stage_name" "backup=$BACKUP_FILE" "kit=$KIT_BACKUP" "logs=$flog"
    printf '\n%sUpdate %s → %s failed%s (at "%s") and was rolled back: %s runs again, with its kit and the data\n' "$C_R$C_BOLD" "$from" "$ver" "$C_0" "$stage_name" "$from"
    say "of the backup taken right before the switch ($BACKUP_FILE)."
    say "  logs of the failed $ver containers  $flog"
    say "  update history                     $UPDATE_LOG"
    exit 2
  fi
  log_record update "id=$id" "from=$from" "to=$ver" result=rollback-failed "stage=$stage_name" "backup=$BACKUP_FILE" "kit=$KIT_BACKUP" "logs=$flog"
  printf '\n%serror:%s update %s → %s failed at "%s", and the automatic rollback failed too (see above).\n' "$C_R$C_BOLD" "$C_0" "$from" "$ver" "$stage_name" >&2
  say "  retry the rollback   ./goose rollback --yes"
  say "  by hand              tar -xzf $KIT_BACKUP -C $KIT_DIR && ./goose restore $BACKUP_FILE --yes && ./goose up --build"
  say "  logs                 $flog"
  exit 3
}

# do_rollback <kit tar> <backup> <current kit list>: the old kit's files back, then the OLD goose restores its
# backup (settings + data: data migrations are forward-only), rebuilds its images, starts and checks.
do_rollback() {
  local rc
  set +e
  (
    set -e
    dc down --remove-orphans >/dev/null 2>&1 || true
    kit_restore "$1" "$3"
    ok "kit files of $(kit_version) restored"
    bash "$GOOSE_DIR/goose" restore "$2" --yes
    bash "$GOOSE_DIR/goose" up --build
    bash "$GOOSE_DIR/goose" doctor
  )
  rc=$?
  set -e
  return "$rc"
}

cmd_rollback() {
  local yes=0
  while [ $# -gt 0 ]; do
    case "$1" in
      --yes | -y) yes=1; shift ;;
      *) die "rollback: unknown option $1" ;;
    esac
  done
  require_setup
  local rec cur from to bk kt res created
  rec=$(last_change)
  [ -n "$rec" ] || die "nothing to roll back: deploy/backups/update.log records no update (after ./goose upgrade, the previous version is still in its own folder: ../gooseplicant-<old>)"
  case "$rec" in '@rollback '*) die "the last change was already a rollback ($(rec_get "$rec" from) → $(rec_get "$rec" to)); goose keeps one step of history" ;; esac
  cur=$(kit_version)
  from=$(rec_get "$rec" from)
  to=$(rec_get "$rec" to)
  bk=$(rec_get "$rec" backup)
  kt=$(rec_get "$rec" kit)
  res=$(rec_get "$rec" result)
  if [ "$cur" != "$to" ] && [ "$res" = ok ]; then
    die "the last update installed $to, but this kit is $cur: it changed since; restore by hand (./goose restore <backup>)"
  fi
  [ -f "$bk" ] || die "the pre-update backup is gone: $bk"
  [ -f "$kt" ] || die "the previous kit is gone: $kt"
  created=$(tar -xzOf "$bk" MANIFEST 2>/dev/null | sed -n 's/^created //p' || true)
  step "Roll back $cur → $from"
  [ "$res" = ok ] || warn "the update to $to ended with \"$res\""
  say "  kit     $kt"
  say "  backup  $bk"
  say "          taken ${created:-(unknown time)}, right before the update to $to"
  warn "everything that changed since ${created:-that backup} is lost: applications that arrived, edits, new users, 2FA registrations"
  need_tty_or_yes "$yes" "./goose rollback"
  if [ "$yes" != 1 ]; then ask_yn_tty "Roll back to $from and the data of ${created:-the backup}?" || die "cancelled"; fi
  need_docker
  lock_update
  # Restoring the kit replaces this very script: go on from a copy outside the install.
  local t
  t=$(mktemp -d "${TMPDIR:-/tmp}/goose-rollback.XXXXXX")
  cp "$SELF" "$t/goose"
  rm -rf "$TMP_ROOT"
  exec bash "$t/goose" rollback-apply --install "$GOOSE_DIR" --update-id "$(rec_get "$rec" id)" --from "$cur" --to "$from" \
    --kit "$kt" --backup "$bk" --cleanup "$t"
}
cmd_rollback_apply() {
  local install="" uid="" from="" to="" kt="" bk="" clean=""
  while [ $# -gt 0 ]; do
    case "$1" in
      --install) install=$2; shift 2 ;;
      --update-id) uid=$2; shift 2 ;;
      --from) from=$2; shift 2 ;;
      --to) to=$2; shift 2 ;;
      --kit) kt=$2; shift 2 ;;
      --backup) bk=$2; shift 2 ;;
      --cleanup) clean=$2; shift 2 ;;
      *) shift ;;
    esac
  done
  [ -d "$install" ] && [ -f "$kt" ] && [ -f "$bk" ] || die "rollback-apply is internal: use ./goose rollback"
  set_dirs "$(cd "$install" && pwd -P)"
  require_setup
  need_docker
  lock_update
  CLEANUP_EXTRA=$clean
  local t id
  t=$(tmpdir)
  id="$(date -u +%Y%m%d-%H%M%S)-$$"
  kit_files_known >"$t/cur" || : >"$t/cur"
  if do_rollback "$kt" "$bk" "$t/cur"; then
    log_record rollback "id=$id" "update=$uid" "from=$from" "to=$to" result=ok "backup=$bk" "kit=$kt"
    step "Rolled back $from → $to"
    say "  data and settings as of $bk"
    return 0
  fi
  log_record rollback "id=$id" "update=$uid" "from=$from" "to=$to" result=failed "backup=$bk" "kit=$kt"
  printf '\n%serror:%s the rollback to %s failed (see above). Retry: ./goose rollback --yes\n' "$C_R$C_BOLD" "$C_0" "$to" >&2
  exit 3
}

# ---------- scheduled updates (cron) ----------
cron_marker() { printf '# gooseplicant-update %s' "$GOOSE_DIR"; }
cron_cmd() { "${GOOSE_CRONTAB:-crontab}" "$@"; }
shq() { printf "'%s'" "$(printf '%s' "$1" | sed "s/'/'\\\\''/g")"; }
cron_entry() { # daily|weekly → the crontab line (03:17 local time; weekly: Sundays). cron's PATH is minimal: pass ours.
  local dow='*' p="" d x
  [ "$1" = weekly ] && dow=0
  for x in docker curl openssl jq python3 node; do
    have "$x" || continue
    d=$(dirname "$(command -v "$x")")
    case ":$p:" in *":$d:"*) ;; *) p="${p:+$p:}$d" ;; esac
  done
  for d in /usr/local/bin /usr/bin /bin /usr/sbin /sbin; do
    case ":$p:" in *":$d:"*) ;; *) p="${p:+$p:}$d" ;; esac
  done
  case "$p$GOOSE_DIR" in *%*) die "cron can't run a path containing %: $GOOSE_DIR" ;; esac
  printf '17 3 * * %s PATH=%s %s update --yes >> %s 2>&1 %s\n' "$dow" "$(shq "$p")" "$(shq "$GOOSE_DIR/goose")" "$(shq "$UPDATE_LOG")" "$(cron_marker)"
}
wt_schedule_manual() { # daily|weekly|off
  local when='*-*-* 03:17:00' cal='Hour 3, Minute 17' run
  [ "$1" != weekly ] || { when='Sun *-*-* 03:17:00'; cal='Weekday 0, Hour 3, Minute 17'; }
  run="$GOOSE_DIR/goose update --yes >> $UPDATE_LOG 2>&1"
  wt_begin "Schedule ./goose update yourself (there is no crontab command here)"
  if [ "$1" = off ]; then
    wt "systemd: sudo systemctl disable --now gooseplicant-update.timer"
    wt "macOS: launchctl unload ~/Library/LaunchAgents/dev.goosenotes.gooseplicant-update.plist (then delete the file)"
    wt_end
    return 0
  fi
  wt "Linux (systemd): /etc/systemd/system/gooseplicant-update.service" \
    "[Service]" "Type=oneshot" "User=$(id -un)" "ExecStart=/bin/sh -c '$run'"
  wt "and /etc/systemd/system/gooseplicant-update.timer" \
    "[Timer]" "OnCalendar=$when" "Persistent=true" "[Install]" "WantedBy=timers.target"
  wt "sudo systemctl daemon-reload && sudo systemctl enable --now gooseplicant-update.timer" "check: systemctl list-timers gooseplicant-update.timer"
  wt "macOS (launchd): ~/Library/LaunchAgents/dev.goosenotes.gooseplicant-update.plist with" \
    "Label dev.goosenotes.gooseplicant-update" \
    "ProgramArguments /bin/sh, -c, $run" \
    "StartCalendarInterval $cal" \
    "then: launchctl load ~/Library/LaunchAgents/dev.goosenotes.gooseplicant-update.plist"
  wt "Scheduled runs pass --yes (never a prompt) and log to $UPDATE_LOG; ./goose update --check shows what's pending."
  wt_end
}
cmd_update_schedule() { # daily|weekly|off|status yes
  local what=$1 yes=$2 current mine line tmp
  case "$what" in daily | weekly | off | status) ;; *) die "--schedule takes daily, weekly, off or status" ;; esac
  if ! have "${GOOSE_CRONTAB:-crontab}"; then
    if [ "$what" = status ]; then say "No crontab command on this machine: goose can't see systemd timers or launchd jobs."; return 0; fi
    wt_schedule_manual "$what"
    return 1
  fi
  current=$(cron_cmd -l 2>/dev/null || true)
  mine=$(printf '%s\n' "$current" | awk -v m="$(cron_marker)" 'index($0, m) { print }')
  if [ "$what" = status ]; then
    if [ -n "$mine" ]; then
      say "Scheduled update (crontab of $(id -un)):"
      printf '%s\n' "$mine" | sed 's/^/  /'
    else
      say "No scheduled update for $GOOSE_DIR."
    fi
    if [ -f "$UPDATE_LOG" ] && grep -q '^@update ' "$UPDATE_LOG"; then say "Last update record:"; grep '^@update ' "$UPDATE_LOG" | tail -n 1 | tr '\t' ' ' | sed 's/^/  /'; fi
    return 0
  fi
  if [ "$what" = off ]; then
    [ -n "$mine" ] || { ok "no scheduled update for $GOOSE_DIR"; return 0; }
    say "  remove: $mine"
    need_tty_or_yes "$yes" "./goose update --schedule off"
    if [ "$yes" != 1 ]; then ask_yn_tty "Remove it from the crontab of $(id -un)?" || die "cancelled"; fi
    line=""
  else
    line=$(cron_entry "$what")
    say "  crontab entry ($(id -un)), $what at 03:17 local time$([ "$what" = daily ] || printf ', Sundays'):"
    say "    $line"
    need_tty_or_yes "$yes" "./goose update --schedule $what"
    if [ "$yes" != 1 ]; then ask_yn_tty "Add it? Each run updates unattended (backup, automatic rollback) and logs to $UPDATE_LOG" || die "cancelled"; fi
  fi
  tmp="$(tmpdir)/crontab"
  {
    [ -z "$current" ] || printf '%s\n' "$current" | awk -v m="$(cron_marker)" '!index($0, m)'
    [ -z "$line" ] || printf '%s\n' "$line"
  } >"$tmp"
  cron_cmd "$tmp" || die "crontab refused the new table ($tmp)"
  if [ -n "$line" ]; then
    ok "scheduled: ./goose update --yes $what at 03:17 (./goose update --schedule status | off)"
    if [ "$(uname -s)" = Darwin ]; then
      case "$KIT_DIR" in "$HOME/Desktop"* | "$HOME/Documents"* | "$HOME/Downloads"*)
        warn "macOS doesn't let cron into Desktop/Documents/Downloads: give /usr/sbin/cron Full Disk Access, or move the install" ;;
      esac
    fi
  else
    ok "scheduled update removed"
  fi
}

# ---------- main ----------
main() {
  tmpdir >/dev/null
  local cmd=${1:-help}
  [ $# -gt 0 ] && shift
  case "$cmd" in
    setup) cmd_setup "$@" ;;
    up) cmd_up "$@" ;;
    down) cmd_down "$@" ;;
    restart) cmd_restart "$@" ;;
    logs) cmd_logs "$@" ;;
    status | ps) cmd_status ;;
    add-user) cmd_add_user "$@" ;;
    remove-user) cmd_remove_user "$@" ;;
    passwd) cmd_passwd "$@" ;;
    reset-2fa) cmd_reset_2fa "$@" ;;
    add-domain) cmd_add_domain "$@" ;;
    doctor) cmd_doctor ;;
    code) cmd_code ;;
    connect) cmd_connect ;;
    render) cmd_render ;;
    validate) require_setup; need_docker; validate_configs ;;
    backup) cmd_backup "$@" ;;
    restore) cmd_restore "$@" ;;
    upgrade) cmd_upgrade "$@" ;;
    upgrade-finish) cmd_upgrade_finish ;;
    update) cmd_update "$@" ;;
    update-apply) cmd_update_apply "$@" ;;
    rollback) cmd_rollback "$@" ;;
    rollback-apply) cmd_rollback_apply "$@" ;;
    version | --version) say "goose (Gooseplicant kit $(kit_version), Authelia $AUTHELIA_VERSION, $CADDY_IMAGE)" ;;
    # test hooks (deploy/test/static.sh): the exact functions update and rollback use, without Docker
    _json) json_get "$1" ;; # JSON path lookup on stdin
    _semver) semver_cmp "$1" "$2" ;;
    _changelog) changelog_between "$1" "$2" "$3" ;;
    _kit_files) record_kit_files && kit_files_known ;;
    _kit_fix_modes) kit_fix_modes ;;
    _kit_switch) # <new kit tar.gz> <old version>: kit-<old>.tar.gz, then the in-place replacement update-apply does
      local d
      d="$(tmpdir)/switch"
      mkdir -p "$d"
      update_lists "$1" "$d/old" "$d/new"
      kit_backup "$2"
      unpack_kit "$1" "$d" "$(sed -n 's|^\([^/]*\)/deploy/goose$|\1|p' "$d/new.raw" | sed 's/^gooseplicant-//')"
      kit_sync "$d/$(sed -n 's|^\([^/]*\)/deploy/goose$|\1|p' "$d/new.raw")" "$d/new" "$d/old"
      write_kit_files "from $(basename "$1")" <"$d/new"
      say "$KIT_BACKUP"
      ;;
    _kit_restore) # <kit tar.gz>: what a rollback does to the files
      kit_files_known >"$(tmpdir)/cur" || : >"$(tmpdir)/cur"
      kit_restore "$1" "$(tmpdir)/cur"
      ;;
    help | -h | --help) usage ;;
    *) usage >&2; die "unknown command: $cmd" ;;
  esac
}
main "$@"
