#!/bin/sh
# ShakeKee installer, generated by https://get.shakekee.com.
# Every download is checked against the SHA-256 written into this script.
set -eu
SK_ORIGIN='https://get.shakekee.com'
SK_SLUG='hub'
SK_DASHBOARD_API='https://api.shakekee.com'
SK_DASHBOARD_ORIGIN='https://dash.shakekee.com'
SK_CDN_URL='https://cdn.shakekee.com'
SK_DAEMON_SHA256='cab2b0300d453e1dcc296baa2c7c70c5be3bd93e8a76f564b17a0feb5f4759c9'
sk_fetch_apache() { sk_download 'shakekee-apache.tar.gz' '97682000984325e9b24677567cda4324595390bd9af23776719435fea59c103d'; }
sk_fetch_caddy() { sk_download 'shakekee-caddy.tar.gz' '27cd4468c8e94a5fc8a9d1a00b76e4fdfeae1d782793d3a4fe56f18c880d3a0c'; }
sk_fetch_cloudflare() { sk_download 'shakekee-cloudflare-1.0.0.tgz' '32959cedca136620c8c8050fc38eb163668c24939eb24162e2563e751dd17e88'; }
sk_fetch_csharp() { sk_download 'ShakeKee.0.1.0.nupkg' '7bf41140ec3fcde17051673793ca4078ea02da9e791aec2ae22e944824757603'; }
sk_fetch_django() { sk_download 'shakekee_django-1.0.0-py3-none-any.whl' 'bfe87b9f48be77f1badea58fc84c448fd2d8a176728d33292d46af57802d8ce2'; }
sk_fetch_drupal() { sk_download 'shakekee-drupal-0.1.0.zip' '70b8734877951bcad8bb7575937b3bf7244eb26bb7155a5be8a23407bebe329e'; }
sk_fetch_go() { sk_download 'shakekee-go-0.1.0.tar.gz' '98c702d46d1a0b39fb9afad4190fde9e5ea667e5582091625b6a69a0f07ea10e'; }
sk_fetch_joomla() { sk_download 'joomla.zip' '4c7666dfe996d283ee7bc7d70a851f3112bb5a87e141f7fb827f68e89fb6734e'; }
sk_fetch_laravel() { sk_download 'shakekee-laravel-1.0.0.zip' '4a9dab5225dcd62180f4f499ac45aff5a81922e766da0b1a8c9bafc7ce5b7ee4'; }
sk_fetch_nginx() { sk_download 'shakekee-nginx.tar.gz' '13972f88834ea27440d9cba0baf15f666de0c88c46f1335d12ee47265066fe15'; }
sk_fetch_nodejs() { sk_download 'shakekee-nodejs-2.0.0.tgz' '59d09d412f070b802528b370f20c135f1fbd0dfdf811ed6d8778b85692df92d6'; }
sk_fetch_php() { sk_download 'php.zip' '4e77466cdd45b840eb01f9ee55126f56512be326eb60b342b5232ebe593a6bf0'; }
sk_fetch_prestashop() { sk_download 'shakekee-0.1.0.zip' 'd404e054e3c487153a64af65fb6dd6e63515539af828b8417692d1eb5231d346'; }
sk_fetch_rust() { sk_download 'shakekee-0.1.0.crate' '80974973026158def374a575bfb4091e994db8fd0716e67c1baa0a2bf7de1631'; }
sk_fetch_shopify() { sk_download 'shakekee-shopify-1.0.0.tgz' 'd63b7c2ff32d387b420ebf4a6f86e2ed01d7d240d7fdc9541484feb138bf6560'; }
sk_fetch_woocommerce() { sk_download 'woocommerce.zip' 'c5de2c806066dee53f474f71e6ff82b01067623471ae4a51241c4fb45c8acbff'; }
sk_fetch_wordpress() { sk_download 'wordpress.zip' '715a234b1158265a896c8397c03d95188579d1988ed48d7d15779dc7de0a8b42'; }
# ---- ui.sh: the terminal UI of the installers (POSIX sh, plain ANSI; defines functions only) ----
# Three modes, chosen by ui_init:
#   rich   stdout is a terminal, colour allowed, arrow-key menus, steps redrawn in place
#   plain  no terminal, NO_COLOR, TERM=dumb or --plain: the same content as plain lines, no colour,
#          no cursor movement (a question is asked as a numbered prompt when a terminal exists)
#   json   --json: one JSON object per line on stdout, field "event"; never asks
# The script arrives on a pipe (through curl), so every answer is read from /dev/tty. Where there is no
# terminal, a call that needs an answer takes its default with --yes (SK_YES=1) and otherwise stops
# with SK-E001 and exit code 2. Colour is never the only carrier of meaning: every state also has a
# word or a symbol. Text handed to the menus and tables should be ASCII (dash counts bytes).
# Scratch variables are named ui_*, results are UI_*; nothing here uses `local`.

# ui_init: decides the mode and sets colours, symbols and the width (60..120).
ui_init() {
  UI_NL='
'
  UI_TAB=$(printf '\t')
  UI_CR=$(printf '\r')
  UI_REPLY=
  UI_STEP_OPEN=0
  UI_STEP_ID=
  UI_STEP_TEXT=
  UI_PROG=-1
  UI_STTY=
  UI_RAW=0
  UI_CURSOR_HIDDEN=0
  UI_ESC=
  UI_B=
  UI_D=
  UI_RED=
  UI_GREEN=
  UI_YELLOW=
  UI_CYAN=
  UI_Z=
  UI_IND=
  case ${UI_FORCE:-} in
    json) UI_MODE=json ;;
    plain) UI_MODE=plain ;;
    *)
      UI_MODE=plain
      # the menus need stty (raw mode) and dd (one byte at a time)
      if [ -t 1 ] && [ -z "${NO_COLOR:-}" ] && [ -n "${TERM:-}" ] && [ "${TERM:-}" != dumb ] &&
        command -v stty >/dev/null 2>&1 && command -v dd >/dev/null 2>&1; then
        UI_MODE=rich
      fi
      ;;
  esac
  UI_TTY=0
  if [ "$UI_MODE" != json ] && (: </dev/tty) 2>/dev/null; then
    UI_TTY=1
  fi
  UI_UTF8=0
  case ${LC_ALL:-${LC_CTYPE:-${LANG:-}}} in
    *[Uu][Tt][Ff]-8* | *[Uu][Tt][Ff]8*) UI_UTF8=1 ;;
  esac
  UI_S_OK='[ok]'
  UI_S_FAIL='[FAIL]'
  UI_S_WARN='[warn]'
  UI_S_RUN='[..]'
  UI_S_PTR='>'
  UI_S_NOTE='-'
  UI_S_BAR='|'
  UI_S_TOP='+'
  UI_S_BOT='+'
  UI_S_HR='-'
  UI_S_FULL='#'
  UI_S_EMPTY='-'
  if [ "$UI_MODE" = rich ]; then
    UI_ESC=$(printf '\033')
    UI_B="${UI_ESC}[1m"
    UI_D="${UI_ESC}[2m"
    UI_RED="${UI_ESC}[31m"
    UI_GREEN="${UI_ESC}[32m"
    UI_YELLOW="${UI_ESC}[33m"
    UI_CYAN="${UI_ESC}[36m"
    UI_Z="${UI_ESC}[0m"
    UI_IND='  '
    if [ "$UI_UTF8" = 1 ]; then
      UI_S_OK='✔'
      UI_S_FAIL='✖'
      UI_S_WARN='▲'
      UI_S_RUN='…'
      UI_S_PTR='❯'
      UI_S_NOTE='›'
      UI_S_BAR='│'
      UI_S_TOP='┌'
      UI_S_BOT='└'
      UI_S_HR='─'
      UI_S_FULL='█'
      UI_S_EMPTY='░'
    fi
  fi
  ui__width
}

ui__width() {
  ui_w=${COLUMNS:-}
  if [ "$UI_TTY" = 1 ] && command -v stty >/dev/null 2>&1; then
    ui_sz=$(stty size </dev/tty 2>/dev/null) || ui_sz=
    case $ui_sz in
      *' '*) ui_w=${ui_sz#* } ;;
    esac
  fi
  case $ui_w in
    '' | 0 | *[!0-9]*) ui_w=80 ;; # zsh reports 0 where it has no terminal
  esac
  if [ "$ui_w" -lt 60 ]; then
    ui_w=60
  elif [ "$ui_w" -gt 120 ]; then
    ui_w=120
  fi
  UI_COLS=$ui_w
}

# ---- small helpers ----

# ui__repeat CHAR N: UI_R is CHAR N times.
ui__repeat() {
  UI_R=
  ui_rn=$2
  while [ "$ui_rn" -gt 0 ]; do
    UI_R=$UI_R$1
    ui_rn=$((ui_rn - 1))
  done
}

# ui__fit TEXT MAX: UI_FIT is TEXT, cut to MAX characters with "..." when it is longer.
ui__fit() {
  UI_FIT=$1
  if [ "${#UI_FIT}" -gt "$2" ]; then
    ui_fn=$(($2 - 3))
    if [ "$ui_fn" -lt 0 ]; then
      ui_fn=0
    fi
    ui__repeat '?' "$ui_fn"
    ui_fr=${UI_FIT#$UI_R}
    UI_FIT="${UI_FIT%"$ui_fr"}..."
  fi
}

# ui__wrap WIDTH TEXT: prints TEXT in lines of at most WIDTH characters (a longer word stays whole).
ui__wrap() {
  ui_ww=$1
  shift
  ui_wl=
  set -f
  for ui_wd in $*; do
    if [ -z "$ui_wl" ]; then
      ui_wl=$ui_wd
    elif [ $((${#ui_wl} + 1 + ${#ui_wd})) -le "$ui_ww" ]; then
      ui_wl="$ui_wl $ui_wd"
    else
      printf '%s\n' "$ui_wl"
      ui_wl=$ui_wd
    fi
  done
  set +f
  if [ -n "$ui_wl" ]; then
    printf '%s\n' "$ui_wl"
  fi
}

# ui__parse_item ITEM: UI_V value, UI_L label, UI_H hint, UI_PRE 1 when the item starts with "+".
ui__parse_item() {
  UI_PRE=0
  ui_qi=$1
  case $ui_qi in
    +*)
      UI_PRE=1
      ui_qi=${ui_qi#+}
      ;;
  esac
  UI_V=${ui_qi%%|*}
  UI_L=$UI_V
  UI_H=
  case $ui_qi in
    *'|'*)
      ui_qr=${ui_qi#*|}
      UI_L=${ui_qr%%|*}
      case $ui_qr in
        *'|'*) UI_H=${ui_qr#*|} ;;
      esac
      ;;
  esac
}

# ui__jq STRING: UI_J is STRING as a JSON string literal.
ui__jq() {
  ui_js=$1
  UI_J=
  case $ui_js in
    *[[:cntrl:]\"\\]*)
      while [ -n "$ui_js" ]; do
        ui_jr=${ui_js#?}
        ui_jc=${ui_js%"$ui_jr"}
        ui_js=$ui_jr
        case $ui_jc in
          '"') UI_J=$UI_J'\"' ;;
          '\') UI_J=$UI_J'\\' ;;
          "$UI_NL") UI_J=$UI_J'\n' ;;
          "$UI_TAB") UI_J=$UI_J'\t' ;;
          "$UI_CR") UI_J=$UI_J'\r' ;;
          [[:cntrl:]]) UI_J="$UI_J " ;;
          *) UI_J=$UI_J$ui_jc ;;
        esac
      done
      ;;
    *) UI_J=$ui_js ;;
  esac
  UI_J="\"$UI_J\""
}

# ui__ev EVENT [KEY VALUE]...: prints one JSON event; a KEY that starts with # is a number,
# a KEY that starts with @ is a ready JSON value (array or object).
ui__ev() {
  ui__jq "$1"
  ui_eo="{\"event\":$UI_J"
  shift
  while [ $# -ge 2 ]; do
    case $1 in
      '#'*)
        case $2 in
          '' | *[!0-9]*) ui_en=0 ;;
          *) ui_en=$2 ;;
        esac
        ui_eo="$ui_eo,\"${1#\#}\":$ui_en"
        ;;
      '@'*) ui_eo="$ui_eo,\"${1#@}\":$2" ;;
      *)
        ui__jq "$2"
        ui_eo="$ui_eo,\"$1\":$UI_J"
        ;;
    esac
    shift 2
  done
  printf '%s\n' "$ui_eo}"
}

# ui__flush: where a step is waiting on the line (rich), wipe that line before other output.
ui__flush() {
  if [ "$UI_MODE" = rich ] && [ "$UI_STEP_OPEN" = 1 ]; then
    printf '%s' "${UI_CR}${UI_ESC}[K"
  fi
}

# ui__reshow: put the waiting step line back after other output.
ui__reshow() {
  if [ "$UI_MODE" = rich ] && [ "$UI_STEP_OPEN" = 1 ]; then
    ui__step_line "$UI_S_RUN" "$UI_STEP_TEXT"
  fi
}

# ui__step_line SYMBOL TEXT: the step line without a newline.
ui__step_line() {
  ui__fit "$2" $((UI_COLS - 12))
  printf '%s' "${UI_IND}$1 $UI_FIT"
}

# ---- terminal control (all through /dev/tty; the standard input is the script) ----

# ui__raw_on: no echo, no line buffering, no CR to LF translation (Enter arrives as CR), and a read that
# gives up after 0.1 s (min 0 time 1). The shell therefore comes back from every read ten times a
# second, so a signal it has a trap for (TERM, HUP) is acted on at once and not at the next key.
# Always undone by ui_cleanup.
ui__raw_on() {
  if [ -z "$UI_STTY" ]; then
    UI_STTY=$(stty -g </dev/tty 2>/dev/null) || UI_STTY=
  fi
  stty -echo -icanon -icrnl min 0 time 1 </dev/tty 2>/dev/null || true
  UI_RAW=1
}

# ui__echo_off: only the echo (a secret is typed on a normal line).
ui__echo_off() {
  if [ -z "$UI_STTY" ]; then
    UI_STTY=$(stty -g </dev/tty 2>/dev/null) || UI_STTY=
  fi
  stty -echo </dev/tty 2>/dev/null || true
  UI_RAW=1
}

ui__raw_off() {
  if [ "$UI_RAW" = 1 ]; then
    if [ -n "$UI_STTY" ]; then
      stty "$UI_STTY" </dev/tty 2>/dev/null || true
    else
      stty echo icanon </dev/tty 2>/dev/null || true
    fi
    UI_RAW=0
  fi
}

ui__cursor_hide() {
  if [ "$UI_MODE" = rich ]; then
    printf '%s' "${UI_ESC}[?25l"
    UI_CURSOR_HIDDEN=1
  fi
}

ui__cursor_show() {
  if [ "$UI_CURSOR_HIDDEN" = 1 ]; then
    printf '%s' "${UI_ESC}[?25h"
    UI_CURSOR_HIDDEN=0
  fi
}

# ui_cleanup: gives the terminal back (settings, cursor, a waiting step line). Safe to call twice,
# and before ui_init; core.sh calls it from the EXIT trap.
ui_cleanup() {
  if [ "${UI_RAW:-0}" = 1 ]; then
    ui__raw_off
  fi
  if [ "${UI_CURSOR_HIDDEN:-0}" = 1 ]; then
    ui__cursor_show
  fi
}

# ui__getc1: one byte from the terminal in UI_C; empty when none came within 0.1 s.
ui__getc1() {
  UI_C=$(dd bs=1 count=1 2>/dev/null </dev/tty) || UI_C=
}

# ui__key: waits for a key. UI_KEY is up, down, space, enter, esc or other. An arrow key is ESC [ A or
# ESC [ B (ESC O A for some terminals), read as three single-byte reads; a lone ESC is told apart by
# the read that gives up after 0.1 s.
ui__key() {
  UI_C=
  while [ -z "$UI_C" ]; do
    ui__getc1
  done
  case $UI_C in
    "$UI_ESC")
      ui__getc1
      ui_k2=$UI_C
      UI_KEY=esc
      if [ "$ui_k2" = '[' ] || [ "$ui_k2" = O ]; then
        ui__getc1
        case $UI_C in
          A) UI_KEY=up ;;
          B) UI_KEY=down ;;
          *) UI_KEY=other ;;
        esac
      fi
      ;;
    "$UI_CR") UI_KEY=enter ;;
    ' ') UI_KEY=space ;;
    k | K) UI_KEY=up ;;
    j | J) UI_KEY=down ;;
    *) UI_KEY=other ;;
  esac
}

# ui__no_input WHAT: the call needs an answer and there is nobody to ask.
ui__no_input() {
  sk_fail 2 SK-E001 "A question needs an answer, but there is no terminal to ask it on: $1" "Run the installer in a terminal, or pass --yes to accept the defaults."
}

# ---- output ----

ui_title() {
  if [ "$UI_MODE" = json ]; then
    ui__ev title text "$1"
    return 0
  fi
  ui__flush
  ui__fit "$1" "$UI_COLS"
  ui__repeat "$UI_S_HR" "${#UI_FIT}"
  printf '\n%s\n%s\n' "${UI_B}${UI_FIT}${UI_Z}" "${UI_D}${UI_R}${UI_Z}"
  ui__reshow
}

ui_say() {
  if [ "$UI_MODE" = json ]; then
    ui__ev say text "$1"
    return 0
  fi
  ui__flush
  printf '%s\n' "$1"
  ui__reshow
}

ui_note() {
  if [ "$UI_MODE" = json ]; then
    ui__ev note text "$1"
    return 0
  fi
  ui__flush
  ui_nl=1
  ui__wrap $((UI_COLS - 4)) "$1" | while IFS= read -r ui_nline; do
    if [ "$ui_nl" = 1 ]; then
      printf '%s\n' "  ${UI_D}${UI_S_NOTE}${UI_Z} ${ui_nline}"
      ui_nl=0
    else
      printf '%s\n' "    ${ui_nline}"
    fi
  done
  ui__reshow
}

# ui_warn TEXT: a warning on stderr (a JSON event on stdout in json mode).
ui_warn() {
  if [ "$UI_MODE" = json ]; then
    ui__ev warn text "$1"
    return 0
  fi
  ui__flush
  printf '%s\n' "${UI_YELLOW}WARNING:${UI_Z} $1" >&2
  ui__reshow
}

# ui_error TEXT: an error on stderr; "ERROR: " stays the first word in every mode.
ui_error() {
  if [ "$UI_MODE" = json ]; then
    ui__ev error message "$1"
    return 0
  fi
  ui__flush
  UI_STEP_OPEN=0
  printf '%s\n' "${UI_RED}ERROR:${UI_Z} $1" >&2
}

# ---- steps ----

ui_step_start() {
  if [ "$UI_STEP_OPEN" = 1 ]; then
    ui_step_warn "no result was reported"
  fi
  UI_STEP_ID=$1
  UI_STEP_TEXT=$2
  if [ "$UI_MODE" = json ]; then
    UI_STEP_OPEN=1
    ui__ev step_start id "$1" text "$2"
  elif [ "$UI_MODE" = rich ]; then
    UI_STEP_OPEN=1
    ui__step_line "${UI_CYAN}${UI_S_RUN}${UI_Z}" "$2"
  else
    UI_STEP_OPEN=1
    printf '%s\n' "$UI_S_RUN $2"
  fi
}

# ui__step_end KIND COLOUR SYMBOL DETAIL: the result line of the open step.
ui__step_end() {
  ui_sk=$1
  ui_sc=$2
  ui_ss=$3
  ui_sd=${4-}
  if [ "$UI_MODE" = json ]; then
    ui__ev "step_$ui_sk" id "$UI_STEP_ID" text "$UI_STEP_TEXT" detail "$ui_sd"
  else
    if [ "$UI_MODE" = rich ] && [ "$UI_STEP_OPEN" = 1 ]; then
      printf '%s' "${UI_CR}${UI_ESC}[K"
    fi
    ui__fit "$UI_STEP_TEXT" $((UI_COLS - 12))
    ui_sl="${UI_IND}${ui_sc}${ui_ss}${UI_Z} $UI_FIT"
    case $ui_sk in
      ok)
        if [ -n "$ui_sd" ]; then
          ui_sl="$ui_sl  ${UI_D}${ui_sd}${UI_Z}"
        fi
        printf '%s\n' "$ui_sl"
        ;;
      *)
        printf '%s\n' "$ui_sl"
        if [ -n "$ui_sd" ]; then
          printf '%s\n' "$ui_sd" | while IFS= read -r ui_dl; do
            printf '%s\n' "${UI_IND}    $ui_dl"
          done
        fi
        ;;
    esac
  fi
  UI_STEP_OPEN=0
}

ui_step_ok() {
  ui__step_end ok "$UI_GREEN" "$UI_S_OK" "${1-}"
}

ui_step_fail() {
  ui__step_end fail "$UI_RED" "$UI_S_FAIL" "${1-}"
}

ui_step_warn() {
  ui__step_end warn "$UI_YELLOW" "$UI_S_WARN" "${1-}"
}

# ui_progress DONE TOTAL LABEL
ui_progress() {
  ui_pd=$1
  ui_pt=$2
  ui_pl=${3-}
  if [ "$ui_pt" -le 0 ]; then
    ui_pp=100
  else
    ui_pp=$((ui_pd * 100 / ui_pt))
  fi
  if [ "$UI_MODE" = json ]; then
    ui__ev progress '#done' "$ui_pd" '#total' "$ui_pt" label "$ui_pl"
    return 0
  fi
  if [ "$UI_MODE" = rich ]; then
    ui_pw=$((UI_COLS - 14 - ${#ui_pl}))
    if [ "$ui_pw" -gt 40 ]; then
      ui_pw=40
    fi
    if [ "$ui_pw" -lt 10 ]; then
      ui_pw=10
      ui__fit "$ui_pl" $((UI_COLS - 24))
      ui_pl=$UI_FIT
    fi
    ui_pf=$((ui_pp * ui_pw / 100))
    ui__repeat "$UI_S_FULL" "$ui_pf"
    ui_pbar=$UI_R
    ui__repeat "$UI_S_EMPTY" $((ui_pw - ui_pf))
    printf '%s' "${UI_CR}${UI_ESC}[K  ${UI_CYAN}${ui_pbar}${UI_Z}${UI_D}${UI_R}${UI_Z} ${ui_pp}% ${ui_pl}"
    if [ "$ui_pd" -ge "$ui_pt" ]; then
      printf '\n'
    fi
    return 0
  fi
  # plain: a line when the tenth changes, and at the end
  ui_pb=$((ui_pp / 10))
  if [ "$ui_pb" != "$UI_PROG" ] || [ "$ui_pd" -ge "$ui_pt" ]; then
    printf '%s\n' "$ui_pl: $ui_pd/$ui_pt (${ui_pp}%)"
    UI_PROG=$ui_pb
  fi
  if [ "$ui_pd" -ge "$ui_pt" ]; then
    UI_PROG=-1
  fi
}

# ---- boxes, tables, copy lines ----

# ui_warn_box LINES...: a framed warning (each argument is a paragraph).
ui_warn_box() {
  if [ "$UI_MODE" = json ]; then
    ui_bj=
    for ui_bl in "$@"; do
      ui__jq "$ui_bl"
      ui_bj="${ui_bj:+$ui_bj,}$UI_J"
    done
    ui__ev warn_box "@lines" "[$ui_bj]"
    return 0
  fi
  ui__flush
  if [ "$UI_MODE" = rich ]; then
    ui__repeat "$UI_S_HR" $((UI_COLS - 14))
    printf '%s\n' "${UI_YELLOW}${UI_S_TOP}${UI_S_HR} ${UI_S_WARN} Warning ${UI_R}${UI_Z}"
    ui_bm="${UI_YELLOW}${UI_S_BAR}${UI_Z}"
  else
    printf '%s\n' '!! WARNING'
    ui_bm='!!'
  fi
  for ui_bl in "$@"; do
    ui__wrap $((UI_COLS - 8)) "$ui_bl" | while IFS= read -r ui_bw; do
      printf '%s\n' "$ui_bm ${UI_B}${ui_bw}${UI_Z}"
    done
  done
  if [ "$UI_MODE" = rich ]; then
    ui__repeat "$UI_S_HR" $((UI_COLS - 3))
    printf '%s\n' "${UI_YELLOW}${UI_S_BOT}${UI_S_HR}${UI_R}${UI_Z}"
  fi
  ui__reshow
}


# ui__cell ROW N: UI_CELL is the Nth "|"-separated cell of ROW.
ui__cell() {
  ui_cr=$1
  ui_cn=$2
  while [ "$ui_cn" -gt 1 ]; do
    case $ui_cr in
      *'|'*) ui_cr=${ui_cr#*|} ;;
      *) ui_cr= ;;
    esac
    ui_cn=$((ui_cn - 1))
  done
  UI_CELL=${ui_cr%%|*}
}

# ui__colw N WIDTH...: UI_CW is the Nth of the widths.
ui__colw() {
  ui_cwn=$1
  shift
  shift $((ui_cwn - 1))
  UI_CW=$1
}

# ui_table ROW...: rows of cells separated by "|"; the first row is the header. The cells line up in
# columns; the last column is cut to the width of the screen.
ui_table() {
  if [ "$UI_MODE" = json ]; then
    ui_tj=
    for ui_tr in "$@"; do
      ui_tc=
      while :; do
        ui__jq "${ui_tr%%|*}"
        ui_tc="${ui_tc:+$ui_tc,}$UI_J"
        case $ui_tr in
          *'|'*) ui_tr=${ui_tr#*|} ;;
          *) break ;;
        esac
      done
      ui_tj="${ui_tj:+$ui_tj,}[$ui_tc]"
    done
    ui__ev table "@rows" "[$ui_tj]"
    return 0
  fi
  ui__flush
  ui_tcols=1
  for ui_tr in "$@"; do
    ui_tm=1
    ui_tx=$ui_tr
    while :; do
      case $ui_tx in
        *'|'*)
          ui_tx=${ui_tx#*|}
          ui_tm=$((ui_tm + 1))
          ;;
        *) break ;;
      esac
    done
    if [ "$ui_tm" -gt "$ui_tcols" ]; then
      ui_tcols=$ui_tm
    fi
  done
  ui_tw=
  ui_tn=1
  while [ "$ui_tn" -le "$ui_tcols" ]; do
    ui_tmax=0
    for ui_tr in "$@"; do
      ui__cell "$ui_tr" "$ui_tn"
      if [ "${#UI_CELL}" -gt "$ui_tmax" ]; then
        ui_tmax=${#UI_CELL}
      fi
    done
    ui_tw="$ui_tw $ui_tmax"
    ui_tn=$((ui_tn + 1))
  done
  ui_trow=0
  for ui_tr in "$@"; do
    ui_tline=
    ui_tn=1
    ui_tused=2
    while [ "$ui_tn" -le "$ui_tcols" ]; do
      ui__cell "$ui_tr" "$ui_tn"
      ui_tcell=$UI_CELL
      ui__colw "$ui_tn" $ui_tw
      ui_tcw=$UI_CW
      if [ "$ui_tn" -eq "$ui_tcols" ]; then
        ui__fit "$ui_tcell" $((UI_COLS - 1 - ui_tused))
        ui_tline="$ui_tline$UI_FIT"
      else
        ui__repeat ' ' $((ui_tcw - ${#ui_tcell} + 2))
        ui_tline="$ui_tline$ui_tcell$UI_R"
        ui_tused=$((ui_tused + ui_tcw + 2))
      fi
      ui_tn=$((ui_tn + 1))
    done
    if [ "$ui_trow" = 0 ]; then
      printf '%s\n' "  ${UI_B}${ui_tline}${UI_Z}"
      ui_trule=${#ui_tline}
      if [ "$ui_trule" -gt $((UI_COLS - 3)) ]; then
        ui_trule=$((UI_COLS - 3))
      fi
      ui__repeat "$UI_S_HR" "$ui_trule"
      printf '%s\n' "  ${UI_D}${UI_R}${UI_Z}"
    else
      printf '%s\n' "  $ui_tline"
    fi
    ui_trow=$((ui_trow + 1))
  done
  ui__reshow
}

# ui_copy_hint TEXT [LABEL]: a value the person has to copy somewhere else (a DNS record, a command),
# after an optional "LABEL, to copy:". The text stands alone on its line, indented, never wrapped or
# decorated, so selecting the line gets exactly the text.
ui_copy_hint() {
  if [ "$UI_MODE" = json ]; then
    ui__ev copy label "${2-}" text "$1"
    return 0
  fi
  ui__flush
  if [ -n "${2-}" ]; then
    printf '%s\n' "  ${UI_D}${2}, to copy:${UI_Z}"
  fi
  printf '%s\n' "    ${UI_B}${1}${UI_Z}"
  ui__reshow
}

# ui_heading TEXT: a bold line, indented by two, with a blank line before it. It groups the lines that follow, one
# level below ui_title (no rule under it, so the two are not confused).
ui_heading() {
  if [ "$UI_MODE" = json ]; then
    ui__ev heading text "$1"
    return 0
  fi
  ui__flush
  ui__fit "$1" $((UI_COLS - 2))
  printf '\n%s\n' "  ${UI_B}${UI_FIT}${UI_Z}"
  ui__reshow
}

# ui_copy_block LINE...: a snippet the person copies as one piece (code of several lines). Each argument is one
# line, indented by four, never wrapped or decorated, so selecting the block gets exactly the text.
ui_copy_block() {
  if [ "$UI_MODE" = json ]; then
    ui_cbj=
    for ui_cbl in "$@"; do
      ui__jq "$ui_cbl"
      ui_cbj="${ui_cbj:+$ui_cbj,}$UI_J"
    done
    ui__ev copy_block "@lines" "[$ui_cbj]"
    return 0
  fi
  ui__flush
  for ui_cbl in "$@"; do
    printf '%s\n' "    $ui_cbl"
  done
  ui__reshow
}

# ---- questions ----

# ui__answered PROMPT VALUE: reports a default taken because of --yes (no terminal needed).
ui__answered() {
  if [ "$UI_MODE" = json ]; then
    ui__ev answer prompt "$1" value "$2" source default
  else
    ui__flush
    printf '%s\n' "$1: $2 (default, --yes)"
    ui__reshow
  fi
}

# ui__menu_draw MULTI CUR SEL REDRAW ITEM...: the menu on screen (rich). The prompt is in ui_mprompt.
ui__menu_draw() {
  ui_dm=$1
  ui_dc=$2
  ui_ds=$3
  ui_dr=$4
  shift 4
  ui_dn=$#
  if [ "$ui_dr" = 1 ]; then
    printf '%s' "${UI_ESC}[$((ui_dn + 2))A${UI_CR}${UI_ESC}[J"
  fi
  ui__fit "$ui_mprompt" $((UI_COLS - 3))
  printf '%s\n' "${UI_B}${UI_CYAN}?${UI_Z} ${UI_B}${UI_FIT}${UI_Z}"
  ui_di=1
  for ui_dit in "$@"; do
    ui__parse_item "$ui_dit"
    if [ "$ui_di" = "$ui_dc" ]; then
      ui_dptr="${UI_CYAN}${UI_S_PTR}${UI_Z} "
    else
      ui_dptr='  '
    fi
    ui_dbox=
    ui_dbl=0
    if [ "$ui_dm" = 1 ]; then
      case $ui_ds in
        *" $ui_di "*) ui_dbox='[x] ' ;;
        *) ui_dbox='[ ] ' ;;
      esac
      ui_dbl=4
    fi
    ui_davail=$((UI_COLS - 1 - 4 - ui_dbl))
    ui__fit "$UI_L" "$ui_davail"
    ui_dlab=$UI_FIT
    ui_dhint=
    ui_droom=$((ui_davail - ${#ui_dlab} - 2))
    if [ -n "$UI_H" ] && [ "$ui_droom" -ge 8 ]; then
      ui__fit "$UI_H" "$ui_droom"
      ui_dhint="  ${UI_D}${UI_FIT}${UI_Z}"
    fi
    if [ "$ui_di" = "$ui_dc" ]; then
      printf '%s\n' "  ${ui_dptr}${ui_dbox}${UI_B}${ui_dlab}${UI_Z}${ui_dhint}"
    else
      printf '%s\n' "  ${ui_dptr}${ui_dbox}${ui_dlab}${ui_dhint}"
    fi
    ui_di=$((ui_di + 1))
  done
  if [ "$ui_dm" = 1 ]; then
    printf '%s\n' "  ${UI_D}up/down move, space selects, enter confirms${UI_Z}"
  else
    printf '%s\n' "  ${UI_D}up/down move, enter selects${UI_Z}"
  fi
}

# ui__menu_rich MULTI PROMPT ITEM...
ui__menu_rich() {
  ui_mm=$1
  ui_mprompt=$2
  shift 2
  ui_mn=$#
  ui_mc=0
  ui_msel=' '
  ui_mi=1
  for ui_mit in "$@"; do
    ui__parse_item "$ui_mit"
    if [ "$UI_PRE" = 1 ]; then
      if [ "$ui_mm" = 1 ]; then
        ui_msel="$ui_msel$ui_mi "
      elif [ "$ui_mc" = 0 ]; then
        ui_mc=$ui_mi
      fi
    fi
    ui_mi=$((ui_mi + 1))
  done
  if [ "$ui_mc" = 0 ]; then
    ui_mc=1
  fi
  ui__raw_on
  ui__cursor_hide
  ui__menu_draw "$ui_mm" "$ui_mc" "$ui_msel" 0 "$@"
  while :; do
    ui__key
    case $UI_KEY in
      up) ui_mc=$((ui_mc - 1)) ;;
      down) ui_mc=$((ui_mc + 1)) ;;
      space)
        if [ "$ui_mm" = 1 ]; then
          case $ui_msel in
            *" $ui_mc "*)
              ui_mpre=${ui_msel%% $ui_mc *}
              ui_mpost=${ui_msel#* $ui_mc }
              ui_msel="$ui_mpre $ui_mpost"
              ;;
            *) ui_msel="$ui_msel$ui_mc " ;;
          esac
        fi
        ;;
      enter) break ;;
    esac
    if [ "$ui_mc" -lt 1 ]; then
      ui_mc=$ui_mn
    elif [ "$ui_mc" -gt "$ui_mn" ]; then
      ui_mc=1
    fi
    ui__menu_draw "$ui_mm" "$ui_mc" "$ui_msel" 1 "$@"
  done
  ui__raw_off
  ui__cursor_show
  # collapse the menu to one line holding the answer
  UI_REPLY=
  ui_mlabels=
  ui_mi=1
  for ui_mit in "$@"; do
    ui__parse_item "$ui_mit"
    ui_mtake=0
    if [ "$ui_mm" = 1 ]; then
      case $ui_msel in
        *" $ui_mi "*) ui_mtake=1 ;;
      esac
    elif [ "$ui_mi" = "$ui_mc" ]; then
      ui_mtake=1
    fi
    if [ "$ui_mtake" = 1 ]; then
      UI_REPLY="${UI_REPLY:+$UI_REPLY }$UI_V"
      ui_mlabels="${ui_mlabels:+$ui_mlabels, }$UI_L"
    fi
    ui_mi=$((ui_mi + 1))
  done
  printf '%s' "${UI_ESC}[$((ui_mn + 2))A${UI_CR}${UI_ESC}[J"
  if [ -z "$ui_mlabels" ]; then
    ui_mlabels='(none)'
  fi
  ui_mp=$ui_mprompt
  if [ $((${#ui_mp} + 4 + ${#ui_mlabels})) -gt $((UI_COLS - 2)) ]; then
    ui_mroom=$((UI_COLS - 6 - ${#ui_mp}))
    if [ "$ui_mroom" -lt 12 ]; then
      ui__fit "$ui_mp" $((UI_COLS / 2))
      ui_mp=$UI_FIT
      ui_mroom=$((UI_COLS - 6 - ${#ui_mp}))
    fi
    ui__fit "$ui_mlabels" "$ui_mroom"
  else
    UI_FIT=$ui_mlabels
  fi
  printf '%s\n' "${UI_B}${UI_CYAN}?${UI_Z} ${UI_B}${ui_mp}${UI_Z}  ${UI_GREEN}${UI_FIT}${UI_Z}"
}

# ui__menu_plain MULTI PROMPT ITEM...: numbered choices, the answer is a line from the terminal.
ui__menu_plain() {
  ui_pm=$1
  ui_pprompt=$2
  shift 2
  ui_pn=$#
  ui_pdef=
  ui_pi=1
  printf '%s\n' "$ui_pprompt"
  for ui_pit in "$@"; do
    ui__parse_item "$ui_pit"
    if [ "$ui_pm" = 1 ]; then
      if [ "$UI_PRE" = 1 ]; then
        ui_pdef="${ui_pdef:+$ui_pdef }$ui_pi"
      fi
    elif [ "$UI_PRE" = 1 ] && [ -z "$ui_pdef" ]; then
      ui_pdef=$ui_pi
    fi
    if [ -n "$UI_H" ]; then
      printf '%s\n' "  $ui_pi) $UI_L  ($UI_H)"
    else
      printf '%s\n' "  $ui_pi) $UI_L"
    fi
    ui_pi=$((ui_pi + 1))
  done
  if [ "$ui_pm" = 0 ] && [ -z "$ui_pdef" ]; then
    ui_pdef=1
  fi
  ui_ptries=0
  while :; do
    if [ "$ui_pm" = 1 ]; then
      printf '%s' "Numbers separated by spaces [${ui_pdef:-none}]: "
    else
      printf '%s' "Number [$ui_pdef]: "
    fi
    if IFS= read -r ui_pans </dev/tty; then
      :
    else
      ui__no_input "$ui_pprompt"
    fi
    if [ -z "$ui_pans" ]; then
      ui_pans=$ui_pdef
    fi
    if [ "$ui_pm" = 1 ] && [ "$ui_pans" = none ]; then
      ui_pans=
    fi
    ui_oifs=$IFS
    IFS=', '
    set -f
    ui_pok=1
    ui_pcount=0
    ui_ppicked=' '
    for ui_ptok in $ui_pans; do
      case $ui_ptok in
        '' | *[!0-9]*) ui_pok=0 ;;
        *)
          if [ "$ui_ptok" -lt 1 ] || [ "$ui_ptok" -gt "$ui_pn" ]; then
            ui_pok=0
          fi
          ui_pcount=$((ui_pcount + 1))
          ui_ppicked="$ui_ppicked$ui_ptok "
          ;;
      esac
    done
    set +f
    IFS=$ui_oifs
    if [ "$ui_pm" = 0 ] && [ "$ui_pcount" != 1 ]; then
      ui_pok=0
    fi
    if [ "$ui_pok" = 1 ]; then
      break
    fi
    ui_ptries=$((ui_ptries + 1))
    if [ "$ui_ptries" -ge 5 ]; then
      ui__no_input "$ui_pprompt"
    fi
    printf '%s\n' "Please enter a number from 1 to $ui_pn."
  done
  UI_REPLY=
  ui_pi=1
  for ui_pit in "$@"; do
    case $ui_ppicked in
      *" $ui_pi "*)
        ui__parse_item "$ui_pit"
        UI_REPLY="${UI_REPLY:+$UI_REPLY }$UI_V"
        ;;
    esac
    ui_pi=$((ui_pi + 1))
  done
}

# ui__menu_default MULTI ITEM...: UI_REPLY is what --yes takes (the "+" items, else the first of a single menu).
ui__menu_default() {
  ui_ym=$1
  shift
  UI_REPLY=
  ui_yfirst=
  for ui_yit in "$@"; do
    ui__parse_item "$ui_yit"
    if [ -z "$ui_yfirst" ]; then
      ui_yfirst=$UI_V
    fi
    if [ "$UI_PRE" = 1 ]; then
      if [ "$ui_ym" = 1 ]; then
        UI_REPLY="${UI_REPLY:+$UI_REPLY }$UI_V"
      elif [ -z "$UI_REPLY" ]; then
        UI_REPLY=$UI_V
      fi
    fi
  done
  if [ "$ui_ym" = 0 ] && [ -z "$UI_REPLY" ]; then
    UI_REPLY=$ui_yfirst
  fi
}

# ui__menu MULTI PROMPT ITEM...
ui__menu() {
  ui_xm=$1
  ui_xp=$2
  shift 2
  if [ $# -lt 1 ]; then
    sk_fail 1 SK-E010 "The installer asked for a choice without any choices: $ui_xp" "This is a bug in the installer; please report it."
  fi
  if [ "${SK_YES:-0}" = 1 ]; then
    ui__menu_default "$ui_xm" "$@"
    ui__answered "$ui_xp" "$UI_REPLY"
  elif [ "$UI_TTY" = 0 ]; then
    ui__no_input "$ui_xp"
  elif [ "$UI_MODE" = rich ]; then
    ui__flush
    ui__menu_rich "$ui_xm" "$ui_xp" "$@"
  else
    ui__menu_plain "$ui_xm" "$ui_xp" "$@"
  fi
}

# ui_menu_single PROMPT ITEM...: items are value|label|hint (hint optional); a leading + marks the
# one the cursor starts on. The value is left in UI_REPLY.
ui_menu_single() {
  ui__menu 0 "$@"
}

# ui_menu_multi PROMPT ITEM...: as above; a leading + preselects. UI_REPLY is the chosen values
# separated by spaces (empty when none).
ui_menu_multi() {
  ui__menu 1 "$@"
}

# ui_ask PROMPT [DEFAULT]: a line of text in UI_REPLY; Enter takes DEFAULT.
ui_ask() {
  ui_aprompt=$1
  ui_ahas=0
  ui_adef=
  if [ $# -ge 2 ]; then
    ui_ahas=1
    ui_adef=$2
  fi
  if [ "${SK_YES:-0}" = 1 ] && [ "$ui_ahas" = 1 ]; then
    UI_REPLY=$ui_adef
    ui__answered "$ui_aprompt" "$UI_REPLY"
    return 0
  fi
  if [ "$UI_TTY" = 0 ]; then
    ui__no_input "$ui_aprompt"
  fi
  ui__flush
  while :; do
    if [ "$ui_ahas" = 1 ] && [ -n "$ui_adef" ]; then
      printf '%s' "${UI_B}${UI_CYAN}?${UI_Z} ${UI_B}${ui_aprompt}${UI_Z} [${ui_adef}] "
    else
      printf '%s' "${UI_B}${UI_CYAN}?${UI_Z} ${UI_B}${ui_aprompt}${UI_Z} "
    fi
    if IFS= read -r UI_REPLY </dev/tty; then
      :
    else
      ui__no_input "$ui_aprompt"
    fi
    if [ -z "$UI_REPLY" ] && [ "$ui_ahas" = 1 ]; then
      UI_REPLY=$ui_adef
    fi
    if [ -n "$UI_REPLY" ] || [ "$ui_ahas" = 1 ]; then
      break
    fi
  done
}

# ui_ask_secret PROMPT: a line typed without echo, in UI_REPLY. Never answered by --yes.
ui_ask_secret() {
  if [ "$UI_TTY" = 0 ] || ! command -v stty >/dev/null 2>&1; then
    ui__no_input "$1"
  fi
  ui__flush
  printf '%s' "${UI_B}${UI_CYAN}?${UI_Z} ${UI_B}$1${UI_Z} (input is hidden) "
  ui__echo_off
  if IFS= read -r UI_REPLY </dev/tty; then
    ui_sec=0
  else
    ui_sec=1
  fi
  ui__raw_off
  printf '\n'
  if [ "$ui_sec" = 1 ]; then
    ui__no_input "$1"
  fi
}

# ui_confirm_yes PROMPT: succeeds only when the person typed yes (--yes counts as having typed it).
ui_confirm_yes() {
  if [ "${SK_YES:-0}" = 1 ]; then
    ui__answered "$1" yes
    return 0
  fi
  if [ "$UI_TTY" = 0 ]; then
    ui__no_input "$1"
  fi
  ui__flush
  printf '%s' "${UI_B}${UI_YELLOW}?${UI_Z} ${UI_B}$1${UI_Z} Type yes to continue: "
  if IFS= read -r ui_cy </dev/tty; then
    :
  else
    ui_cy=
  fi
  case $ui_cy in
    [Yy][Ee][Ss]) return 0 ;;
  esac
  printf '%s\n' "Not confirmed."
  return 1
}
# ---- core.sh: what every installer builds on (POSIX sh; defines functions only, runs nothing) ----
# The served script is: header, ui.sh, core.sh, common.sh, the recipe wrapped in sk_recipe(), and one last
# line that calls sk_boot. A download that stops half way therefore never gets as far as a command.
#
# Exit codes
#   0    done                  1    failed (die, sk_error)     2    needs input but has no terminal, or a bad option
#   3    stopped by the person (the plan was not confirmed; not an error)
#   4    not available for this installer yet
#   129  hang-up    130  interrupted (Ctrl-C)    143  terminated
#
# Errors have a code, SK-Exxx, and a help address <origin>/help/SK-Exxx:
#   SK-E001  a question needs an answer but there is no terminal and no --yes
#   SK-E002  a framework option is wrong (for example --home without a directory)
#   SK-E005  sudo is needed and not there
#   SK-E006  the ShakeKee home directory cannot be found or written
#   SK-E008  --status / --update / --uninstall are not available for this installer (it is not recorded as installed)
#   SK-E009  the integration is installed but has no step for --status / --update / --uninstall
#   SK-E010  a mistake in the installer itself
#   SK-E011  a file ShakeKee was asked to back up or edit is a symbolic link, not a regular file, or has an odd name
#   SK-E012  a file changed while it was being edited, or after our edit, so it was not written or restored
#   SK-E013  a backup, a working copy or the new file could not be written
# Recipes use SK-E1xx and up.

# sk_boot ARGS...: the entry point (the last line of the served script).
sk_boot() {
  # zsh runs this as a POSIX shell only under emulation; word splitting is needed here
  if [ -n "${ZSH_VERSION:-}" ]; then
    emulate sh
  fi
  SK_TASK=
  SK_DRY=0
  SK_FILE=
  SK_YES=0
  SK_HOME=
  SK_HOME_ARG=
  SK_LOG=
  SK_UID=
  SK_ACTION=install
  SK_INTEGRATION=${SK_SLUG:-}
  SK_EDITS=
  SK_BACKUP_DIR=
  SK_HOOK_RAN=0
  SK_HOOK_RC=0
  SK_LEFT=0
  SK_LEFT_LIST=
  SK_PLAN=
  SK_PHASE=
  SK_REC=
  SK_REC_LOADED=0
  UI_FORCE=
  sk_argerr=
  # Framework options are taken out here; the recipe gets the rest, in order.
  sk_n=$#
  sk_rest=0
  while [ "$sk_n" -gt 0 ]; do
    sk_a=$1
    shift
    sk_n=$((sk_n - 1))
    if [ "$sk_rest" = 1 ]; then
      set -- "$@" "$sk_a"
      continue
    fi
    case $sk_a in
      --yes) SK_YES=1 ;;
      --json) UI_FORCE=json ;;
      --plain)
        if [ "$UI_FORCE" != json ]; then
          UI_FORCE=plain
        fi
        ;;
      --dry-run) SK_DRY=1 ;;
      --status | --update | --uninstall | --doctor)
        if [ "$SK_ACTION" != install ] && [ "$SK_ACTION" != "${sk_a#--}" ]; then
          sk_argerr='only one of --status, --update, --uninstall and --doctor can be given'
        fi
        SK_ACTION=${sk_a#--}
        ;;
      --home)
        if [ "$sk_n" -lt 1 ]; then
          sk_argerr='--home needs a directory'
        else
          SK_HOME_ARG=$1
          shift
          sk_n=$((sk_n - 1))
        fi
        ;;
      --home=*) SK_HOME_ARG=${sk_a#--home=} ;;
      --)
        sk_rest=1
        set -- "$@" "$sk_a"
        ;;
      *) set -- "$@" "$sk_a" ;;
    esac
  done
  ui_init
  trap sk_on_exit EXIT
  trap 'exit 129' HUP
  trap 'exit 130' INT
  trap 'exit 143' TERM
  if [ -n "$sk_argerr" ]; then
    sk_fail 2 SK-E002 "$sk_argerr" "Example: --home /srv/shakekee"
  fi
  case $SK_ACTION in
    install) sk_recipe "$@" ;;
    *) "sk_action_$SK_ACTION" "$@" ;;
  esac
}

# The EXIT trap: gives the terminal back and removes the temporary directory, whatever ended the script.
sk_on_exit() {
  sk_rc=$?
  ui_cleanup
  sk_edit_cleanup
  if [ "$sk_rc" = 130 ] && [ "$UI_MODE" != json ]; then
    printf '\nInterrupted.\n' >&2
  fi
  sk_cleanup
  exit "$sk_rc"
}

# sk_usage_common: the options the framework takes, for a recipe's --help text.
sk_usage_common() {
  printf '%s\n' \
    'Options of every ShakeKee installer:' \
    '  --yes         take the default for every question (needed where there is no terminal)' \
    '  --dry-run     show what would be done, change nothing' \
    '  --plain       no colour, no cursor movement' \
    '  --json        one JSON object per line instead of text; never asks' \
    '  --home DIR    where ShakeKee keeps its files (default ~/.shakekee, /var/lib/shakekeed as root)' \
    '  --status      show what was installed, and check that it works' \
    '  --update      bring what was installed up to date' \
    '  --doctor      check this machine and the installation, with a fix for every problem' \
    '  --uninstall   remove what was installed (changed files are restored from their backups)'
}

# ---- errors ----

# sk__report EXIT CODE MESSAGE [FIX]: prints an error with its code and a help address, and logs it.
sk__report() {
  sk_fx=$1
  sk_fc=$2
  sk_fm=$3
  sk_ff=${4-}
  case $sk_fc in
    SK-E[0-9][0-9][0-9]) ;;
    *) sk_fc=SK-E000 ;;
  esac
  sk_log "ERROR $sk_fc: $sk_fm"
  if [ "$UI_MODE" = json ]; then
    ui__ev error code "$sk_fc" message "$sk_fm" fix "$sk_ff" help "${SK_ORIGIN:-}/help/$sk_fc" '#exit' "$sk_fx"
  else
    ui_error "$sk_fm"
    if [ -n "$sk_ff" ]; then
      sk_fh='  Fix: '
      ui__wrap $((UI_COLS - 8)) "$sk_ff" | while IFS= read -r sk_fl; do
        printf '%s\n' "$sk_fh $sk_fl" >&2
        sk_fh='       '
      done
    fi
    printf '%s\n' "  Help: ${SK_ORIGIN:-}/help/$sk_fc" >&2
  fi
}

# sk_fail EXIT CODE MESSAGE [FIX]: reports an error with its code and a help address, then exits.
sk_fail() {
  sk__report "$@"
  exit "$1"
}

# sk_error CODE MESSAGE [FIX]: sk_fail with exit code 1.
sk_error() {
  sk_fail 1 "$@"
}

# ---- who and where ----

sk_is_root() {
  if [ -z "$SK_UID" ]; then
    SK_UID=$(id -u 2>/dev/null) || SK_UID=
  fi
  [ "$SK_UID" = 0 ]
}

# sk_mode: prints system when running as root, otherwise user.
sk_mode() {
  if sk_is_root; then
    printf '%s\n' system
  else
    printf '%s\n' user
  fi
}

# sk_home: sets SK_HOME to the directory ShakeKee keeps its files in: --home, else SHAKEKEE_HOME, else
# ~/.shakekee (user mode) or /var/lib/shakekeed (system mode). Nothing is created.
sk_home() {
  if [ -n "$SK_HOME" ]; then
    return 0
  fi
  sk_hv=${SK_HOME_ARG:-${SHAKEKEE_HOME:-}}
  if [ -z "$sk_hv" ]; then
    if sk_is_root; then
      sk_hv=/var/lib/shakekeed
    elif [ -n "${HOME:-}" ]; then
      sk_hv=$HOME/.shakekee
    else
      sk_fail 1 SK-E006 "Cannot tell where to keep ShakeKee's files: HOME is not set." "Pass --home DIR or set SHAKEKEE_HOME."
    fi
  fi
  case $sk_hv in
    /*) ;;
    *) sk_hv=$PWD/$sk_hv ;;
  esac
  SK_HOME=$sk_hv
}

# sk_ensure_dir SUBDIR: creates SK_HOME/SUBDIR (mode 0700 for what it creates); nothing with --dry-run.
sk_ensure_dir() {
  sk_home
  if [ "$SK_DRY" = 1 ]; then
    return 0
  fi
  if ! (umask 077 && mkdir -p "$SK_HOME/$1") 2>/dev/null; then
    sk_fail 1 SK-E006 "Cannot create $SK_HOME/$1." "Check the permissions, or use --home DIR for another place."
  fi
}

# ---- log ----

# sk_log_open: opens <home>/logs/install-<UTC>.log (once). With --dry-run nothing is written.
sk_log_open() {
  if [ -n "$SK_LOG" ]; then
    return 0
  fi
  if [ "$SK_DRY" = 1 ]; then
    SK_LOG=/dev/null
    return 0
  fi
  sk_ensure_dir logs
  sk_lt=$(date -u +%Y%m%dT%H%M%SZ 2>/dev/null) || sk_lt=unknown-time
  sk_lf=$SK_HOME/logs/install-$sk_lt.log
  if ! (umask 077 && : >>"$sk_lf") 2>/dev/null; then
    sk_fail 1 SK-E006 "Cannot write the log file $sk_lf." "Check the permissions, or use --home DIR for another place."
  fi
  SK_LOG=$sk_lf
  printf '%s\n' "# ShakeKee installer ${SK_SLUG:-}, $sk_lt, origin ${SK_ORIGIN:-}" >>"$SK_LOG"
}

# sk_log TEXT: a line in the log, if the log is open.
sk_log() {
  if [ -n "$SK_LOG" ] && [ "$SK_LOG" != /dev/null ]; then
    printf '%s\n' "$*" >>"$SK_LOG" 2>/dev/null || true
  fi
}

# ---- running things ----

# sk_quote_args WORD...: SK_QA is the words as a shell would need them written.
sk_quote_args() {
  SK_QA=
  for sk_qw in "$@"; do
    case $sk_qw in
      '' | *[!A-Za-z0-9_@%+=:,./-]*)
        sk_qs=$sk_qw
        sk_qo=
        while :; do
          case $sk_qs in
            *\'*)
              sk_qo="$sk_qo${sk_qs%%\'*}'\\''"
              sk_qs=${sk_qs#*\'}
              ;;
            *)
              sk_qo="$sk_qo$sk_qs"
              break
              ;;
          esac
        done
        sk_qw="'$sk_qo'"
        ;;
    esac
    SK_QA="${SK_QA:+$SK_QA }$sk_qw"
  done
}

# sk_sudo COMMAND...: shows the command, then runs it as root (directly when already root).
# With --dry-run it only says what it would do.
sk_sudo() {
  sk_quote_args "$@"
  if [ "$SK_DRY" = 1 ]; then
    ui_say "[dry-run] sudo $SK_QA"
    return 0
  fi
  if sk_is_root; then
    ui_say "  \$ $SK_QA"
    sk_log "\$ $SK_QA"
    "$@"
    return $?
  fi
  if ! command -v sudo >/dev/null 2>&1; then
    sk_fail 1 SK-E005 "This step needs administrator rights and sudo is not installed: $SK_QA" "Run the installer as root, or install sudo."
  fi
  ui_say "  \$ sudo $SK_QA"
  sk_log "\$ sudo $SK_QA"
  command sudo "$@"
}

# sk_step_run ID TEXT COMMAND...: one step with a result line; the full output of the command goes to
# the log. Returns the command's status after reporting the failure. With --dry-run it only says what
# it would do.
sk_step_run() {
  sk_sid=$1
  sk_stx=$2
  shift 2
  if [ "$SK_DRY" = 1 ]; then
    ui_say "[dry-run] $sk_stx"
    return 0
  fi
  sk_log_open
  sk_quote_args "$@"
  sk_log "[$sk_sid] \$ $SK_QA"
  sk_soff=$(wc -c <"$SK_LOG" 2>/dev/null) || sk_soff=0
  ui_step_start "$sk_sid" "$sk_stx"
  if "$@" </dev/null >>"$SK_LOG" 2>&1; then
    ui_step_ok
    return 0
  else
    sk_src=$?
  fi
  # the last lines this command wrote
  set -- $sk_soff
  sk_stail=$(tail -c +$(($1 + 1)) "$SK_LOG" 2>/dev/null | tail -n 6) || sk_stail=
  ui_step_fail "exit status $sk_src${sk_stail:+$UI_NL$sk_stail}${UI_NL}the full output is in $SK_LOG"
  return "$sk_src"
}

# ---- what was done: install.json ----
# <home>/install.json is a JSON object with one key per line: `  "key": "string",`. Keys are
# [A-Za-z0-9_.-]; values are strings. sk_record replaces the value of a key in place and appends a new
# key at the end, so the order of the file is the order things were first recorded. Plain sh reads it back
# line by line; there is no jq. The keys ShakeKee itself writes (docs/installer-lifecycle.md):
#   integration.SLUG   version of an integration that has lifecycle steps     installed.SLUG  UTC time
#   backup.NNNN        ORIGINAL|COPY|SHA256-AFTER-OUR-EDIT|SCOPE               (a file we changed)
#   created.NNNN       FILE|SHA256-WE-WROTE ("-" for a directory)|SCOPE        (a file or directory we made)
#   remains.NNNN       SCOPE|TEXT                                              (something only the person can remove)
# Other keys are facts for --status (recipes choose them). SCOPE is the integration that did it.

# sk_own_only PATH: true when PATH is the caller's own and nobody else may write it (no group or other
# write bit). What the installer acts on later (paths it runs, files it puts back) must pass this: a
# file another user can write is a way to make root do things.
sk_own_only() {
  # -O: owned by the effective user of this shell (dash, bash and busybox have it); not `id`, which a test may fake
  [ -O "$1" ] || return 1
  sk_oo=$(ls -ld "$1" 2>/dev/null) || return 1
  case $(printf '%s\n' "$sk_oo" | cut -c1-10) in
    ?????w* | ????????w*) return 1 ;;
  esac
  return 0
}

# sk_rec_load: reads install.json into SK_REC once (lines "key<TAB>json value"). The record and the home
# it is in must be the caller's own (sk_own_only): --doctor runs the daemon it names and --uninstall puts
# back the files it names.
sk_rec_load() {
  if [ "$SK_REC_LOADED" = 1 ]; then
    return 0
  fi
  SK_REC_LOADED=1
  sk_home
  if [ -f "$SK_HOME/install.json" ]; then
    if ! sk_own_only "$SK_HOME" || ! sk_own_only "$SK_HOME/install.json"; then
      sk_fail 1 SK-E014 "$SK_HOME/install.json or $SK_HOME is not owned by you alone (another user could change what is recorded there)." "As the owner of the installation: chown $(id -un 2>/dev/null || echo "$SK_UID") $SK_HOME $SK_HOME/install.json; chmod go-w $SK_HOME $SK_HOME/install.json. If the daemon's state is in $SK_HOME itself, move it to $SK_HOME/state first (see SK-E711)."
    fi
    while IFS= read -r sk_rl; do
      case $sk_rl in
        '  "'*)
          sk_rk=${sk_rl#  \"}
          sk_rk=${sk_rk%%\"*}
          sk_rv=${sk_rl#*\": }
          sk_rv=${sk_rv%,}
          SK_REC="${SK_REC:+$SK_REC$UI_NL}$sk_rk$UI_TAB$sk_rv"
          ;;
      esac
    done <"$SK_HOME/install.json"
  fi
}

# sk__unjq JSON-STRING: SK_RV is the text of a JSON string literal.
sk__unjq() {
  sk_uj=$1
  case $sk_uj in
    \"*\")
      sk_uj=${sk_uj#\"}
      sk_uj=${sk_uj%\"}
      ;;
    *)
      SK_RV=$sk_uj
      return 0
      ;;
  esac
  SK_RV=
  while :; do
    case $sk_uj in
      *\\*)
        SK_RV=$SK_RV${sk_uj%%\\*}
        sk_uj=${sk_uj#*\\}
        sk_uc=${sk_uj%"${sk_uj#?}"}
        sk_uj=${sk_uj#?}
        case $sk_uc in
          n) SK_RV=$SK_RV$UI_NL ;;
          t) SK_RV=$SK_RV$UI_TAB ;;
          r) SK_RV=$SK_RV$UI_CR ;;
          *) SK_RV=$SK_RV$sk_uc ;;
        esac
        ;;
      *)
        SK_RV=$SK_RV$sk_uj
        break
        ;;
    esac
  done
}

# sk_rec_get KEY: SK_RV is the value of KEY; fails when there is none. Does not need the file to exist.
sk_rec_get() {
  sk_rec_load
  SK_RV=
  if [ -n "$SK_REC" ]; then
    while IFS=$UI_TAB read -r sk_gk sk_gv; do
      if [ "$sk_gk" = "$1" ]; then
        sk__unjq "$sk_gv"
        return 0
      fi
    done <<REC
$SK_REC
REC
  fi
  return 1
}

# sk__rec_write: SK_REC to install.json (atomic).
sk__rec_write() {
  sk_home
  sk_rt=$SK_HOME/install.json.tmp.$$
  sk_ru=$(umask)
  umask 077
  {
    if [ -z "$SK_REC" ]; then
      printf '{}\n'
    else
      printf '{\n'
      sk_rfirst=1
      while IFS=$UI_TAB read -r sk_rk sk_rv; do
        if [ "$sk_rfirst" = 1 ]; then
          sk_rfirst=0
        else
          printf ',\n'
        fi
        printf '  "%s": %s' "$sk_rk" "$sk_rv"
      done <<REC
$SK_REC
REC
      printf '\n}\n'
    fi
  } >"$sk_rt" && mv -f "$sk_rt" "$SK_HOME/install.json" ||
    sk_fail 1 SK-E006 "Cannot write $SK_HOME/install.json." "Check the permissions."
  umask "$sk_ru"
}

# sk__rec_put KEY JSON-VALUE: replaces KEY in place, or appends it.
sk__rec_put() {
  sk_pk=$1
  sk_pv=$2
  sk_rnew=
  sk_rseen=0
  if [ -n "$SK_REC" ]; then
    while IFS=$UI_TAB read -r sk_rk sk_rv; do
      if [ "$sk_rk" = "$sk_pk" ]; then
        sk_rv=$sk_pv
        sk_rseen=1
      fi
      sk_rnew="${sk_rnew:+$sk_rnew$UI_NL}$sk_rk$UI_TAB$sk_rv"
    done <<REC
$SK_REC
REC
  fi
  if [ "$sk_rseen" = 0 ]; then
    sk_rnew="${sk_rnew:+$sk_rnew$UI_NL}$sk_pk$UI_TAB$sk_pv"
  fi
  SK_REC=$sk_rnew
  sk__rec_write
}

# sk_record KEY VALUE: remembers one fact in <home>/install.json for --status and --uninstall
# (a later value for the same key replaces the earlier one). Not written with --dry-run.
sk_record() {
  case $1 in
    '' | *[!A-Za-z0-9_.-]*) sk_fail 1 SK-E010 "A bad key was given to sk_record: $1" "This is a bug in the installer; please report it." ;;
  esac
  if [ "$SK_DRY" = 1 ]; then
    return 0
  fi
  sk_ensure_dir .
  sk_rec_load
  ui__jq "$2"
  sk__rec_put "$1" "$UI_J"
}

# sk_unrecord KEY: forgets a key (nothing happens when it is not there, or with --dry-run).
sk_unrecord() {
  if [ "$SK_DRY" = 1 ]; then
    return 0
  fi
  sk_rec_load
  if [ -z "$SK_REC" ]; then
    return 0
  fi
  sk_rnew=
  sk_rseen=0
  while IFS=$UI_TAB read -r sk_rk sk_rv; do
    if [ "$sk_rk" = "$1" ]; then
      sk_rseen=1
    else
      sk_rnew="${sk_rnew:+$sk_rnew$UI_NL}$sk_rk$UI_TAB$sk_rv"
    fi
  done <<REC
$SK_REC
REC
  if [ "$sk_rseen" = 1 ]; then
    SK_REC=$sk_rnew
    sk__rec_write
  fi
}

# sk__seq PREFIX: SK_SEQ is the next number (0001, 0002, ...) for keys named PREFIX.NNNN.
sk__seq() {
  sk_rec_load
  sk_sq=0
  if [ -n "$SK_REC" ]; then
    while IFS=$UI_TAB read -r sk_sk sk_sv; do
      case $sk_sk in
        "$1".*)
          sk_sn=${sk_sk#"$1".}
          case $sk_sn in
            '' | *[!0-9]*) continue ;;
          esac
          while :; do
            case $sk_sn in
              0?*) sk_sn=${sk_sn#0} ;;
              *) break ;;
            esac
          done
          if [ "$sk_sn" -gt "$sk_sq" ]; then
            sk_sq=$sk_sn
          fi
          ;;
      esac
    done <<REC
$SK_REC
REC
  fi
  sk_sq=$((sk_sq + 1))
  SK_SEQ=$(printf '%04d' "$sk_sq")
}

# sk__fields VALUE: SK_F1..SK_F4 are the fields of a value that has fields separated by bars.
sk__fields() {
  SK_F1=
  SK_F2=
  SK_F3=
  SK_F4=
  IFS='|' read -r SK_F1 SK_F2 SK_F3 SK_F4 <<FIELDS
$1
FIELDS
}

# sk__reverse LIST: SK_REVERSED is a list of lines the other way round.
sk__reverse() {
  SK_REVERSED=
  if [ -n "$1" ]; then
    while IFS= read -r sk_vl; do
      SK_REVERSED="$sk_vl${SK_REVERSED:+$UI_NL$SK_REVERSED}"
    done <<LIST
$1
LIST
  fi
}

# ---- backups and recorded changes: the base of safe editing and of --uninstall ----
# A file ShakeKee changes is first copied to <home>/backups/<UTC time>/<absolute path, / replaced by _> and
# the copy is recorded (backup.NNNN). A file it creates is recorded with the hash of what it wrote
# (created.NNNN). --uninstall (sk_restore_all) undoes the records newest first, and never touches a
# file that is not as ShakeKee left it. Paths must be absolute or are made absolute, and must not hold a bar
# or a control character. Symbolic links are refused: edit the file they point to. With --dry-run these
# calls say what they would do and change nothing, so a recipe wraps the edit itself in sk_do.

# sk__abs PATH: SK_ABS is PATH made absolute; refuses a name that cannot be recorded.
sk__abs() {
  case $1 in
    '') sk_fail 1 SK-E010 "A file name is missing in a call to the backup or edit functions." "This is a bug in the installer; please report it." ;;
    /*) SK_ABS=$1 ;;
    *) SK_ABS=$PWD/$1 ;;
  esac
  case $SK_ABS in
    *'|'* | *"$UI_NL"* | *"$UI_TAB"* | *"$UI_CR"*)
      sk_fail 1 SK-E011 "ShakeKee cannot keep a record of this file name (a bar or a control character): $SK_ABS" "Rename the file, or use another place."
      ;;
  esac
}

# sk__plain_file PATH: PATH may be there, but then it must be a regular file and not a symbolic link.
sk__plain_file() {
  if [ -L "$1" ]; then
    sk_fail 1 SK-E011 "Refusing to touch $1: it is a symbolic link." "Edit the file it points to instead (see: readlink -f $1), or replace the link by a real file."
  fi
  if [ -e "$1" ] && [ ! -f "$1" ]; then
    sk_fail 1 SK-E011 "Refusing to touch $1: it is not a regular file." "Pick the file itself."
  fi
}

# sk__dirname PATH: SK_DIR is the directory part of an absolute PATH.
sk__dirname() {
  SK_DIR=${1%/*}
  if [ -z "$SK_DIR" ]; then
    SK_DIR=/
  fi
}

# sk__flat PATH: SK_FLAT is PATH with every / replaced by _.
sk__flat() {
  SK_FLAT=
  sk_fr=$1
  while :; do
    case $sk_fr in
      */*)
        SK_FLAT=$SK_FLAT${sk_fr%%/*}_
        sk_fr=${sk_fr#*/}
        ;;
      *)
        SK_FLAT=$SK_FLAT$sk_fr
        break
        ;;
    esac
  done
}

# sk__backup_dir: SK_BACKUP_DIR is this run's backup directory (0700), made on first use.
sk__backup_dir() {
  if [ -n "$SK_BACKUP_DIR" ]; then
    return 0
  fi
  sk_home
  sk_bt=$(date -u +%Y%m%dT%H%M%SZ 2>/dev/null) || sk_bt=unknown-time
  if ! (umask 077 && mkdir -p "$SK_HOME/backups/$sk_bt") 2>/dev/null; then
    sk_fail 1 SK-E013 "Cannot create the backup directory $SK_HOME/backups/$sk_bt." "Check the permissions, or use --home DIR for another place. Nothing was changed."
  fi
  SK_BACKUP_DIR=$SK_HOME/backups/$sk_bt
}

# sk_backup FILE: copies FILE (mode, and owner when root, are kept) into the backup directory and records
# it. SK_BACKUP is the copy and SK_BACKUP_KEY its key in install.json.
sk_backup() {
  sk__abs "$1"
  sk_bf=$SK_ABS
  sk__plain_file "$sk_bf"
  if [ ! -e "$sk_bf" ]; then
    sk_fail 1 SK-E011 "Cannot back up $sk_bf: it does not exist." "Check the path."
  fi
  SK_BACKUP=
  SK_BACKUP_KEY=
  if [ "$SK_DRY" = 1 ]; then
    ui_say "[dry-run] back up $sk_bf"
    return 0
  fi
  sk__backup_dir
  sk__flat "$sk_bf"
  sk_bc=$SK_BACKUP_DIR/$SK_FLAT
  sk_bn=1
  while [ -e "$sk_bc" ] || [ -L "$sk_bc" ]; do
    sk_bn=$((sk_bn + 1))
    sk_bc=$SK_BACKUP_DIR/$SK_FLAT.$sk_bn
  done
  if ! cp -p "$sk_bf" "$sk_bc" 2>/dev/null || ! cmp -s "$sk_bf" "$sk_bc"; then
    rm -f "$sk_bc"
    sk_fail 1 SK-E013 "Cannot make a backup copy of $sk_bf in $SK_BACKUP_DIR." "Nothing was changed. Check the permissions and the free space."
  fi
  sk__seq backup
  SK_BACKUP_KEY=backup.$SK_SEQ
  sk_record "$SK_BACKUP_KEY" "$sk_bf|$sk_bc||${SK_INTEGRATION:-}"
  SK_BACKUP=$sk_bc
  sk_log "backup $sk_bf -> $sk_bc"
}

# sk__set_after KEY FILE: records the hash of FILE as the state our change left it in.
sk__set_after() {
  sk_rec_get "$1" || return 0
  sk__fields "$SK_RV"
  sk_sa1=$SK_F1
  sk_sa2=$SK_F2
  sk_sa4=$SK_F4
  sk_sa3=$(sk_sha256 "$2")
  sk_record "$1" "$sk_sa1|$sk_sa2|$sk_sa3|$sk_sa4"
}

# sk_changed FILE: for a recipe that backed FILE up with sk_backup and changed it by itself: records the
# state it left, so that --uninstall can tell whether the file was touched since.
sk_changed() {
  sk__abs "$1"
  sk_cf=$SK_ABS
  if [ "$SK_DRY" = 1 ]; then
    return 0
  fi
  sk_ck=
  sk_rec_load
  if [ -n "$SK_REC" ]; then
    while IFS=$UI_TAB read -r sk_ckk sk_ckv; do
      case $sk_ckk in
        backup.*)
          sk__unjq "$sk_ckv"
          sk__fields "$SK_RV"
          if [ "$SK_F1" = "$sk_cf" ] && [ -z "$SK_F3" ]; then
            sk_ck=$sk_ckk
          fi
          ;;
      esac
    done <<REC
$SK_REC
REC
  fi
  if [ -z "$sk_ck" ]; then
    sk_fail 1 SK-E010 "sk_changed $sk_cf without a matching sk_backup." "This is a bug in the installer; please report it."
  fi
  sk__set_after "$sk_ck" "$sk_cf"
}

# sk_created PATH: records a file (or directory) that ShakeKee made, with the hash of what it wrote.
sk_created() {
  sk__abs "$1"
  sk_xf=$SK_ABS
  if [ "$SK_DRY" = 1 ]; then
    return 0
  fi
  if [ -L "$sk_xf" ]; then
    sk_fail 1 SK-E011 "Refusing to record $sk_xf: it is a symbolic link." "Record the file it points to."
  elif [ -d "$sk_xf" ]; then
    sk_xh=-
  elif [ -f "$sk_xf" ]; then
    sk_xh=$(sk_sha256 "$sk_xf")
  else
    sk_fail 1 SK-E011 "Cannot record $sk_xf as created: it does not exist." "This is a bug in the installer; please report it."
  fi
  # the same file again: the record is updated where it is
  sk_xk=
  sk_rec_load
  if [ -n "$SK_REC" ]; then
    while IFS=$UI_TAB read -r sk_xkk sk_xkv; do
      case $sk_xkk in
        created.*)
          sk__unjq "$sk_xkv"
          sk__fields "$SK_RV"
          if [ "$SK_F1" = "$sk_xf" ]; then
            sk_xk=$sk_xkk
          fi
          ;;
      esac
    done <<REC
$SK_REC
REC
  fi
  if [ -z "$sk_xk" ]; then
    sk__seq created
    sk_xk=created.$SK_SEQ
  fi
  sk_record "$sk_xk" "$sk_xf|$sk_xh|${SK_INTEGRATION:-}"
}

# ---- editing a file in a way that can be undone ----
# sk_edit_begin FILE   backs FILE up and makes a working copy next to it (sk_edit_path FILE is its name);
#                      the recipe changes the working copy, never FILE
# sk_edit_commit FILE  writes the working copy over FILE: same mode and owner, one atomic rename. Refuses
#                      (SK-E012) when FILE changed since sk_edit_begin
# sk_edit_rollback FILE   before the commit: throws the working copy away. After it: puts the backup back,
#                      byte for byte (for example when `nginx -t` rejects the new configuration)
# FILE untouched until the commit; an interrupted run leaves FILE as it was (the working copy is removed
# when the script ends). A FILE that does not exist yet is created by the commit.

# SK_EDITS holds one line per file: FILE|WORKING-COPY|HASH-AT-BEGIN|MODE|EXISTED|STATE|BACKUP-KEY|HASH-AFTER
# (EXISTED is 1 or 0, STATE open or committed). It is parsed with bars, so the fields hold none.

# sk_edit_path FILE: prints the name of the working copy.
sk_edit_path() {
  sk__abs "$1"
  sk__dirname "$SK_ABS"
  printf '%s\n' "${SK_DIR%/}/.${SK_ABS##*/}.shakekee-edit"
}

# sk__edit_find FILE: SK_E_* are the fields of FILE's line in SK_EDITS; fails when there is none.
sk__edit_find() {
  if [ -n "$SK_EDITS" ]; then
    while IFS='|' read -r sk_ef1 sk_ef2 sk_ef3 sk_ef4 sk_ef5 sk_ef6 sk_ef7 sk_ef8; do
      if [ "$sk_ef1" = "$1" ]; then
        SK_E_TMP=$sk_ef2
        SK_E_HASH=$sk_ef3
        SK_E_MODE=$sk_ef4
        SK_E_EXISTED=$sk_ef5
        SK_E_STATE=$sk_ef6
        SK_E_BKEY=$sk_ef7
        SK_E_AFTER=$sk_ef8
        return 0
      fi
    done <<EDITS
$SK_EDITS
EDITS
  fi
  return 1
}

# sk__edit_put FILE TMP HASH MODE EXISTED STATE BKEY AFTER: sets the line of FILE.
sk__edit_put() {
  sk__edit_drop "$1"
  SK_EDITS="${SK_EDITS:+$SK_EDITS$UI_NL}$1|$2|$3|$4|$5|$6|$7|$8"
}

# sk__edit_drop FILE: forgets FILE's line.
sk__edit_drop() {
  sk_en=
  if [ -n "$SK_EDITS" ]; then
    while IFS= read -r sk_el; do
      case $sk_el in
        "$1|"*) ;;
        *) sk_en="${sk_en:+$sk_en$UI_NL}$sk_el" ;;
      esac
    done <<EDITS
$SK_EDITS
EDITS
  fi
  SK_EDITS=$sk_en
}

# sk__modesym FILE: SK_MODE is the permissions of FILE written the way chmod takes them (u=rw,g=r,o=r).
sk__modesym() {
  SK_MODE=
  sk_ms=$(ls -ld "$1" 2>/dev/null) || sk_ms=
  sk_ms=${sk_ms%% *}
  case $sk_ms in
    ??????????*) ;;
    *) return 0 ;;
  esac
  sk_ms=${sk_ms#?}
  sk_mu=${sk_ms%"${sk_ms#???}"}
  sk_ms=${sk_ms#???}
  sk_mg=${sk_ms%"${sk_ms#???}"}
  sk_ms=${sk_ms#???}
  sk_mo=${sk_ms%"${sk_ms#???}"}
  SK_MODE=
  sk_mx=
  for sk_mp in "u:$sk_mu" "g:$sk_mg" "o:$sk_mo"; do
    sk_mw=${sk_mp#?:}
    sk_mc=${sk_mp%%:*}
    sk_mv=
    case $sk_mw in r??) sk_mv=r ;; esac
    case $sk_mw in ?w?) sk_mv=${sk_mv}w ;; esac
    case $sk_mw in ??x | ??s | ??t) sk_mv=${sk_mv}x ;; esac
    SK_MODE="${SK_MODE:+$SK_MODE,}$sk_mc=$sk_mv"
    case $sk_mw in
      ??s | ??S)
        if [ "$sk_mc" != o ]; then
          sk_mx="$sk_mx,$sk_mc+s"
        fi
        ;;
      ??t | ??T) sk_mx="$sk_mx,+t" ;;
    esac
  done
  SK_MODE=$SK_MODE$sk_mx
}

# sk_edit_begin FILE
sk_edit_begin() {
  sk__abs "$1"
  sk_bf2=$SK_ABS
  sk__plain_file "$sk_bf2"
  if [ "$SK_DRY" = 1 ]; then
    ui_say "[dry-run] edit $sk_bf2"
    return 0
  fi
  sk__dirname "$sk_bf2"
  sk_bd=$SK_DIR
  sk_bw=${sk_bd%/}/.${sk_bf2##*/}.shakekee-edit
  if [ ! -d "$sk_bd" ] || [ ! -w "$sk_bd" ]; then
    sk_fail 1 SK-E013 "Cannot write in $sk_bd, where $sk_bf2 is." "Run the installer as the user that owns it (or with sudo). Nothing was changed."
  fi
  sk__edit_drop_tmp "$sk_bf2"
  rm -f "$sk_bw"
  if [ -e "$sk_bf2" ]; then
    sk_backup "$sk_bf2"
    sk_bk=$SK_BACKUP_KEY
    sk_bh=$(sk_sha256 "$sk_bf2")
    sk__modesym "$sk_bf2"
    sk_bm=$SK_MODE
    if ! cp -p "$sk_bf2" "$sk_bw" 2>/dev/null || ! chmod u+w "$sk_bw" 2>/dev/null; then
      rm -f "$sk_bw"
      sk_fail 1 SK-E013 "Cannot make a working copy of $sk_bf2 next to it." "Nothing was changed. Check the permissions."
    fi
    sk__edit_put "$sk_bf2" "$sk_bw" "$sk_bh" "$sk_bm" 1 open "$sk_bk" ""
  else
    if ! : >"$sk_bw" 2>/dev/null; then
      sk_fail 1 SK-E013 "Cannot create $sk_bf2." "Nothing was changed. Check the permissions."
    fi
    sk__edit_put "$sk_bf2" "$sk_bw" - "" 0 open "" ""
  fi
}

# sk__edit_drop_tmp FILE: removes the working copy of an earlier, unfinished edit of FILE.
sk__edit_drop_tmp() {
  if sk__edit_find "$1"; then
    if [ "$SK_E_STATE" = open ]; then
      rm -f "$SK_E_TMP"
    fi
    sk__edit_drop "$1"
  fi
}

# sk_edit_cleanup: removes the working copies of edits that were never committed (the EXIT trap).
sk_edit_cleanup() {
  if [ -n "${SK_EDITS:-}" ]; then
    while IFS='|' read -r sk_cf1 sk_cf2 sk_cf3 sk_cf4 sk_cf5 sk_cf6 sk_cf7 sk_cf8; do
      if [ "$sk_cf6" = open ]; then
        rm -f "$sk_cf2"
      fi
    done <<EDITS
$SK_EDITS
EDITS
  fi
  return 0
}

# sk_edit_commit FILE
sk_edit_commit() {
  sk__abs "$1"
  sk_cm=$SK_ABS
  if [ "$SK_DRY" = 1 ]; then
    return 0
  fi
  if ! sk__edit_find "$sk_cm" || [ "$SK_E_STATE" != open ]; then
    sk_fail 1 SK-E010 "sk_edit_commit $sk_cm without sk_edit_begin." "This is a bug in the installer; please report it."
  fi
  sk_ct=$SK_E_TMP
  sk_ch=$SK_E_HASH
  sk_cmode=$SK_E_MODE
  sk_cex=$SK_E_EXISTED
  sk_ck2=$SK_E_BKEY
  if [ -L "$sk_ct" ] || [ ! -f "$sk_ct" ]; then
    sk__edit_drop "$sk_cm"
    sk_fail 1 SK-E012 "The working copy of $sk_cm is gone; nothing was written." "Run the installer again."
  fi
  sk__dirname "$sk_cm"
  sk_cn=${SK_DIR%/}/.${sk_cm##*/}.shakekee-new
  if [ "$sk_cex" = 1 ]; then
    sk__plain_file "$sk_cm"
    if [ ! -f "$sk_cm" ] || [ "$(sk_sha256 "$sk_cm")" != "$sk_ch" ]; then
      rm -f "$sk_ct"
      sk__edit_drop "$sk_cm"
      sk_fail 1 SK-E012 "$sk_cm changed while it was being edited (by you or by another program); nothing was written." "Look at the file, then run the installer again. The original is in $SK_BACKUP_DIR."
    fi
    if cmp -s "$sk_ct" "$sk_cm"; then
      # nothing to change: no new file, and no backup record for an edit that did not happen
      rm -f "$sk_ct"
      sk_unrecord "$sk_ck2"
      sk__edit_drop "$sk_cm"
      sk_log "unchanged $sk_cm"
      return 0
    fi
    rm -f "$sk_cn"
    # the new file gets the attributes of the old one, then the content, then the old name, in one rename
    if ! cp -p "$sk_cm" "$sk_cn" 2>/dev/null || ! chmod u+w "$sk_cn" 2>/dev/null ||
      ! cat "$sk_ct" >"$sk_cn" 2>/dev/null ||
      ! { [ -z "$sk_cmode" ] || chmod "$sk_cmode" "$sk_cn" 2>/dev/null; } ||
      ! mv -f "$sk_cn" "$sk_cm" 2>/dev/null; then
      rm -f "$sk_cn" "$sk_ct"
      sk__edit_drop "$sk_cm"
      sk_fail 1 SK-E013 "Cannot write the new $sk_cm." "$sk_cm was not changed. Check the permissions and the free space."
    fi
  else
    if [ -e "$sk_cm" ] || [ -L "$sk_cm" ]; then
      rm -f "$sk_ct"
      sk__edit_drop "$sk_cm"
      sk_fail 1 SK-E012 "$sk_cm appeared while it was being created; nothing was written." "Look at the file, then run the installer again."
    fi
    if ! mv -f "$sk_ct" "$sk_cm" 2>/dev/null; then
      rm -f "$sk_ct"
      sk__edit_drop "$sk_cm"
      sk_fail 1 SK-E013 "Cannot create $sk_cm." "Check the permissions."
    fi
  fi
  rm -f "$sk_ct"
  sk_caf=$(sk_sha256 "$sk_cm")
  if [ "$sk_cex" = 1 ]; then
    sk__set_after "$sk_ck2" "$sk_cm"
  else
    sk_created "$sk_cm"
  fi
  sk__edit_put "$sk_cm" "$sk_ct" "$sk_ch" "$sk_cmode" "$sk_cex" committed "$sk_ck2" "$sk_caf"
  sk_log "edited $sk_cm"
}

# sk_edit_rollback FILE
sk_edit_rollback() {
  sk__abs "$1"
  sk_rf=$SK_ABS
  if [ "$SK_DRY" = 1 ]; then
    return 0
  fi
  if ! sk__edit_find "$sk_rf"; then
    return 0
  fi
  sk_rt2=$SK_E_TMP
  sk_rst=$SK_E_STATE
  sk_rex=$SK_E_EXISTED
  sk_rbk=$SK_E_BKEY
  sk_raf=$SK_E_AFTER
  if [ "$sk_rst" = open ]; then
    rm -f "$sk_rt2"
    if [ -n "$sk_rbk" ]; then
      sk_unrecord "$sk_rbk"
    fi
    sk__edit_drop "$sk_rf"
    sk_log "edit of $sk_rf discarded"
    return 0
  fi
  if [ -L "$sk_rf" ] || [ ! -f "$sk_rf" ] || [ "$(sk_sha256 "$sk_rf")" != "$sk_raf" ]; then
    sk_fail 1 SK-E012 "$sk_rf is not as ShakeKee left it; it was not rolled back." "Compare it with the backup in $SK_BACKUP_DIR."
  fi
  if [ "$sk_rex" = 1 ]; then
    sk_rec_get "$sk_rbk" || true
    sk__fields "$SK_RV"
    if ! sk__restore_copy "$SK_F2" "$sk_rf"; then
      sk_fail 1 SK-E013 "Cannot put the backup back over $sk_rf." "The backup is $SK_F2."
    fi
    sk_unrecord "$sk_rbk"
  else
    rm -f "$sk_rf"
    sk_rec_load
    sk_rk2=
    if [ -n "$SK_REC" ]; then
      while IFS=$UI_TAB read -r sk_rkk sk_rkv; do
        case $sk_rkk in
          created.*)
            sk__unjq "$sk_rkv"
            sk__fields "$SK_RV"
            if [ "$SK_F1" = "$sk_rf" ]; then
              sk_rk2=$sk_rkk
            fi
            ;;
        esac
      done <<REC
$SK_REC
REC
    fi
    if [ -n "$sk_rk2" ]; then
      sk_unrecord "$sk_rk2"
    fi
  fi
  sk__edit_drop "$sk_rf"
  sk_log "edit of $sk_rf rolled back"
}

# sk__restore_copy COPY DEST: puts COPY over DEST (attributes of COPY), through a file next to DEST and one rename.
sk__restore_copy() {
  sk__dirname "$2"
  sk_rn=${SK_DIR%/}/.${2##*/}.shakekee-restore
  rm -f "$sk_rn"
  if cp -p "$1" "$sk_rn" 2>/dev/null && cmp -s "$1" "$sk_rn" && mv -f "$sk_rn" "$2" 2>/dev/null; then
    return 0
  fi
  rm -f "$sk_rn"
  return 1
}

# ---- undoing it all ----

# sk__changes [SCOPE]: SK_CHG lists the keys of the recorded changes, newest first, and SK_NCHG counts them.
# With SCOPE only what that integration did.
sk__changes() {
  SK_CHG=
  SK_NCHG=0
  sk_rec_load
  if [ -n "$SK_REC" ]; then
    while IFS=$UI_TAB read -r sk_hk sk_hv; do
      case $sk_hk in
        backup.* | created.*)
          if [ -n "${1:-}" ]; then
            sk__unjq "$sk_hv"
            sk_hs=${SK_RV##*"|"}
            if [ "$sk_hs" != "$1" ]; then
              continue
            fi
          fi
          SK_CHG="$sk_hk${SK_CHG:+$UI_NL$SK_CHG}"
          SK_NCHG=$((SK_NCHG + 1))
          ;;
      esac
    done <<REC
$SK_REC
REC
  fi
}

# sk__left TEXT: something that was not undone: reported, counted, and the record stays.
sk__left() {
  SK_LEFT=$((SK_LEFT + 1))
  SK_LEFT_LIST="${SK_LEFT_LIST:+$SK_LEFT_LIST$UI_NL}$1"
  ui_step_warn "$1"
}

# sk__restore_backup KEY ORIGINAL COPY AFTER
sk__restore_backup() {
  ui_step_start restore "Restoring $2"
  if [ ! -f "$3" ]; then
    sk__left "the backup copy $3 is missing; $2 is left as it is"
  elif [ -L "$2" ]; then
    sk__left "$2 is now a symbolic link; left as it is (the original is kept at $3)"
  elif [ ! -e "$2" ]; then
    sk__left "$2 is gone; not recreated (the original is kept at $3)"
  elif cmp -s "$3" "$2"; then
    sk_unrecord "$1"
    ui_step_ok "already the original"
  elif [ -n "$4" ] && [ "$(sk_sha256 "$2")" = "$4" ]; then
    if [ "$SK_DRY" = 1 ]; then
      ui_step_ok "would put back the original from $3"
    elif sk__restore_copy "$3" "$2"; then
      sk_unrecord "$1"
      ui_step_ok "the original is back (copy kept at $3)"
    else
      sk__left "could not write $2; the original is kept at $3"
    fi
  else
    sk__left "$2 was changed after ShakeKee edited it, so it was not touched (the original is kept at $3)"
  fi
}

# sk__restore_created KEY FILE HASH
sk__restore_created() {
  ui_step_start remove "Removing $2"
  if [ "$3" = - ]; then
    if [ ! -d "$2" ]; then
      sk_unrecord "$1"
      ui_step_ok "already gone"
    elif [ "$SK_DRY" = 1 ]; then
      ui_step_ok "would remove the directory if it is empty"
    elif rmdir "$2" 2>/dev/null; then
      sk_unrecord "$1"
      ui_step_ok
    else
      sk__left "the directory $2 is not empty; left as it is"
    fi
  elif [ ! -e "$2" ] && [ ! -L "$2" ]; then
    sk_unrecord "$1"
    ui_step_ok "already gone"
  elif [ -L "$2" ] || [ ! -f "$2" ]; then
    sk__left "$2 is not the file ShakeKee wrote; left as it is"
  elif [ "$(sk_sha256 "$2")" != "$3" ]; then
    sk__left "$2 differs from what ShakeKee wrote (you changed it); left as it is"
  elif [ "$SK_DRY" = 1 ]; then
    ui_step_ok "would remove"
  else
    rm -f "$2"
    sk_unrecord "$1"
    ui_step_ok
  fi
}

# sk_restore_all [SCOPE]: undoes the recorded changes, newest first: puts backups back over files that are
# still as ShakeKee left them, removes created files that are still as ShakeKee wrote them. Whatever
# differs is left alone and said so (SK_LEFT counts those, SK_LEFT_LIST names them); its record stays.
sk_restore_all() {
  SK_LEFT=0
  SK_LEFT_LIST=
  sk__changes "${1:-}"
  if [ -z "$SK_CHG" ]; then
    return 0
  fi
  while IFS= read -r sk_ak; do
    if sk_rec_get "$sk_ak"; then
      sk__fields "$SK_RV"
      case $sk_ak in
        backup.*) sk__restore_backup "$sk_ak" "$SK_F1" "$SK_F2" "$SK_F3" ;;
        created.*) sk__restore_created "$sk_ak" "$SK_F1" "$SK_F2" ;;
      esac
    fi
  done <<KEYS
$SK_CHG
KEYS
}

# sk_remains TEXT: something only the person can remove (a DNS record, a secret in another service);
# --uninstall lists it at the end.
sk_remains() {
  sk__seq remains
  sk_record "remains.$SK_SEQ" "${SK_INTEGRATION:-}|$1"
}

# ---- phases ----
# A recipe defines any of recipe_detect, recipe_choose, recipe_check, recipe_plan, recipe_apply,
# recipe_verify, recipe_handover and calls sk_phases (or sk_phase NAME for one) as the last thing it does.
# recipe_plan lists what will happen with sk_plan_add; the list is shown and, unless --yes or
# --dry-run, the person confirms it before recipe_apply runs. With --dry-run the steps (sk_step_run,
# sk_sudo, sk_do) only say what they would do and recipe_verify is skipped.
#
# With --status, --update, --uninstall or --doctor (SK_ACTION) the same call runs the recipe's hook
# recipe_status, recipe_update, recipe_uninstall or recipe_doctor instead of the phases. --update without a
# recipe_update runs the phases again. A recipe that has any of these hooks is recorded as an integration
# (sk_integration_add) when its apply phase is done; only recorded integrations take part in the actions.

# sk_plan_add TEXT
sk_plan_add() {
  SK_PLAN="${SK_PLAN:+$SK_PLAN$UI_NL}$1"
}

sk_phase() {
  case $1 in
    detect | choose | check | plan | apply | verify | handover) ;;
    *) sk_fail 1 SK-E010 "Unknown phase: $1" "This is a bug in the installer; please report it." ;;
  esac
  SK_PHASE=$1
  sk_log "phase $1"
  if [ "$UI_MODE" = json ]; then
    ui__ev phase name "$1"
  fi
  if [ "$1" = verify ] && [ "$SK_DRY" = 1 ]; then
    return 0
  fi
  if command -v "recipe_$1" >/dev/null 2>&1; then
    "recipe_$1"
  fi
  if [ "$1" = plan ]; then
    sk_plan_confirm
  fi
}

# sk__has_hooks: succeeds when the recipe defined a hook for one of the actions.
sk__has_hooks() {
  for sk_hn in status update uninstall doctor; do
    if command -v "recipe_$sk_hn" >/dev/null 2>&1; then
      return 0
    fi
  done
  return 1
}

# sk__unset_hooks: forgets the functions a recipe defined, so that the next recipe does not run them.
sk__unset_hooks() {
  for sk_hn in detect choose check plan apply verify handover status update uninstall doctor; do
    unset -f "recipe_$sk_hn" 2>/dev/null || true
  done
}

# sk_phases: all seven, in order (or the hook of the action, see above).
sk_phases() {
  if [ "$SK_ACTION" != install ]; then
    SK_HOOK_RAN=1
    SK_HOOK_RC=0
    if command -v "recipe_$SK_ACTION" >/dev/null 2>&1; then
      if [ "$UI_MODE" = json ]; then
        ui__ev hook action "$SK_ACTION" integration "${SK_INTEGRATION:-}"
      fi
      "recipe_$SK_ACTION" || SK_HOOK_RC=$?
      return 0
    fi
    if [ "$SK_ACTION" != update ]; then
      SK_HOOK_RAN=0
      return 0
    fi
  fi
  for sk_pn in detect choose check plan apply verify handover; do
    sk_phase "$sk_pn"
    if [ "$sk_pn" = apply ] && sk__has_hooks; then
      sk_integration_add "${SK_INTEGRATION:-${SK_SLUG:-}}" "${SK_RECIPE_VERSION:-installed}"
    fi
  done
}

sk_plan_confirm() {
  if [ -z "$SK_PLAN" ]; then
    return 0
  fi
  if [ "$UI_MODE" = json ]; then
    sk_pj=
    while IFS= read -r sk_pl; do
      ui__jq "$sk_pl"
      sk_pj="${sk_pj:+$sk_pj,}$UI_J"
    done <<PLAN
$SK_PLAN
PLAN
    ui__ev plan "@steps" "[$sk_pj]"
  else
    ui_title "What will happen"
    sk_pi=1
    while IFS= read -r sk_pl; do
      ui_say "  $sk_pi. $sk_pl"
      sk_pi=$((sk_pi + 1))
    done <<PLAN
$SK_PLAN
PLAN
  fi
  if [ "$SK_DRY" = 1 ]; then
    ui_note "Dry run: nothing will be changed."
    return 0
  fi
  if [ "$SK_YES" = 1 ]; then
    return 0
  fi
  ui_menu_single "Go ahead?" "+go|Yes, go ahead" "stop|No, stop here"
  if [ "$UI_REPLY" != go ]; then
    # SK_STOP_NOTE: set by a recipe that did something before its plan (an SDK that installed the daemon first)
    ui_say "Stopped at your request. ${SK_STOP_NOTE:-Nothing was changed.}"
    exit 3
  fi
}

# ---- integrations: what --status, --update, --doctor and --uninstall act on ----

# sk_integration_add SLUG [VERSION]: records that SLUG is installed (sk_phases does it for a recipe with hooks).
sk_integration_add() {
  case $1 in
    '' | *[!a-z0-9_]*) sk_fail 1 SK-E010 "A bad integration name was given to sk_integration_add: $1" "This is a bug in the installer; please report it." ;;
  esac
  sk_record "integration.$1" "${2:-installed}"
  sk_record "installed.$1" "$(date -u +%Y-%m-%dT%H:%M:%SZ 2>/dev/null || :)"
}

# sk_integration_forget SLUG
sk_integration_forget() {
  sk_unrecord "integration.$1"
  sk_unrecord "installed.$1"
}

# sk_integrations: SK_INTEG lists the recorded integrations, oldest first.
sk_integrations() {
  SK_INTEG=
  sk_rec_load
  if [ -n "$SK_REC" ]; then
    while IFS=$UI_TAB read -r sk_ik sk_iv; do
      case $sk_ik in
        integration.*) SK_INTEG="${SK_INTEG:+$SK_INTEG$UI_NL}${sk_ik#integration.}" ;;
      esac
    done <<REC
$SK_REC
REC
  fi
}

# sk__is_hub: the hub script (SK_HUB=1 or SK_SLUG=hub) has a recipe function per integration
# (sk_recipe_<slug>); a script for one integration only has sk_recipe.
sk__is_hub() {
  if [ "${SK_HUB:-0}" = 1 ] || [ "${SK_SLUG:-}" = hub ]; then
    return 0
  fi
  for sk_hi in $SK_INTEG; do
    if command -v "sk_recipe_$sk_hi" >/dev/null 2>&1; then
      return 0
    fi
  done
  return 1
}

# sk_action_targets [reverse]: SK_TARGETS lists the integrations to act on (all recorded ones in the hub,
# only its own in the script of one integration) and SK_SCOPE is the scope for sk_restore_all.
sk_action_targets() {
  sk_integrations
  SK_TARGETS=
  SK_SCOPE=
  if sk__is_hub; then
    SK_TARGETS=$SK_INTEG
  else
    SK_SCOPE=${SK_SLUG:-}
    for sk_ti in $SK_INTEG; do
      if [ "$sk_ti" = "${SK_SLUG:-}" ]; then
        SK_TARGETS=$sk_ti
      fi
    done
  fi
  if [ "${1:-}" = reverse ]; then
    sk__reverse "$SK_TARGETS"
    SK_TARGETS=$SK_REVERSED
  fi
}

# sk_run_recipe SLUG [ARGS...]: runs the recipe of SLUG (sk_recipe_<slug> in the hub, sk_recipe in the script
# of that slug) under the current SK_ACTION. Returns 127 when this script does not have the recipe. For an
# action other than install it returns 126 when the recipe has no hook for it, else the hook's status.
sk_run_recipe() {
  sk_rr=$1
  shift
  if command -v "sk_recipe_$sk_rr" >/dev/null 2>&1; then
    sk_rrf=sk_recipe_$sk_rr
  elif [ "$sk_rr" = "${SK_SLUG:-}" ]; then
    sk_rrf=sk_recipe
  else
    return 127
  fi
  sk_rrp=$SK_INTEGRATION
  SK_INTEGRATION=$sk_rr
  SK_HOOK_RAN=0
  SK_HOOK_RC=0
  sk_rrc=0
  if [ "$SK_ACTION" = install ]; then
    # not in a || list: the shell would ignore set -e inside the recipe, which relies on it to stop at the first failure
    "$sk_rrf" "$@"
  else
    "$sk_rrf" "$@" || sk_rrc=$?
  fi
  sk__unset_hooks
  SK_INTEGRATION=$sk_rrp
  if [ "$SK_ACTION" = install ] || [ "$sk_rrc" != 0 ]; then
    return "$sk_rrc"
  fi
  if [ "$SK_HOOK_RAN" = 0 ]; then
    return 126
  fi
  return "$SK_HOOK_RC"
}

# sk__target SLUG ARGS...: sk_run_recipe for an action; SK_TR is ok, failed, nohook or
# missing. A recipe without the hook, or a recipe this script does not have, gets a message here.
sk__target() {
  sk_tg=$1
  shift
  sk_tgc=0
  sk_run_recipe "$sk_tg" "$@" || sk_tgc=$?
  case $sk_tgc in
    0) SK_TR=ok ;;
    126)
      SK_TR=nohook
      ;;
    127)
      SK_TR=missing
      sk__report 4 SK-E009 "$sk_tg is recorded as installed, but this script cannot act on it." "Run the installer of the ShakeKee it was installed from, or the production one: curl -fsSL https://get.shakekee.com | sh -s -- --$SK_ACTION"
      ;;
    *) SK_TR=failed ;;
  esac
}

# sk__no_hook SLUG: the integration is installed but has no step for the action.
sk__no_hook() {
  sk__report 4 SK-E009 "$1 has no --$SK_ACTION step." "Do it with the tool $1 was installed with (for example the plugin manager of your site)."
}

# sk_not_available: nothing is recorded for this installer, so there is nothing to act on (SK-E008, exit 4).
sk_not_available() {
  case $SK_ACTION in
    uninstall) sk_fail 4 SK-E008 "Uninstalling is not available for this installer yet." "Remove it with the tool it was installed with (for example the plugin manager of your site)." ;;
    update) sk_fail 4 SK-E008 "Updating is not available for this installer yet." "Run the installer again; it is safe to run again." ;;
    *) sk_fail 4 SK-E008 "--$SK_ACTION is not available for this installer yet." "Run the installer without it." ;;
  esac
}

# ---- --status ----

# sk__nobar TEXT: SK_NB is TEXT without bars (they separate the cells of a table).
sk__nobar() {
  SK_NB=
  sk_nb=$1
  while :; do
    case $sk_nb in
      *'|'*)
        SK_NB=$SK_NB${sk_nb%%'|'*}/
        sk_nb=${sk_nb#*'|'}
        ;;
      *)
        SK_NB=$SK_NB$sk_nb
        break
        ;;
    esac
  done
}

# sk_status_summary: what install.json says, readable (an event with --json).
sk_status_summary() {
  sk_integrations
  sk__changes ""
  if [ "$UI_MODE" = json ]; then
    sk_sj=
    for sk_si in $SK_INTEG; do
      ui__jq "$sk_si"
      sk_sj="${sk_sj:+$sk_sj,}$UI_J"
    done
    sk_sr='{}'
    if [ -f "$SK_HOME/install.json" ]; then
      sk_sr=$(tr -d '\n' <"$SK_HOME/install.json") || sk_sr='{}'
    fi
    ui__ev status home "$SK_HOME" mode "$(sk_mode)" '@record' "$sk_sr" '@integrations' "[$sk_sj]" '#changes' "$SK_NCHG"
    return 0
  fi
  ui_title "ShakeKee status"
  ui_say "  Home: $SK_HOME ($(sk_mode) mode)"
  if [ -z "$SK_REC" ]; then
    ui_say "  Nothing is recorded here yet."
    return 0
  fi
  set -- "Integration|Version|Since"
  for sk_si in $SK_INTEG; do
    sk_rec_get "integration.$sk_si" || true
    sk__nobar "$SK_RV"
    sk_sv1=$SK_NB
    sk_rec_get "installed.$sk_si" || true
    sk__nobar "$SK_RV"
    set -- "$@" "$sk_si|$sk_sv1|$SK_NB"
  done
  if [ -n "$SK_INTEG" ]; then
    ui_table "$@"
  else
    ui_say "  No integration with lifecycle steps is recorded."
  fi
  set -- "Fact|Value"
  while IFS=$UI_TAB read -r sk_fk sk_fv; do
    case $sk_fk in
      integration.* | installed.* | backup.* | created.* | remains.*) ;;
      *)
        sk__unjq "$sk_fv"
        sk__nobar "$SK_RV"
        set -- "$@" "$sk_fk|$SK_NB"
        ;;
    esac
  done <<REC
$SK_REC
REC
  if [ $# -gt 1 ]; then
    ui_table "$@"
  fi
  if [ "$SK_NCHG" -gt 0 ]; then
    ui_note "$SK_NCHG recorded change(s) to files; --uninstall puts them back (copies of the originals are under backups/)."
  fi
  sk__remains_list ""
}

# sk__remains_list [SCOPE]: shows what is recorded with sk_remains.
sk__remains_list() {
  sk_rl2=
  if [ -n "$SK_REC" ]; then
    while IFS=$UI_TAB read -r sk_mk sk_mv; do
      case $sk_mk in
        remains.*)
          sk__unjq "$sk_mv"
          sk_msc=${SK_RV%%"|"*}
          sk_mtx=${SK_RV#*"|"}
          if [ -z "${1:-}" ] || [ "$sk_msc" = "$1" ]; then
            sk_rl2="${sk_rl2:+$sk_rl2$UI_NL}$sk_mtx"
          fi
          ;;
      esac
    done <<REC
$SK_REC
REC
  fi
  if [ -z "$sk_rl2" ]; then
    return 0
  fi
  if [ "$UI_MODE" = json ]; then
    sk_rj=
    while IFS= read -r sk_rx; do
      ui__jq "$sk_rx"
      sk_rj="${sk_rj:+$sk_rj,}$UI_J"
    done <<LIST
$sk_rl2
LIST
    ui__ev remains "@items" "[$sk_rj]"
    return 0
  fi
  ui_title "Only you can remove these"
  while IFS= read -r sk_rx; do
    ui_note "$sk_rx"
  done <<LIST
$sk_rl2
LIST
}

# --status: the summary, then the status hook of each recorded integration (live checks). Exits 1 when a hook
# reports a problem.
sk_action_status() {
  sk_home
  sk_rec_load
  sk_action_targets
  sk_status_summary
  sk_bad=0
  for sk_ti in $SK_TARGETS; do
    ui_title "Checks: $sk_ti"
    sk__target "$sk_ti" "$@"
    case $SK_TR in
      ok) ;;
      nohook) ui_say "  $sk_ti has no live checks." ;;
      *) sk_bad=1 ;;
    esac
  done
  exit "$sk_bad"
}

# ---- --doctor ----

# sk__doc_fail DETAIL: ends the open step as a failure and counts it.
sk__doc_fail() {
  SK_DOC_BAD=$((SK_DOC_BAD + 1))
  ui_step_fail "$1"
}

# sk__doc_daemon: asks the daemon's own status (shakekeed status --json) when a daemon is recorded or installed.
sk__doc_daemon() {
  sk_dd=
  # the recorded binary is run: it must be ours and nobody else's to write
  if sk_rec_get daemon && [ -x "$SK_RV" ] && sk_own_only "$SK_RV"; then
    sk_dd=$SK_RV
  elif [ -x "$SK_HOME/bin/shakekeed" ] && sk_own_only "$SK_HOME/bin/shakekeed"; then
    sk_dd=$SK_HOME/bin/shakekeed
  fi
  if [ -z "$sk_dd" ]; then
    return 0
  fi
  ui_step_start daemon "The ShakeKee daemon answers ($sk_dd)"
  # a system-mode daemon keeps its state under /var/lib/shakekeed and reads its config from /etc: ask it with that config
  sk_dc=
  if sk_rec_get daemon.mode && [ "$SK_RV" = system ] && sk_rec_get daemon.conf; then
    sk_dc=$SK_RV
  fi
  if [ -n "$sk_dc" ]; then
    sk_dq=$("$sk_dd" --config "$sk_dc" status --json 2>&1 </dev/null) && sk_dr=0 || sk_dr=1
  else
    sk_dq=$(SHAKEKEE_HOME=$SK_HOME "$sk_dd" status --json 2>&1 </dev/null) && sk_dr=0 || sk_dr=1
  fi
  if [ "$sk_dr" = 0 ]; then
    case $sk_dq in
      *'"reachable":true'* | *'"reachable": true'*)
        case $sk_dq in
          *'"problems":[]'* | *'"problems": []'*) ui_step_ok ;;
          *) ui_step_warn "it runs, but reports problems:${UI_NL}$(printf '%s' "$sk_dq" | cut -c1-300)${UI_NL}Fix: run \"$sk_dd status\" and follow what it says." ;;
        esac
        ;;
      *) sk__doc_fail "it is installed but not reachable.${UI_NL}Fix: start it (systemctl --user start shakekeed, or sudo systemctl start shakekeed) and check \"$sk_dd status\"." ;;
    esac
  else
    sk__doc_fail "\"$sk_dd status --json\" failed:${UI_NL}$(printf '%s' "$sk_dq" | cut -c1-300)${UI_NL}Fix: reinstall the daemon, or run the command by hand."
  fi
}

# --doctor: the checks every installation needs, then the doctor hook of each recorded integration. The
# fix for a problem is part of its line. Exits 1 when something failed.
sk_action_doctor() {
  sk_home
  sk_rec_load
  sk_action_targets
  SK_DOC_BAD=0
  ui_title "ShakeKee doctor"
  ui_step_start tools "Tools: curl, tar, gzip and a SHA-256 tool"
  sk_dm=
  for sk_dt in curl tar gzip; do
    if ! command -v "$sk_dt" >/dev/null 2>&1; then
      sk_dm="$sk_dm $sk_dt"
    fi
  done
  if ! command -v sha256sum >/dev/null 2>&1 && ! command -v shasum >/dev/null 2>&1 && ! command -v openssl >/dev/null 2>&1; then
    sk_dm="$sk_dm sha256sum"
  fi
  if [ -z "$sk_dm" ]; then
    ui_step_ok
  else
    sk__doc_fail "missing:$sk_dm${UI_NL}Fix: install them with your package manager (for example: apt install curl tar gzip coreutils)."
  fi
  ui_step_start ed25519 "OpenSSL can make Ed25519 keys"
  if ! command -v openssl >/dev/null 2>&1; then
    ui_step_warn "openssl is not installed; keys for a new API credential would be made with node or python3 (cryptography).${UI_NL}Fix: install openssl 1.1.1 or newer."
  elif openssl genpkey -algorithm ed25519 -outform DER >/dev/null 2>&1; then
    ui_step_ok
  else
    ui_step_warn "this OpenSSL cannot make Ed25519 keys (it is older than 1.1.1, or LibreSSL older than 3.7).${UI_NL}Fix: install a newer OpenSSL, or have node or python3 with cryptography."
  fi
  ui_step_start origin "Reaching ${SK_ORIGIN:-the installer service}"
  if ! command -v curl >/dev/null 2>&1; then
    ui_step_warn "skipped: curl is missing"
  # (not a download: the answer is thrown away; the one place that downloads is sk_download)
  elif sk_dc=$(curl -f -sS --proto '=https' --tlsv1.2 --max-time 10 -o /dev/null "${SK_ORIGIN:-https://get.shakekee.com}/health" 2>&1 </dev/null); then
    ui_step_ok
  else
    sk__doc_fail "${sk_dc:-no answer}${UI_NL}Fix: check the network connection, the proxy settings and the clock of this machine."
  fi
  ui_step_start home "The ShakeKee folder $SK_HOME"
  if [ ! -e "$SK_HOME" ]; then
    ui_step_warn "it does not exist yet (nothing is installed here)"
  elif [ -d "$SK_HOME" ] && [ -w "$SK_HOME" ]; then
    ui_step_ok
  else
    sk__doc_fail "it is not a directory you can write to.${UI_NL}Fix: chown it to the user that runs the installer, or use --home DIR."
  fi
  sk__doc_daemon
  for sk_ti in $SK_TARGETS; do
    ui_title "Checks: $sk_ti"
    sk__target "$sk_ti" "$@"
    case $SK_TR in
      ok | nohook) ;;
      *) SK_DOC_BAD=$((SK_DOC_BAD + 1)) ;;
    esac
  done
  if [ "$SK_DOC_BAD" = 0 ]; then
    ui_say "No problem found."
  else
    ui_say "$SK_DOC_BAD problem(s) found; the line of each says how to fix it."
  fi
  if [ "$UI_MODE" = json ]; then
    ui__ev doctor '#problems' "$SK_DOC_BAD"
  fi
  if [ "$SK_DOC_BAD" != 0 ]; then
    exit 1
  fi
  exit 0
}

# ---- --update ----

# --update: the update hook of each recorded integration (without one, the recipe's phases run again, which
# is what "safe to run again" means for an installer).
sk_action_update() {
  sk_home
  sk_rec_load
  sk_action_targets
  if [ -z "$SK_TARGETS" ]; then
    sk_not_available
  fi
  sk_bad=0
  for sk_ti in $SK_TARGETS; do
    sk_rec_get "integration.$sk_ti" || true
    ui_title "Updating $sk_ti (recorded version: ${SK_RV:-unknown})"
    sk__target "$sk_ti" "$@"
    case $SK_TR in
      ok) ;;
      nohook) sk__no_hook "$sk_ti"; sk_bad=4 ;;
      *) sk_bad=1 ;;
    esac
  done
  exit "$sk_bad"
}

# ---- --uninstall ----

# sk__remove_home: the end of a full uninstall: asks before removing the folder, and asks separately before
# removing credentials/ and state/ (private keys, the daemon's identity).
sk__remove_home() {
  if [ ! -f "$SK_HOME/install.json" ] || [ "$SK_HOME" = / ] || [ "$SK_HOME" = "${HOME:-/}" ]; then
    return 0
  fi
  if [ "$SK_LEFT" -gt 0 ]; then
    ui_note "$SK_HOME stays: it holds the copies of the files that could not be restored."
    return 0
  fi
  if [ "$SK_DRY" = 1 ]; then
    ui_say "[dry-run] would ask whether to remove $SK_HOME"
    return 0
  fi
  sk_integrations
  if [ -n "$SK_INTEG" ]; then
    ui_note "$SK_HOME stays: other integrations are still recorded in it ($(printf '%s' "$SK_INTEG" | tr '\n' ' '))."
    return 0
  fi
  ui_menu_single "Remove the ShakeKee folder $SK_HOME (logs, backups, examples)?" "+keep|Keep it" "remove|Remove it"
  if [ "$UI_REPLY" != remove ]; then
    return 0
  fi
  sk_keepc=
  sk_dropd=
  for sk_hd in credentials state; do
    if [ -d "$SK_HOME/$sk_hd" ]; then
      if [ "$sk_hd" = credentials ]; then
        ui_menu_single "Also remove credentials/ (the saved API keys and private keys)?" "+keep|Keep them" "remove|Remove them"
      else
        ui_menu_single "Also remove state/ (the daemon's identity key and registrations)?" "+keep|Keep it" "remove|Remove it"
      fi
      if [ "$UI_REPLY" != remove ]; then
        sk_keepc="$sk_keepc $sk_hd"
      else
        sk_dropd="$sk_dropd $sk_hd"
      fi
    fi
  done
  SK_LOG=/dev/null
  # only what ShakeKee puts there; anything else in the folder stays
  for sk_hf in install.json logs backups examples bin shakekeed.toml $sk_dropd; do
    rm -rf "$SK_HOME/$sk_hf"
  done
  rmdir "$SK_HOME" 2>/dev/null || true
  ui_say "Removed $SK_HOME${sk_keepc:+ (kept:$sk_keepc)}."
}

# --uninstall: shows the plan and asks; runs the uninstall hook of each recorded integration, newest first;
# puts back what was edited and removes what was created (only what is still as ShakeKee left it); lists what
# only the person can remove; asks before removing the folder. With --dry-run nothing is changed.
sk_action_uninstall() {
  sk_home
  sk_rec_load
  sk_action_targets reverse
  sk__changes "$SK_SCOPE"
  if [ -z "$SK_TARGETS" ] && [ "$SK_NCHG" = 0 ]; then
    if sk__is_hub; then
      ui_say "Nothing is recorded in $SK_HOME; there is nothing to remove."
      exit 0
    fi
    sk_not_available
  fi
  for sk_ti in $SK_TARGETS; do
    sk_plan_add "Run the uninstall step of $sk_ti"
  done
  if [ "$SK_NCHG" -gt 0 ]; then
    sk_plan_add "Undo $SK_NCHG recorded change(s) to files (backups are put back, files we created are removed, unless you changed them since)"
  fi
  sk_plan_add "List what only you can remove"
  sk_plan_confirm
  sk_bad=0
  for sk_ti in $SK_TARGETS; do
    ui_title "Removing $sk_ti"
    sk__target "$sk_ti" "$@"
    case $SK_TR in
      ok) sk_integration_forget "$sk_ti" ;;
      nohook) sk__no_hook "$sk_ti"; sk_bad=4 ;;
      *) sk_bad=1 ;;
    esac
  done
  if [ "$SK_NCHG" -gt 0 ]; then
    ui_title "Putting back what was changed"
    sk_restore_all "$SK_SCOPE"
    if [ "$SK_LEFT" -gt 0 ] && [ "$sk_bad" = 0 ]; then
      sk_bad=1
    fi
  else
    SK_LEFT=0
  fi
  sk__remains_list "$SK_SCOPE"
  sk__remove_home
  exit "$sk_bad"
}

# ---- shared by every installer (POSIX sh; nothing here is specific to an integration) ----
# Function definitions only. These variables are set, and the traps installed, by sk_boot in core.sh:
# SK_TASK (temporary directory), SK_DRY (1 with --dry-run), SK_FILE (the last verified download).

sk_cleanup() {
  if [ -n "$SK_TASK" ] && [ -d "$SK_TASK" ]; then
    rm -rf "$SK_TASK"
  fi
}

# say, warn and die are the original interface; they print through ui.sh and keep the log.
say() {
  sk_log "$*"
  ui_say "$*"
}
warn() {
  sk_log "WARNING: $*"
  ui_warn "$*"
}
die() {
  sk_log "ERROR: $*"
  ui_error "$*"
  exit 1
}

# need COMMAND HINT
need() {
  command -v "$1" >/dev/null 2>&1 || die "$1 is not installed or not on the PATH: $2"
}

# Runs a state-changing command, or only says what it would do with --dry-run.
sk_do() {
  sk_desc=$1
  shift
  if [ "$SK_DRY" = 1 ]; then
    say "[dry-run] $sk_desc"
  else
    "$@"
  fi
}

sk_sha256() {
  if command -v sha256sum >/dev/null 2>&1; then
    sha256sum "$1" | cut -d ' ' -f 1
  elif command -v shasum >/dev/null 2>&1; then
    shasum -a 256 "$1" | cut -d ' ' -f 1
  elif command -v openssl >/dev/null 2>&1; then
    openssl dgst -sha256 -r "$1" | cut -d ' ' -f 1
  else
    die "a SHA-256 tool (sha256sum, shasum or openssl) is required to verify the download"
  fi
}

# sk_download FILE SHA256: fetches $SK_ORIGIN/artifacts/FILE over https (no redirects), checks the
# SHA-256 that is written into this script, and leaves the path in SK_FILE. Nothing else is touched
# before the check passes.
sk_download() {
  need curl "install curl and run this again"
  if [ -z "$SK_TASK" ]; then
    SK_TASK=$(mktemp -d "${TMPDIR:-/tmp}/shakekee-install.XXXXXX") || die "cannot create a temporary directory"
  fi
  sk_url=$SK_ORIGIN/artifacts/$1
  curl --fail --silent --show-error --proto '=https' --tlsv1.2 --max-time 300 --output "$SK_TASK/$1" "$sk_url" ||
    die "download failed: $sk_url"
  sk_actual=$(sk_sha256 "$SK_TASK/$1")
  [ "$sk_actual" = "$2" ] || die "checksum mismatch for $1 (expected $2, got $sk_actual); nothing was installed"
  say "Downloaded and verified $1."
  SK_FILE=$SK_TASK/$1
}

# sk_keep FILE DIR: copies a verified download into DIR (created), so that a package manager that
# records the path keeps working after the temporary directory is gone. Sets SK_KEPT.
sk_keep() {
  mkdir -p "$2" || die "cannot create $2"
  cp "$1" "$2/" || die "cannot copy into $2"
  SK_KEPT=$2/${1##*/}
}

# sk_value FLAG ARGUMENT-COUNT: refuses an option that was given without its value.
sk_value() {
  [ "$2" -ge 2 ] || die "$1 needs a value"
}

# sk_not_root_unless COMMAND-NAME OVERRIDE-HINT
sk_refuse_root() {
  if [ "$(id -u)" = 0 ]; then
    die "$1 should not be run as root here: $2"
  fi
}

# sk_extract_tgz FILE DIR: unpacks a .tgz/.tar.gz into DIR (created) without trusting its member names.
sk_extract_tgz() {
  need tar "install tar"
  need gzip "install gzip"
  mkdir -p "$2" || die "cannot create $2"
  if tar -tzf "$1" | grep -Eq '(^/|(^|/)\.\.(/|$))'; then
    die "the archive contains absolute or parent paths; refusing to unpack it"
  fi
  tar -xzf "$1" -C "$2" || die "cannot unpack $1"
}

# sk_unpack_only SOURCE-DIR TARGET-DIR: the files, unpacked where you can read them.
sk_unpack_only() {
  if [ -e "$2" ]; then
    diff -r "$1" "$2" >/dev/null 2>&1 || die "$2 exists and is different. Move it away or run in another directory."
    say "$2 already holds this version."
  elif [ "$SK_DRY" = 1 ]; then
    say "[dry-run] unpack into $2"
  else
    cp -R "$1" "$2" || die "cannot write $2"
    say "Unpacked into $2."
  fi
}

# Composer's artifact repository reads the zip with PHP's zip extension.
sk_require_php_zip() {
  need php "install PHP and run this again"
  php -r 'exit(class_exists("ZipArchive") ? 0 : 1);' ||
    die "PHP's zip extension is not enabled (Composer's artifact repository needs it): install php-zip and run this again."
}
# ---- detect.sh: what is on this machine (POSIX sh; defines functions only) ----
# Read-only: it reads files and directory listings and looks at the process list. It sends nothing over the
# network, needs no root and creates nothing. The hub (hub.sh) calls it; a recipe may too.
#
#   sk_detect_run     sets the facts
#       SK_FACT_OS          linux | darwin | ... (uname -s, lower case)
#       SK_FACT_ARCH        x86_64 | aarch64 | ... (amd64 and arm64 are renamed)
#       SK_FACT_DISTRO      ID= of /etc/os-release (debian, ubuntu, alpine...), macos, or unknown
#       SK_FACT_INIT        systemd | other
#       SK_FACT_USER        the user name
#       SK_FACT_SUDO        yes | no: root, or sudo is installed (whether this user may use it is not tested)
#       SK_FACT_WEBSERVERS  space separated: the running ones of nginx, apache, caddy
#   sk_detect_items   prints one line per finding: slug|path|label|hint
#       slug   an installer slug (wordpress, woocommerce, joomla, drupal, prestashop, laravel, django, nodejs,
#              cloudflare, shopify, go, rust, php, csharp, nginx, apache, caddy) or daemon;
#              installed:<slug> where ShakeKee is already installed (path as for the slug)
#       path   the project or site directory, the server binary, or the install home; - when there is none
#       label  what it is, hint  one short fact about it (never contains a newline or a |)
#
# Where it looks: the current directory and, for sites, /var/www/* and /srv/* (the directory itself and its
# children), ~/public_html and ~/www; of each of those also public_html, htdocs, html and httpdocs inside it.
# SHAKEKEE_SITE_ROOTS (space separated directories) replaces that list of roots; the current directory is
# always searched. At most 100 children of a root are looked at.
#
# A generic finding is dropped where a specific one is in the same directory: nodejs where there is a Laravel,
# Cloudflare or Shopify project, php where there is WordPress, WooCommerce, Joomla, Drupal, PrestaShop or Laravel.
#
# Hooks for tests (not for users): SK_DETECT_PROCS (names, replaces the process list), SK_DETECT_BINDIRS
# (directories searched for server binaries, replaces PATH and the usual sbin directories), SK_DETECT_HOMES
# (install homes that are looked at for the daemon, replaces SK_HOME, SHAKEKEE_HOME, ~/.shakekee and
# /var/lib/shakekeed).

sk_detect_nl='
'

# sk_detect_run
sk_detect_run() {
  sk_dv=$(uname -s 2>/dev/null) || sk_dv=unknown
  SK_FACT_OS=$(printf '%s' "$sk_dv" | tr 'A-Z' 'a-z')
  sk_dv=$(uname -m 2>/dev/null) || sk_dv=unknown
  case $sk_dv in
    amd64 | x86_64) SK_FACT_ARCH=x86_64 ;;
    arm64 | aarch64) SK_FACT_ARCH=aarch64 ;;
    *) SK_FACT_ARCH=$sk_dv ;;
  esac
  SK_FACT_DISTRO=unknown
  if [ "$SK_FACT_OS" = darwin ]; then
    SK_FACT_DISTRO=macos
  elif [ -r /etc/os-release ]; then
    while IFS= read -r sk_dl; do
      case $sk_dl in
        ID=*)
          sk_dv=${sk_dl#ID=}
          sk_dv=${sk_dv#\"}
          sk_dv=${sk_dv%\"}
          sk_dv=${sk_dv#\'}
          sk_dv=${sk_dv%\'}
          if [ -n "$sk_dv" ]; then
            SK_FACT_DISTRO=$sk_dv
          fi
          break
          ;;
      esac
    done </etc/os-release
  fi
  if [ -d /run/systemd/system ]; then
    SK_FACT_INIT=systemd
  else
    SK_FACT_INIT=other
  fi
  SK_FACT_USER=$(id -un 2>/dev/null) || SK_FACT_USER=unknown
  SK_FACT_SUDO=no
  if [ "$(id -u 2>/dev/null)" = 0 ] || command -v sudo >/dev/null 2>&1; then
    SK_FACT_SUDO=yes
  fi
  SK_FACT_WEBSERVERS=
  sk_dprocs=$(sk_detect_procs)
  for sk_dw in nginx apache caddy; do
    case "$sk_detect_nl$sk_dprocs$sk_detect_nl" in
      *"$sk_detect_nl$sk_dw$sk_detect_nl"*) SK_FACT_WEBSERVERS="${SK_FACT_WEBSERVERS:+$SK_FACT_WEBSERVERS }$sk_dw" ;;
    esac
  done
}

# sk_detect_procs: the names of the web servers among the running processes, one per line, as
# nginx / apache / caddy.
sk_detect_procs() {
  sk_dall=
  if [ -n "${SK_DETECT_PROCS+x}" ]; then
    sk_dall=$(printf '%s\n' $SK_DETECT_PROCS)
  elif [ -d /proc/self ]; then
    for sk_dp in /proc/[0-9]*/comm; do
      if [ -r "$sk_dp" ]; then
        sk_dn=
        read -r sk_dn <"$sk_dp" 2>/dev/null || true
        sk_dall="$sk_dall$sk_detect_nl$sk_dn"
      fi
    done
  else
    sk_dall=$(ps -A -o comm= 2>/dev/null || ps -e -o comm= 2>/dev/null || ps -o comm= 2>/dev/null || true)
  fi
  while IFS= read -r sk_dn; do
    sk_dn=${sk_dn##*/}
    case $sk_dn in
      nginx) printf '%s\n' nginx ;;
      apache2 | apache | httpd) printf '%s\n' apache ;;
      caddy) printf '%s\n' caddy ;;
    esac
  done <<DETECT_PROCS
$sk_dall
DETECT_PROCS
}

# sk_detect_bin NAME...: prints the path of the first of the programs that is found in a directory of PATH
# or one of the usual sbin directories, and fails when none is.
sk_detect_bin() {
  if [ -n "${SK_DETECT_BINDIRS+x}" ]; then
    sk_dirs=$SK_DETECT_BINDIRS
  else
    sk_dirs="$(printf '%s' "${PATH:-}" | tr ':' ' ') /usr/sbin /usr/local/sbin /sbin /usr/local/bin /opt/homebrew/bin /usr/local/nginx/sbin /usr/local/apache2/bin"
  fi
  for sk_dn in "$@"; do
    for sk_dd in $sk_dirs; do
      if [ -f "$sk_dd/$sk_dn" ] && [ -x "$sk_dd/$sk_dn" ]; then
        printf '%s\n' "$sk_dd/$sk_dn"
        return 0
      fi
    done
  done
  return 1
}

# sk_detect_has FILE REGEX: FILE exists and has a line matching the extended regex.
sk_detect_has() {
  [ -f "$1" ] && grep -Eq -- "$2" "$1" 2>/dev/null
}

# sk_detect_mentions FILE...: one of the files exists and mentions ShakeKee.
sk_detect_mentions() {
  for sk_dm in "$@"; do
    if [ -f "$sk_dm" ] && grep -qi shakekee "$sk_dm" 2>/dev/null; then
      return 0
    fi
  done
  return 1
}

# sk_detect_candidates: the directories that may hold a site or a project, one per line (duplicates are
# dropped by the caller).
sk_detect_candidates() {
  printf '%s\n' "$PWD"
  if [ -n "${SHAKEKEE_SITE_ROOTS+x}" ]; then
    set -- $SHAKEKEE_SITE_ROOTS
  else
    set -- /var/www /srv "${HOME:-/nonexistent}/public_html" "${HOME:-/nonexistent}/www"
  fi
  for sk_dr in "$@"; do
    if [ -d "$sk_dr" ]; then
      sk_detect_candidate_with_subs "$sk_dr"
      sk_dcount=0
      for sk_dc in "$sk_dr"/*; do
        if [ -d "$sk_dc" ]; then
          sk_dcount=$((sk_dcount + 1))
          if [ "$sk_dcount" -gt 100 ]; then
            break
          fi
          sk_detect_candidate_with_subs "$sk_dc"
        fi
      done
    fi
  done
}

sk_detect_candidate_with_subs() {
  printf '%s\n' "$1"
  for sk_ds in public_html htdocs html httpdocs; do
    if [ -d "$1/$sk_ds" ]; then
      printf '%s\n' "$1/$sk_ds"
    fi
  done
}

# sk_detect_add SLUG PATH LABEL HINT: one finding. Fields cannot hold a | or a control character (a directory
# named with terminal escape codes must not reach the screen): such a finding is dropped.
sk_detect_add() {
  case "$1$2$3$4" in
    *'|'* | *[[:cntrl:]]*) return 0 ;;
  esac
  SK_DET_OUT="${SK_DET_OUT:+$SK_DET_OUT$sk_detect_nl}$1|$2|$3|$4"
}

# sk_detect_dir DIR: the findings for one directory (appended to SK_DET_OUT).
sk_detect_dir() {
  sk_dd=$1
  if [ ! -d "$sk_dd" ] || [ ! -r "$sk_dd" ]; then
    return 0
  fi
  sk_dd=$(cd "$sk_dd" 2>/dev/null && pwd -P) || return 0
  case "$sk_detect_nl$SK_DET_SEEN$sk_detect_nl" in
    *"$sk_detect_nl$sk_dd$sk_detect_nl"*) return 0 ;;
  esac
  SK_DET_SEEN="${SK_DET_SEEN:+$SK_DET_SEEN$sk_detect_nl}$sk_dd"
  case $sk_dd in
    *'|'* | *[[:cntrl:]]*) return 0 ;;
  esac
  sk_df_wp=0
  sk_df_woo=0
  sk_df_joomla=0
  sk_df_drupal=0
  sk_df_presta=0
  sk_df_laravel=0
  sk_df_django=0
  sk_df_node=0
  sk_df_go=0
  sk_df_rust=0
  sk_df_php=0
  sk_df_cs=0
  sk_df_cf=0
  sk_df_shop=0

  if [ -f "$sk_dd/wp-config.php" ]; then
    sk_df_wp=1
    set -- $(ls -ld "$sk_dd/wp-config.php" 2>/dev/null) x x x
    sk_down=$3
    if command -v wp >/dev/null 2>&1; then
      sk_dcli='WP-CLI found'
    else
      sk_dcli='WP-CLI not found'
    fi
    sk_detect_add wordpress "$sk_dd" 'WordPress site' "owner $sk_down, $sk_dcli"
    if [ -d "$sk_dd/wp-content/plugins/shakekee-protection" ]; then
      sk_detect_add installed:wordpress "$sk_dd" 'ShakeKee Protection plugin' 'already installed'
    fi
  fi
  if [ -d "$sk_dd/wp-content/plugins/woocommerce" ]; then
    sk_df_woo=1
    sk_detect_add woocommerce "$sk_dd" 'WooCommerce shop' 'plugin found'
    if [ -d "$sk_dd/wp-content/plugins/shakekee-woocommerce" ]; then
      sk_detect_add installed:woocommerce "$sk_dd" 'ShakeKee for WooCommerce' 'already installed'
    fi
  fi
  if [ -f "$sk_dd/configuration.php" ] && [ -f "$sk_dd/libraries/src/Version.php" ]; then
    sk_df_joomla=1
    sk_detect_add joomla "$sk_dd" 'Joomla site' 'configuration.php found'
    if [ -d "$sk_dd/plugins/system/shakekee" ]; then
      sk_detect_add installed:joomla "$sk_dd" 'ShakeKee Joomla plugin' 'already installed'
    fi
  fi
  if sk_detect_has "$sk_dd/composer.json" '"drupal/core'; then
    sk_df_drupal=1
    sk_detect_add drupal "$sk_dd" 'Drupal project' 'composer.json requires drupal/core'
    if sk_detect_mentions "$sk_dd/composer.json"; then
      sk_detect_add installed:drupal "$sk_dd" 'ShakeKee for Drupal' 'already in composer.json'
    fi
  fi
  if [ -f "$sk_dd/config/defines.inc.php" ]; then
    sk_df_presta=1
    sk_detect_add prestashop "$sk_dd" 'PrestaShop shop' 'config/defines.inc.php found'
    if [ -d "$sk_dd/modules/shakekee" ]; then
      sk_detect_add installed:prestashop "$sk_dd" 'ShakeKee PrestaShop module' 'already installed'
    fi
  fi
  if [ -f "$sk_dd/artisan" ] && sk_detect_has "$sk_dd/composer.json" '"laravel/framework"'; then
    sk_df_laravel=1
    sk_detect_add laravel "$sk_dd" 'Laravel project' 'artisan and laravel/framework found'
    if [ -f "$sk_dd/config/shakekee.php" ] || sk_detect_mentions "$sk_dd/composer.json"; then
      sk_detect_add installed:laravel "$sk_dd" 'ShakeKee for Laravel' 'already installed'
    fi
  fi
  if [ -f "$sk_dd/manage.py" ]; then
    sk_df_django=1
    sk_detect_add django "$sk_dd" 'Django project' 'manage.py found'
    if sk_detect_mentions "$sk_dd/requirements.txt" "$sk_dd/pyproject.toml"; then
      sk_detect_add installed:django "$sk_dd" 'ShakeKee for Django' 'already in the requirements'
    fi
  fi
  if [ -f "$sk_dd/wrangler.toml" ]; then
    sk_df_cf=1
    sk_detect_add cloudflare "$sk_dd" 'Cloudflare Worker' 'wrangler.toml found'
    if [ -d "$sk_dd/shakekee-cloudflare" ]; then
      sk_detect_add installed:cloudflare "$sk_dd" 'ShakeKee Worker' 'already unpacked'
    fi
  fi
  if [ -f "$sk_dd/shopify.app.toml" ]; then
    sk_df_shop=1
    sk_detect_add shopify "$sk_dd" 'Shopify app' 'shopify.app.toml found'
    if [ -d "$sk_dd/shakekee-shopify" ]; then
      sk_detect_add installed:shopify "$sk_dd" 'ShakeKee for Shopify' 'already unpacked'
    fi
  fi
  if [ -f "$sk_dd/package.json" ] && [ "$sk_df_laravel$sk_df_cf$sk_df_shop" = 000 ]; then
    sk_df_node=1
    sk_dnm=$(sed -n 's/^[[:space:]]*"name"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' "$sk_dd/package.json" 2>/dev/null | head -n 1) || sk_dnm=
    sk_detect_add nodejs "$sk_dd" 'Node.js project' "package.json${sk_dnm:+, name $sk_dnm}"
    if sk_detect_mentions "$sk_dd/package.json"; then
      sk_detect_add installed:nodejs "$sk_dd" 'ShakeKee for Node.js' 'already in package.json'
    fi
  fi
  if [ -f "$sk_dd/go.mod" ]; then
    sk_df_go=1
    sk_dgm=$(sed -n 's/^module[[:space:]][[:space:]]*//p' "$sk_dd/go.mod" 2>/dev/null | head -n 1) || sk_dgm=
    sk_detect_add go "$sk_dd" 'Go module' "go.mod${sk_dgm:+, module $sk_dgm}"
    if sk_detect_mentions "$sk_dd/go.mod"; then
      sk_detect_add installed:go "$sk_dd" 'ShakeKee for Go' 'already in go.mod'
    fi
  fi
  if [ -f "$sk_dd/Cargo.toml" ]; then
    sk_df_rust=1
    sk_detect_add rust "$sk_dd" 'Rust crate' 'Cargo.toml found'
    if sk_detect_mentions "$sk_dd/Cargo.toml"; then
      sk_detect_add installed:rust "$sk_dd" 'ShakeKee for Rust' 'already in Cargo.toml'
    fi
  fi
  sk_dproj=
  sk_dprojn=0
  for sk_dp in "$sk_dd"/*.csproj; do
    if [ -f "$sk_dp" ]; then
      sk_dprojn=$((sk_dprojn + 1))
      sk_dproj="${sk_dproj:+$sk_dproj }${sk_dp##*/}"
    fi
  done
  if [ "$sk_dprojn" -gt 0 ]; then
    sk_df_cs=1
    sk_detect_add csharp "$sk_dd" 'C# project' "$sk_dproj"
    for sk_dp in "$sk_dd"/*.csproj; do
      if sk_detect_mentions "$sk_dp"; then
        sk_detect_add installed:csharp "$sk_dd" 'ShakeKee for C#' 'already in the project file'
        break
      fi
    done
  fi
  if [ "$sk_df_wp$sk_df_woo$sk_df_joomla$sk_df_drupal$sk_df_presta$sk_df_laravel" = 000000 ]; then
    if [ -f "$sk_dd/composer.json" ]; then
      sk_df_php=1
      sk_detect_add php "$sk_dd" 'PHP project' 'composer.json found'
    elif [ -f "$sk_dd/index.php" ]; then
      sk_df_php=1
      sk_detect_add php "$sk_dd" 'PHP site' 'index.php found'
    fi
    if [ "$sk_df_php" = 1 ] && sk_detect_mentions "$sk_dd/composer.json"; then
      sk_detect_add installed:php "$sk_dd" 'ShakeKee for PHP' 'already in composer.json'
    fi
  fi
}

# sk_detect_items: all findings, one per line.
sk_detect_items() {
  if [ -z "${SK_FACT_OS+x}" ]; then
    sk_detect_run
  fi
  SK_DET_OUT=
  SK_DET_SEEN=
  sk_dcands=$(sk_detect_candidates)
  while IFS= read -r sk_dcd; do
    sk_detect_dir "$sk_dcd"
  done <<DETECT_DIRS
$sk_dcands
DETECT_DIRS

  # web servers: a binary, or a running process
  sk_dprocs=" $SK_FACT_WEBSERVERS "
  sk_dwebs=0
  for sk_dw in nginx apache caddy; do
    case $sk_dw in
      apache) sk_dbin=$(sk_detect_bin apache2 httpd apache) || sk_dbin= ;;
      *) sk_dbin=$(sk_detect_bin "$sk_dw") || sk_dbin= ;;
    esac
    case $sk_dprocs in
      *" $sk_dw "*) sk_drun=1 ;;
      *) sk_drun=0 ;;
    esac
    if [ -n "$sk_dbin" ] || [ "$sk_drun" = 1 ]; then
      sk_dwebs=1
      if [ "$sk_drun" = 1 ]; then
        sk_dhint='running'
      else
        sk_dhint='installed, not running'
      fi
      sk_detect_add "$sk_dw" "${sk_dbin:--}" "$sk_dw web server" "$sk_dhint"
      case $sk_dw in
        nginx) sk_dmarker=/etc/nginx/shakekee ;;
        apache) sk_dmarker=/etc/shakekee/shakekee.conf ;;
        caddy) sk_dmarker=/etc/caddy/shakekee ;;
      esac
      if [ -e "$sk_dmarker" ]; then
        sk_detect_add "installed:$sk_dw" "$sk_dmarker" "ShakeKee for $sk_dw" 'already installed'
      fi
    fi
  done

  # the daemon: its binary or its install record
  sk_dfound=
  if [ -n "${SK_DETECT_HOMES+x}" ]; then
    set -- $SK_DETECT_HOMES
  else
    set -- "${SK_HOME:-}" "${SHAKEKEE_HOME:-}" "${HOME:-/nonexistent}/.shakekee" /var/lib/shakekeed
  fi
  for sk_dh in "$@"; do
    if [ -n "$sk_dh" ] && { [ -x "$sk_dh/bin/shakekeed" ] || [ -f "$sk_dh/install.json" ]; }; then
      sk_dfound=$sk_dh
      break
    fi
  done
  if [ -z "$sk_dfound" ]; then
    sk_dfound=$(sk_detect_bin shakekeed) || sk_dfound=
  fi
  if [ -n "$sk_dfound" ]; then
    sk_detect_add installed:daemon "$sk_dfound" 'ShakeKee daemon (shakekeed)' 'already installed'
  elif [ "$sk_dwebs" = 1 ]; then
    sk_detect_add daemon - 'ShakeKee daemon (shakekeed)' 'protects the sites of this machine through the web server'
  fi
  if [ -n "$SK_DET_OUT" ]; then
    printf '%s\n' "$SK_DET_OUT"
  fi
}
# ---- server.sh: web servers (nginx, Apache, Caddy): finding them and, only when the person allows it, editing their configuration ----
# POSIX sh; defines functions only. The served scripts of nginx, apache, caddy and the daemon carry it, and so does the hub.
#
#   sk_server_detect              which servers are installed and running: SK_SRV_FOUND, SK_SRV_RUNNING (space separated)
#                                 and SK_SRV_INFO (lines server|binary|control script|main config|config root|running|version)
#   sk_server_select SERVER       sets SK_SRV, SK_SRV_BIN, SK_SRV_CTL, SK_SRV_CONF, SK_SRV_ROOT, SK_SRV_ISRUN (yes|no), SK_SRV_VER,
#                                 SK_SRV_TEST_TEXT and SK_SRV_RELOAD_TEXT (how the server is tested and reloaded); fails when it is not installed
#   sk_server_rule SERVER SITE UPSTREAM
#                                 the daemon rule step (nginx, apache or caddy must pass /.shakekee/ and /.well-known/shakekee/ to
#                                 UPSTREAM, the daemon's public listener, for SITE): shows the exact lines, asks (default: only show),
#                                 and when the person allows it, adds them. SK_SERVER_RULE says what happened: shown, added,
#                                 present (was there already), declined or failed. Fails (1) only for failed; the configuration is then as before.
#   sk_server_remove SERVER SITE  takes back what sk_server_rule / sk_server_wire did for SITE (byte for byte, unless the file was changed since)
#   sk_server_wire SERVER SITE public|full
#                                 the editing engine behind both: "public" is the daemon route, "full" the whole integration of the package
#                                 (nginx, apache or caddy as a guard of the site's locations)
#   sk_server_restore_all [SCOPE] the undo of everything recorded, where the files need sudo (sk_restore_all cannot)
#   sk_server_probe SITE          asks the web server on this machine whether it passes /.shakekee/ to a daemon
#
# Consent. Nothing is edited unless the person says yes: SK_SERVER_EDIT=yes (--edit-server) allows it without a question,
# SK_SERVER_EDIT=no (--no-edit-server) forbids it, otherwise it is asked (a menu whose default is "only show me the lines" and a typed
# yes after a warning), and --yes alone never allows it. A user's file is never rewritten in place: a copy of it is made under
# <home>/backups/, one include line (a Caddy `import`) is inserted in the server block of the site, everything else is
# ShakeKee's own file (<config root>/shakekee-sites/<site>.conf), the server's own test command checks the result, and a
# failed test puts the backup back byte for byte before anything is reloaded.
#
# Privileges. Nothing here runs as root as a whole. A write into a directory this user cannot write goes through sudo, one
# command at a time, each shown before it runs. Files this user can write (Homebrew, a test tree, root running the script)
# are written directly.
#
# Variables: SK_SRV_PUBLIC (127.0.0.1:7879) and SK_SRV_PREFLIGHT (127.0.0.1:7878) are the daemon's addresses;
# SK_SRV_SITESDIR is where the per-site files go (default: <config root>/shakekee-sites, Apache /etc/shakekee/sites);
# SK_SRV_PKGDIR is where the package of the integration is installed (the recipe sets it); SK_CADDYFILE names the Caddyfile
# (default /etc/caddy/Caddyfile). Tests use SK_DETECT_PROCS and SK_DETECT_BINDIRS like detect.sh.
#
# Facts in install.json: server.<server>.<site> = kind|edited file|per-site file|backup key, kind public (the daemon's lines), full
# (the package's) or both; the pseudo site _global_ is the once-per-server include of the package (nginx http { }, the Caddyfile's
# import). The backup of an edit of the person's file is recorded as wiring.NNNN, not backup.NNNN, and the per-site files with the
# scope "server": the generic undo of one integration (sk_restore_all) must not take one include away while another needs it, so
# only sk_server_remove and the rollback undo them. Codes: SK-E601 to SK-E609 and SK-E611.
# Scratch variables are sk_sv_*, results SK_SRV_*; the text of a daemon route is kept in step with
# edge/shakekeed/integrations/* by a test.

# ---- small things ----

# sk__srv_init: defaults and a working directory (inside the temporary directory, which is removed on exit)
sk__srv_init() {
  : "${SK_SRV_PUBLIC:=127.0.0.1:7879}"
  : "${SK_SRV_PREFLIGHT:=127.0.0.1:7878}"
  : "${SK_SRV_PRIV:=0}"
  : "${SK_SRV_PRIMED:=0}"
  # the scripts hold no literal plain-http URL (a test checks that); the daemon's address is a loopback one
  SK_SRV_HTTP='http:'
  if [ -z "${SK_SRV_W:-}" ] || [ ! -d "$SK_SRV_W" ]; then
    if [ -z "$SK_TASK" ]; then
      SK_TASK=$(mktemp -d "${TMPDIR:-/tmp}/shakekee-install.XXXXXX") ||
        sk_fail 1 SK-E006 "Cannot create a temporary directory." "Check that ${TMPDIR:-/tmp} is writable."
    fi
    SK_SRV_W=$SK_TASK/server
    mkdir -p "$SK_SRV_W" || sk_fail 1 SK-E006 "Cannot create a temporary directory." "Check that ${TMPDIR:-/tmp} is writable."
    SK_SRV_OUTF=$SK_SRV_W/out
    SK_SRV_N=0
  fi
}

# sk__srv_tmp: SK_SRV_T is a new name inside the working directory
sk__srv_tmp() {
  SK_SRV_N=$((SK_SRV_N + 1))
  SK_SRV_T=$SK_SRV_W/t$SK_SRV_N
}

# sk__srv_site_ok NAME: a host name (letters, digits, dots, hyphens; no wildcard)
sk__srv_site_ok() {
  case $1 in
    '' | .* | -* | *. | *..* | *[!A-Za-z0-9.-]*) return 1 ;;
  esac
  [ "${#1}" -le 253 ]
}

# sk__srv_addr_ok HOST:PORT
sk__srv_addr_ok() {
  case $1 in
    '' | *[!A-Za-z0-9.:-]* | :* | *: | *:*:*) return 1 ;;
    *:*) ;;
    *) return 1 ;;
  esac
  sk_sv_ao=${1##*:}
  case $sk_sv_ao in
    '' | *[!0-9]*) return 1 ;;
  esac
  [ "$sk_sv_ao" -ge 1 ] && [ "$sk_sv_ao" -le 65535 ]
}

# sk__srv_head FILE [N]: the first N (default 6) lines of a file, for an error message
sk__srv_head() {
  sed -n "1,${2:-6}p" "$1" 2>/dev/null | cut -c1-200
}

# sk__srv_sedq TEXT: TEXT, written so that an s||| command of sed takes it literally in its replacement
sk__srv_sedq() {
  printf '%s' "$1" | sed -e 's/[\\&|]/\\&/g'
}

# ---- finding the servers ----

# sk__srv_procs: the web servers among the running processes, one per line (nginx, apache, caddy)
sk__srv_procs() {
  sk_sv_pr_all=
  if [ -n "${SK_DETECT_PROCS+x}" ]; then
    sk_sv_pr_all=$(printf '%s\n' $SK_DETECT_PROCS)
  elif [ -d /proc/self ]; then
    for sk_sv_pr_p in /proc/[0-9]*/comm; do
      if [ -r "$sk_sv_pr_p" ]; then
        sk_sv_pr_n=
        read -r sk_sv_pr_n <"$sk_sv_pr_p" 2>/dev/null || true
        sk_sv_pr_all="$sk_sv_pr_all$UI_NL$sk_sv_pr_n"
      fi
    done
  else
    sk_sv_pr_all=$(ps -A -o comm= 2>/dev/null || ps -e -o comm= 2>/dev/null || ps -o comm= 2>/dev/null || true)
  fi
  while IFS= read -r sk_sv_pr_n; do
    sk_sv_pr_n=${sk_sv_pr_n##*/}
    case $sk_sv_pr_n in
      nginx) printf '%s\n' nginx ;;
      apache2 | apache | httpd) printf '%s\n' apache ;;
      caddy) printf '%s\n' caddy ;;
    esac
  done <<SK_SRV_PROCS
$sk_sv_pr_all
SK_SRV_PROCS
}

# sk__srv_bin NAME...: the path of the first of the programs found in PATH or the usual sbin directories
sk__srv_bin() {
  if [ -n "${SK_DETECT_BINDIRS+x}" ]; then
    sk_sv_bi_dirs=$SK_DETECT_BINDIRS
  else
    sk_sv_bi_dirs="$(printf '%s' "${PATH:-}" | tr ':' ' ') /usr/sbin /usr/local/sbin /sbin /usr/local/bin /opt/homebrew/bin /usr/local/nginx/sbin /usr/local/apache2/bin"
  fi
  for sk_sv_bi_n in "$@"; do
    for sk_sv_bi_d in $sk_sv_bi_dirs; do
      if [ -f "$sk_sv_bi_d/$sk_sv_bi_n" ] && [ -x "$sk_sv_bi_d/$sk_sv_bi_n" ]; then
        printf '%s\n' "$sk_sv_bi_d/$sk_sv_bi_n"
        return 0
      fi
    done
  done
  return 1
}

# sk__srv_probe NAME RUNNING: sets sk_sv_pb_line (server|binary|control script|main config|config root|running|version);
# fails when the server is not installed. Only asks the program for its version and where its configuration is.
sk__srv_probe() {
  sk_sv_pb_ctl=
  sk_sv_pb_ver=
  sk_sv_pb_conf=
  sk_sv_pb_root=
  case $1 in
    nginx)
      sk_sv_pb_bin=$(sk__srv_bin nginx) || return 1
      sk_sv_pb_out=$("$sk_sv_pb_bin" -V 2>&1 </dev/null) || true
      sk_sv_pb_ver=$(printf '%s\n' "$sk_sv_pb_out" | sed -n 's|^nginx version: nginx/\([^ ]*\).*|\1|p' | head -n 1)
      sk_sv_pb_conf=$(printf '%s\n' "$sk_sv_pb_out" | tr ' ' '\n' | sed -n 's/^--conf-path=//p' | head -n 1)
      if [ -z "$sk_sv_pb_conf" ]; then
        for sk_sv_pb_c in /etc/nginx/nginx.conf /usr/local/etc/nginx/nginx.conf /opt/homebrew/etc/nginx/nginx.conf /usr/local/nginx/conf/nginx.conf; do
          if [ -f "$sk_sv_pb_c" ]; then
            sk_sv_pb_conf=$sk_sv_pb_c
            break
          fi
        done
      fi
      sk_sv_pb_root=${sk_sv_pb_conf%/*}
      ;;
    apache)
      sk_sv_pb_ctl=$(sk__srv_bin apache2ctl apachectl) || sk_sv_pb_ctl=
      sk_sv_pb_bin=$(sk__srv_bin apache2 httpd apache) || sk_sv_pb_bin=
      if [ -z "$sk_sv_pb_ctl$sk_sv_pb_bin" ]; then
        return 1
      fi
      # -V prints the compiled-in root and main file; it may also complain about the configuration (and exit 1): ignored
      sk_sv_pb_out=$("${sk_sv_pb_bin:-$sk_sv_pb_ctl}" -V 2>&1 </dev/null) || true
      sk_sv_pb_ver=$(printf '%s\n' "$sk_sv_pb_out" | sed -n 's|^Server version: Apache/\([^ ]*\).*|\1|p' | head -n 1)
      sk_sv_pb_root=$(printf '%s\n' "$sk_sv_pb_out" | sed -n 's/^ *-D HTTPD_ROOT="\(.*\)".*/\1/p' | head -n 1)
      sk_sv_pb_conf=$(printf '%s\n' "$sk_sv_pb_out" | sed -n 's/^ *-D SERVER_CONFIG_FILE="\(.*\)".*/\1/p' | head -n 1)
      if [ -z "$sk_sv_pb_root" ]; then
        for sk_sv_pb_c in /etc/apache2 /etc/httpd /usr/local/apache2 /opt/homebrew/etc/httpd; do
          if [ -d "$sk_sv_pb_c" ]; then
            sk_sv_pb_root=$sk_sv_pb_c
            break
          fi
        done
      fi
      if [ -z "$sk_sv_pb_conf" ]; then
        for sk_sv_pb_c in apache2.conf conf/httpd.conf httpd.conf; do
          if [ -f "$sk_sv_pb_root/$sk_sv_pb_c" ]; then
            sk_sv_pb_conf=$sk_sv_pb_c
            break
          fi
        done
      fi
      case $sk_sv_pb_conf in
        '' | /*) ;;
        *) sk_sv_pb_conf=$sk_sv_pb_root/$sk_sv_pb_conf ;;
      esac
      ;;
    caddy)
      sk_sv_pb_bin=$(sk__srv_bin caddy) || return 1
      sk_sv_pb_out=$("$sk_sv_pb_bin" version 2>&1 </dev/null) || true
      sk_sv_pb_ver=$(printf '%s\n' "$sk_sv_pb_out" | sed -n 's/^v\{0,1\}\([0-9][^ ]*\).*/\1/p' | head -n 1)
      sk_sv_pb_conf=${SK_CADDYFILE:-/etc/caddy/Caddyfile}
      sk_sv_pb_root=${sk_sv_pb_conf%/*}
      ;;
    *) return 1 ;;
  esac
  sk_sv_pb_line="$1|$sk_sv_pb_bin|$sk_sv_pb_ctl|$sk_sv_pb_conf|$sk_sv_pb_root|$2|$sk_sv_pb_ver"
}

# sk_server_detect: see the top of this file.
sk_server_detect() {
  sk__srv_init
  SK_SRV_FOUND=
  SK_SRV_RUNNING=
  SK_SRV_INFO=
  sk_sv_dt_procs=$(sk__srv_procs)
  for sk_sv_dt_s in nginx apache caddy; do
    sk_sv_dt_run=no
    case "$UI_NL$sk_sv_dt_procs$UI_NL" in
      *"$UI_NL$sk_sv_dt_s$UI_NL"*)
        sk_sv_dt_run=yes
        SK_SRV_RUNNING="${SK_SRV_RUNNING:+$SK_SRV_RUNNING }$sk_sv_dt_s"
        ;;
    esac
    if sk__srv_probe "$sk_sv_dt_s" "$sk_sv_dt_run"; then
      SK_SRV_FOUND="${SK_SRV_FOUND:+$SK_SRV_FOUND }$sk_sv_dt_s"
      SK_SRV_INFO="${SK_SRV_INFO:+$SK_SRV_INFO$UI_NL}$sk_sv_pb_line"
    fi
  done
  return 0
}

# sk_server_select SERVER: see the top of this file.
sk_server_select() {
  case ${1:-} in
    nginx | apache | caddy) ;;
    *) sk_fail 1 SK-E601 "Unknown web server: ${1:-}." "The installer knows nginx, apache and caddy." ;;
  esac
  sk__srv_init
  if [ -z "${SK_SRV_INFO+x}" ]; then
    sk_server_detect
  fi
  sk_sv_se_line=
  while IFS= read -r sk_sv_se_l; do
    case $sk_sv_se_l in
      "$1|"*) sk_sv_se_line=$sk_sv_se_l ;;
    esac
  done <<SK_SRV_SEL
$SK_SRV_INFO
SK_SRV_SEL
  if [ -z "$sk_sv_se_line" ]; then
    return 1
  fi
  IFS='|' read -r SK_SRV SK_SRV_BIN SK_SRV_CTL SK_SRV_CONF SK_SRV_ROOT SK_SRV_ISRUN SK_SRV_VER <<SK_SRV_SEL
$sk_sv_se_line
SK_SRV_SEL
  case $SK_SRV in
    nginx)
      SK_SRV_TEST_TEXT="nginx -t"
      SK_SRV_RELOAD_TEXT="nginx -s reload"
      ;;
    apache)
      sk_sv_se_c=${SK_SRV_CTL:-$SK_SRV_BIN}
      SK_SRV_TEST_TEXT="${sk_sv_se_c##*/} configtest"
      SK_SRV_RELOAD_TEXT="${sk_sv_se_c##*/} graceful"
      ;;
    caddy)
      SK_SRV_TEST_TEXT="caddy validate --config $SK_SRV_CONF --adapter caddyfile"
      SK_SRV_RELOAD_TEXT="caddy reload --config $SK_SRV_CONF --adapter caddyfile"
      ;;
  esac
  return 0
}

# ---- the configuration files of the selected server ----

# sk__srv_includes FILE: the include patterns of a configuration file, one per line
sk__srv_includes() {
  case $SK_SRV in
    nginx) sed -e 's/#.*//' "$1" 2>/dev/null | sed -n 's/^[[:space:]]*include[[:space:]][[:space:]]*\([^;]*\);.*/\1/p' ;;
    apache) sed -n 's/^[[:space:]]*[Ii]nclude\([Oo]ptional\)\{0,1\}[[:space:]][[:space:]]*\([^#]*\)$/\2/p' "$1" 2>/dev/null ;;
    caddy) sed -n 's/^[[:space:]]*import[[:space:]][[:space:]]*\([^[:space:]]*\).*/\1/p' "$1" 2>/dev/null | grep -E '[/*]' || true ;;
  esac | sed -e 's/[[:space:]]*$//' -e 's/^"\(.*\)"$/\1/' -e "s/^'\(.*\)'\$/\1/"
}

# sk__srv_files: SK_SRV_FILELIST is the main configuration file and every file it includes (up to 300), one per line;
# SK_SRV_UNREAD lists the ones this user cannot read
sk__srv_files() {
  SK_SRV_FILELIST=
  SK_SRV_UNREAD=
  if [ -z "$SK_SRV_CONF" ] || [ ! -f "$SK_SRV_CONF" ] || [ ! -r "$SK_SRV_CONF" ]; then
    return 1
  fi
  SK_SRV_FILELIST=$SK_SRV_CONF
  sk_sv_fl_i=0
  while :; do
    sk_sv_fl_i=$((sk_sv_fl_i + 1))
    if [ "$sk_sv_fl_i" -gt 300 ]; then
      break
    fi
    sk_sv_fl_f=$(printf '%s\n' "$SK_SRV_FILELIST" | sed -n "${sk_sv_fl_i}p")
    if [ -z "$sk_sv_fl_f" ]; then
      break
    fi
    if [ ! -f "$sk_sv_fl_f" ]; then
      continue
    fi
    if [ ! -r "$sk_sv_fl_f" ]; then
      SK_SRV_UNREAD="${SK_SRV_UNREAD:+$SK_SRV_UNREAD }$sk_sv_fl_f"
      continue
    fi
    sk_sv_fl_pats=$(sk__srv_includes "$sk_sv_fl_f")
    while IFS= read -r sk_sv_fl_p; do
      if [ -z "$sk_sv_fl_p" ]; then
        continue
      fi
      case $sk_sv_fl_p in
        /*) ;;
        *)
          if [ "$SK_SRV" = caddy ]; then
            sk_sv_fl_p=${sk_sv_fl_f%/*}/$sk_sv_fl_p
          else
            sk_sv_fl_p=$SK_SRV_ROOT/$sk_sv_fl_p
          fi
          ;;
      esac
      case $sk_sv_fl_p in
        *' '* | *'|'*) continue ;;
      esac
      for sk_sv_fl_g in $sk_sv_fl_p; do
        if [ -f "$sk_sv_fl_g" ]; then
          case "$UI_NL$SK_SRV_FILELIST$UI_NL" in
            *"$UI_NL$sk_sv_fl_g$UI_NL"*) ;;
            *) SK_SRV_FILELIST=$SK_SRV_FILELIST$UI_NL$sk_sv_fl_g ;;
          esac
        fi
      done
    done <<SK_SRV_FL
$sk_sv_fl_pats
SK_SRV_FL
  done
  return 0
}

# The awk programs. Each finds the blocks of one kind of server and prints a line per block:
#   START-LINE|SSL|REDIRECT|NAMES     (SSL 1: serves https; REDIRECT 1: only redirects; NAMES: space separated)
# They read text outside quotes and comments only, so a brace in a string does not count.

sk__srv_awk_common() {
  cat <<'SK_AWK'
function strip(s,   i, j, n, c, q, out) {
  out = ""; q = ""; n = length(s)
  for (i = 1; i <= n; i++) {
    c = substr(s, i, 1)
    if (q != "") { if (c == q) q = ""; continue }
    if (c == "\"" || c == "\047") { q = c; out = out "\"\""; continue }
    if (c == "#") break
    if (c == "$" && substr(s, i + 1, 1) == "{") {
      j = index(substr(s, i), "}")
      if (j == 0) j = n - i + 1
      i = i + j - 1
      out = out "v"
      continue
    }
    out = out c
  }
  return out
}
function squeeze(s) {
  gsub(/[ \t]+/, " ", s); sub(/^ /, "", s); sub(/ $/, "", s)
  return s
}
SK_AWK
}

sk__srv_awk_nginx() {
  cat <<'SK_AWK'
BEGIN { depth = 0; inblock = 0; col = 0 }
{
  plain = strip($0)
  t = plain; no = gsub(/\{/, "", t)
  t = plain; nc = gsub(/\}/, "", t)
  if (!inblock && plain ~ /^[ \t]*server[ \t]*\{[ \t]*$/) {
    inblock = 1; bdepth = depth; start = NR; names = ""; ssl = 0; redir = 0; col = 0
  } else if (inblock && depth == bdepth + 1) {
    if (plain ~ /^[ \t]*server_name([ \t]|$)/) col = 1
    if (col) { names = names " " plain; if (plain ~ /;/) col = 0 }
    if (plain ~ /^[ \t]*listen[ \t]/ && (plain ~ /[ \t]ssl([ \t;]|$)/ || plain ~ /(^|[ \t:])443([ \t;]|$)/)) ssl = 1
    if (plain ~ /^[ \t]*return[ \t]+30[0-9]([ \t;]|$)/) redir = 1
  }
  depth += no - nc
  if (inblock && depth <= bdepth) {
    inblock = 0
    gsub(/server_name|;/, " ", names)
    print start "|" ssl "|" redir "|" squeeze(names)
  }
}
SK_AWK
}

sk__srv_awk_apache() {
  cat <<'SK_AWK'
BEGIN { inblock = 0 }
{
  plain = strip($0)
  if (!inblock && plain ~ /^[ \t]*<[Vv][Ii][Rr][Tt][Uu][Aa][Ll][Hh][Oo][Ss][Tt][ \t>]/) {
    inblock = 1; start = NR; names = ""; ssl = 0; redir = 0
    if (plain ~ /:443[ \t>]/) ssl = 1
  } else if (inblock) {
    if (plain ~ /^[ \t]*<\/[Vv][Ii][Rr][Tt][Uu][Aa][Ll][Hh][Oo][Ss][Tt]>/) {
      inblock = 0
      gsub(/[Ss][Ee][Rr][Vv][Ee][Rr][Nn][Aa][Mm][Ee]|[Ss][Ee][Rr][Vv][Ee][Rr][Aa][Ll][Ii][Aa][Ss]/, " ", names)
      gsub(/:[0-9]+/, "", names)
      print start "|" ssl "|" redir "|" squeeze(names)
    } else {
      if (plain ~ /^[ \t]*[Ss][Ee][Rr][Vv][Ee][Rr][Nn][Aa][Mm][Ee][ \t]/ || plain ~ /^[ \t]*[Ss][Ee][Rr][Vv][Ee][Rr][Aa][Ll][Ii][Aa][Ss][ \t]/) names = names " " plain
      if (plain ~ /^[ \t]*[Ss][Ss][Ll][Ee][Nn][Gg][Ii][Nn][Ee][ \t]+[Oo][Nn]/) ssl = 1
      if (plain ~ /^[ \t]*[Rr][Ee][Dd][Ii][Rr][Ee][Cc][Tt]([Mm][Aa][Tt][Cc][Hh])?[ \t]/) redir = 1
    }
  }
}
SK_AWK
}

sk__srv_awk_caddy() {
  cat <<'SK_AWK'
BEGIN { depth = 0; inblock = 0 }
{
  plain = strip($0)
  t = plain; no = gsub(/\{/, "", t)
  t = plain; nc = gsub(/\}/, "", t)
  if (depth == 0 && !inblock && plain ~ /[^ \t].*\{[ \t]*$/ && plain !~ /^[ \t]*\(/) {
    labels = plain
    sub(/[ \t]*\{[ \t]*$/, "", labels)
    inblock = 1; start = NR; ssl = 1; redir = 0
    n = split(labels, parts, /[ \t,]+/)
    names = ""
    for (k = 1; k <= n; k++) {
      p = parts[k]
      if (p == "") continue
      if (p ~ /^http:\/\//) ssl = 0
      sub(/^https?:\/\//, "", p)
      sub(/\/.*$/, "", p)
      sub(/:[0-9]+$/, "", p)
      names = names " " p
    }
  } else if (inblock && depth == 1) {
    if (plain ~ /^[ \t]*redir[ \t]/) redir = 1
  }
  depth += no - nc
  if (inblock && depth <= 0) {
    inblock = 0
    print start "|" ssl "|" redir "|" squeeze(names)
  }
}
SK_AWK
}

# sk__srv_scan FILE: the blocks of FILE (see above)
sk__srv_scan() {
  case $SK_SRV in
    nginx) sk_sv_sc_prog="$(sk__srv_awk_common)$UI_NL$(sk__srv_awk_nginx)" ;;
    apache) sk_sv_sc_prog="$(sk__srv_awk_common)$UI_NL$(sk__srv_awk_apache)" ;;
    caddy) sk_sv_sc_prog="$(sk__srv_awk_common)$UI_NL$(sk__srv_awk_caddy)" ;;
  esac
  awk "$sk_sv_sc_prog" "$1" 2>/dev/null
}

# sk__srv_blocks SITE: SK_SRV_BLOCKS has a line FILE|START|SSL|REDIRECT|FIRST-NAME for every block that names SITE.
# Fails when the main configuration cannot be read.
sk__srv_blocks() {
  SK_SRV_BLOCKS=
  sk__srv_files || return 1
  sk_sv_bl_site=$(printf '%s' "$1" | tr 'A-Z' 'a-z')
  while IFS= read -r sk_sv_bl_f; do
    case $sk_sv_bl_f in
      '' | *'|'*) continue ;;
    esac
    sk_sv_bl_out=$(sk__srv_scan "$sk_sv_bl_f") || sk_sv_bl_out=
    while IFS='|' read -r sk_sv_bl_s sk_sv_bl_ssl sk_sv_bl_r sk_sv_bl_names; do
      if [ -z "$sk_sv_bl_s" ]; then
        continue
      fi
      sk_sv_bl_names=$(printf '%s' "$sk_sv_bl_names" | tr 'A-Z' 'a-z')
      case " $sk_sv_bl_names " in
        *" $sk_sv_bl_site "*) SK_SRV_BLOCKS="${SK_SRV_BLOCKS:+$SK_SRV_BLOCKS$UI_NL}$sk_sv_bl_f|$sk_sv_bl_s|$sk_sv_bl_ssl|$sk_sv_bl_r|${sk_sv_bl_names%% *}" ;;
      esac
    done <<SK_SRV_BL
$sk_sv_bl_out
SK_SRV_BL
  done <<SK_SRV_BL
$SK_SRV_FILELIST
SK_SRV_BL
  return 0
}

# sk__srv_pick: from SK_SRV_BLOCKS, the file to edit (SK_SRV_TARGET) and the lines of the blocks in it (SK_SRV_LINES):
# the blocks that serve https; else the ones that do not only redirect; else all of them. Fails when there is none.
# SK_SRV_FIRST is the first name of the first of them (the one nginx and Apache judge a request by).
sk__srv_pick() {
  SK_SRV_TARGET=
  SK_SRV_LINES=
  SK_SRV_FIRST=
  if [ -z "$SK_SRV_BLOCKS" ]; then
    return 1
  fi
  for sk_sv_pk_pass in ssl plain all; do
    sk_sv_pk_file=
    sk_sv_pk_lines=
    while IFS='|' read -r sk_sv_pk_f sk_sv_pk_s sk_sv_pk_ssl sk_sv_pk_r sk_sv_pk_first; do
      case $sk_sv_pk_pass in
        ssl)
          if [ "$sk_sv_pk_ssl" != 1 ]; then
            continue
          fi
          ;;
        plain)
          if [ "$sk_sv_pk_r" != 0 ]; then
            continue
          fi
          ;;
      esac
      if [ -z "$sk_sv_pk_file" ]; then
        sk_sv_pk_file=$sk_sv_pk_f
        SK_SRV_FIRST=$sk_sv_pk_first
      fi
      if [ "$sk_sv_pk_f" = "$sk_sv_pk_file" ]; then
        sk_sv_pk_lines="${sk_sv_pk_lines:+$sk_sv_pk_lines }$sk_sv_pk_s"
      fi
    done <<SK_SRV_PK
$SK_SRV_BLOCKS
SK_SRV_PK
    if [ -n "$sk_sv_pk_file" ]; then
      SK_SRV_TARGET=$sk_sv_pk_file
      SK_SRV_LINES=$sk_sv_pk_lines
      return 0
    fi
  done
  return 1
}

# sk_server_names: the host names the selected server's configuration serves, one per line, no duplicates
# (wildcards, regular expressions and the catch-all _ are left out). For a menu of sites.
sk_server_names() {
  sk__srv_files || return 0
  sk_sv_nm_all=
  while IFS= read -r sk_sv_nm_f; do
    case $sk_sv_nm_f in
      '' | *'|'*) continue ;;
    esac
    sk_sv_nm_out=$(sk__srv_scan "$sk_sv_nm_f") || sk_sv_nm_out=
    while IFS='|' read -r sk_sv_nm_s sk_sv_nm_ssl sk_sv_nm_r sk_sv_nm_names; do
      for sk_sv_nm_n in $sk_sv_nm_names; do
        sk_sv_nm_n=$(printf '%s' "$sk_sv_nm_n" | tr 'A-Z' 'a-z')
        if sk__srv_site_ok "$sk_sv_nm_n"; then
          case "$UI_NL$sk_sv_nm_all$UI_NL" in
            *"$UI_NL$sk_sv_nm_n$UI_NL"*) ;;
            *) sk_sv_nm_all="${sk_sv_nm_all:+$sk_sv_nm_all$UI_NL}$sk_sv_nm_n" ;;
          esac
        fi
      done
    done <<SK_SRV_NM
$sk_sv_nm_out
SK_SRV_NM
  done <<SK_SRV_NM
$SK_SRV_FILELIST
SK_SRV_NM
  if [ -n "$sk_sv_nm_all" ]; then
    printf '%s\n' "$sk_sv_nm_all"
  fi
}

# sk__srv_real FILE: SK_SRV_REAL is FILE with symbolic links resolved. Fails unless that is a regular file.
sk__srv_real() {
  SK_SRV_REAL=$1
  if [ -L "$1" ]; then
    if sk_sv_rl=$(readlink -f "$1" 2>/dev/null) && [ -n "$sk_sv_rl" ]; then
      SK_SRV_REAL=$sk_sv_rl
    else
      sk_sv_rl=$1
      sk_sv_rn=0
      while [ -L "$sk_sv_rl" ] && [ "$sk_sv_rn" -lt 20 ]; do
        sk_sv_rt=$(readlink "$sk_sv_rl") || return 1
        case $sk_sv_rt in
          /*) sk_sv_rl=$sk_sv_rt ;;
          *) sk_sv_rl=${sk_sv_rl%/*}/$sk_sv_rt ;;
        esac
        sk_sv_rn=$((sk_sv_rn + 1))
      done
      SK_SRV_REAL=$sk_sv_rl
    fi
  fi
  [ -f "$SK_SRV_REAL" ] && [ ! -L "$SK_SRV_REAL" ]
}

# ---- what goes into the files ----

# sk__srv_tpl SERVER: the daemon route for one site as the server's own syntax, with @PASS@ for the daemon's address
# as a URL and @UP@ for the address alone. The same text is in edge/shakekeed/integrations/* (a test compares them).
sk__srv_tpl() {
  case $1 in
    nginx)
      cat <<'SK_TPL'
location ^~ /.shakekee/ {
    proxy_pass @PASS@;
    proxy_http_version 1.1;
    proxy_set_header Connection "";
    proxy_buffering off;
    proxy_cache off;
    proxy_read_timeout 1h;
    client_max_body_size 256k;
    proxy_set_header Host $http_host;
    proxy_set_header X-SK-Secret "";
    proxy_set_header X-Forwarded-For $remote_addr;
    proxy_set_header X-Forwarded-Host $host;
    proxy_set_header X-Forwarded-Proto $scheme;
    proxy_set_header Forwarded "";
}

location ^~ /.well-known/shakekee/ {
    proxy_pass @PASS@;
    proxy_set_header Host $host;
    proxy_set_header X-SK-Secret "";
    proxy_set_header X-Forwarded-For $remote_addr;
    proxy_set_header Forwarded "";
}
SK_TPL
      ;;
    apache)
      cat <<'SK_TPL'
ProxyPass "/.shakekee/" "@PASS@/.shakekee/" timeout=3600
ProxyPass "/.well-known/shakekee/" "@PASS@/.well-known/shakekee/"
<LocationMatch "^/(\.shakekee/|\.well-known/shakekee/)">
    ProxyPreserveHost On
    RequestHeader unset X-SK-Secret
    RequestHeader unset X-Forwarded-For
    RequestHeader unset X-Forwarded-Host
    RequestHeader unset X-Forwarded-Server
    RequestHeader unset Forwarded
</LocationMatch>
SK_TPL
      ;;
    caddy)
      cat <<'SK_TPL'
@shakekee_daemon path /.shakekee/* /.well-known/shakekee/*
handle @shakekee_daemon {
	reverse_proxy @UP@ {
		flush_interval -1
		header_up Host {hostport}
		header_up -X-SK-Secret
		header_up -Forwarded
	}
}
SK_TPL
      ;;
  esac
}

# sk__srv_pkgdir SERVER: SK_SRV_PKGDIR, where the package of the integration is installed: what the recipe set, else where an
# earlier installation recorded it (nginx.dir, apache.etc, caddy.dir), else the standard place
sk__srv_pkgdir() {
  if [ -z "${SK_SRV_PKGDIR:-}" ]; then
    case $1 in
      nginx) if sk_rec_get nginx.dir && [ -n "$SK_RV" ]; then SK_SRV_PKGDIR=$SK_RV; else SK_SRV_PKGDIR=/etc/nginx/shakekee; fi ;;
      apache) if sk_rec_get apache.etc && [ -n "$SK_RV" ]; then SK_SRV_PKGDIR=$SK_RV; else SK_SRV_PKGDIR=/etc/shakekee; fi ;;
      caddy) if sk_rec_get caddy.dir && [ -n "$SK_RV" ]; then SK_SRV_PKGDIR=$SK_RV; else SK_SRV_PKGDIR=/etc/caddy/shakekee; fi ;;
    esac
  fi
}

# sk__srv_layout SERVER SITE KIND: SK_SRV_SITEFILE (the per-site file), SK_SRV_INCLUDE (the line that goes into the
# user's server block), SK_SRV_BODY (a file with the text of the per-site file; the working directory holds it)
sk__srv_layout() {
  sk_sv_ly_dir=${SK_SRV_SITESDIR:-}
  if [ -z "$sk_sv_ly_dir" ]; then
    case $1 in
      apache) sk_sv_ly_dir=/etc/shakekee/sites ;;
      *) sk_sv_ly_dir=$SK_SRV_ROOT/shakekee-sites ;;
    esac
  fi
  case $1 in
    caddy) SK_SRV_SITEFILE=$sk_sv_ly_dir/$2.Caddyfile ;;
    *) SK_SRV_SITEFILE=$sk_sv_ly_dir/$2.conf ;;
  esac
  case $1 in
    nginx) SK_SRV_INCLUDE="include $SK_SRV_SITEFILE;" ;;
    apache) SK_SRV_INCLUDE="Include $SK_SRV_SITEFILE" ;;
    caddy) SK_SRV_INCLUDE="import $SK_SRV_SITEFILE" ;;
  esac
  sk__srv_pkgdir "$1"
  # these paths are written into the person's configuration: only characters that need no quoting there
  for sk_sv_ly_p in "$SK_SRV_SITEFILE" "$SK_SRV_PKGDIR"; do
    case $sk_sv_ly_p in
      '' | *[!A-Za-z0-9/._+@%=,:~-]*)
        sk_fail 1 SK-E602 "The path $sk_sv_ly_p has a character the installer does not write into a web server configuration (a space, a quote, a backslash...)." "Use a directory whose path has only letters, digits and / . _ + - (SK_SRV_SITESDIR, --dir)."
        ;;
    esac
  done
  sk__srv_tmp
  SK_SRV_BODY=$SK_SRV_T
  {
    printf '%s\n' "# ShakeKee: written by the ShakeKee installer for $2 (kind: $3). Remove it with the installer's --uninstall."
    if [ "$3" = full ]; then
      case $1 in
        nginx) printf '%s\n' "include $SK_SRV_PKGDIR/shakekee-server.conf;" ;;
        apache) printf '%s\n' "Include $SK_SRV_PKGDIR/shakekee.conf" ;;
        caddy) printf '%s\n' "import shakekee_public" ;;
      esac
    else
      sk__srv_tpl "$1" | sed -e "s|@PASS@|${SK_SRV_HTTP}//$SK_SRV_PUBLIC|g" -e "s|@UP@|$SK_SRV_PUBLIC|g"
    fi
  } >"$SK_SRV_BODY"
}

# the text a person adds by hand, for the rule step: SERVER prints it with the daemon's address filled in
sk__srv_manual() {
  sk__srv_tpl "$1" | sed -e "s|@PASS@|${SK_SRV_HTTP}//$2|g" -e "s|@UP@|$2|g"
}

# ---- doing things as root, one command at a time ----

# sk__srv_priv_for PATH: SK_SRV_PRIV is 1 when the nearest existing directory above PATH cannot be written by this user
sk__srv_priv_for() {
  sk_sv_pf=${1%/*}
  if [ -z "$sk_sv_pf" ]; then
    sk_sv_pf=/
  fi
  while [ ! -d "$sk_sv_pf" ] && [ "$sk_sv_pf" != / ]; do
    sk_sv_pf=${sk_sv_pf%/*}
    if [ -z "$sk_sv_pf" ]; then
      sk_sv_pf=/
    fi
  done
  if [ -w "$sk_sv_pf" ] && [ -x "$sk_sv_pf" ]; then
    SK_SRV_PRIV=0
  else
    SK_SRV_PRIV=1
  fi
}

# sk__srv_prime: asks for the sudo password once, before the first command that needs it (so that the prompt does not
# land in the middle of a step line)
sk__srv_prime() {
  if [ "$SK_SRV_PRIMED" = 1 ]; then
    return 0
  fi
  if ! command -v sudo >/dev/null 2>&1; then
    sk__report 1 SK-E005 "This step needs administrator rights and sudo is not installed." "Run the installer as root, or install sudo."
    return 1
  fi
  if ! command sudo -n true </dev/null >/dev/null 2>&1; then
    ui_say "  \$ sudo -v"
    sk_log "\$ sudo -v"
    if ! command sudo -v; then
      sk__report 1 SK-E608 "sudo did not accept the password, or cannot ask for one here." "Run the installer from a terminal where sudo works, or as root. Nothing was changed."
      return 1
    fi
  fi
  SK_SRV_PRIMED=1
}

# sk__srv_sudo COMMAND...: runs the command as root when SK_SRV_PRIV is 1 (and this user is not root), showing it first;
# otherwise as this user. Its output goes to SK_SRV_OUTF and to the log (not with SK_SRV_QUIET=1). Returns its status.
sk__srv_sudo() {
  sk__srv_init
  : >"$SK_SRV_OUTF"
  sk_quote_args "$@"
  if [ "$SK_SRV_PRIV" = 1 ] && ! sk_is_root; then
    sk__srv_prime || return 1
    ui_say "  \$ sudo $SK_QA"
    sk_log "\$ sudo $SK_QA"
    if command sudo "$@" </dev/null >"$SK_SRV_OUTF" 2>&1; then
      sk_sv_su_rc=0
    else
      sk_sv_su_rc=$?
    fi
  else
    sk_log "\$ $SK_QA"
    if "$@" </dev/null >"$SK_SRV_OUTF" 2>&1; then
      sk_sv_su_rc=0
    else
      sk_sv_su_rc=$?
    fi
  fi
  if [ "${SK_SRV_QUIET:-0}" != 1 ] && [ -n "$SK_LOG" ] && [ "$SK_LOG" != /dev/null ]; then
    while IFS= read -r sk_sv_su_l; do
      sk_log "  $sk_sv_su_l"
    done <"$SK_SRV_OUTF"
  fi
  return "$sk_sv_su_rc"
}

# sk__srv_owner FILE: SK_SRV_UID and SK_SRV_GID (numbers)
sk__srv_owner() {
  sk_sv_ow=$(ls -ldn "$1" 2>/dev/null) || sk_sv_ow=
  set -- $sk_sv_ow
  SK_SRV_UID=${3:-}
  SK_SRV_GID=${4:-}
}

# sk__srv_install SRC DEST MODE [UID GID]: DEST is SRC with that mode (and owner, where this runs as root).
sk__srv_install() {
  if [ -n "${4:-}" ] && { [ "$SK_SRV_PRIV" = 1 ] || sk_is_root; }; then
    sk__srv_sudo install -m "$3" -o "$4" -g "${5:-$4}" "$1" "$2"
  else
    sk__srv_sudo install -m "$3" "$1" "$2"
  fi
}

# sk__srv_mkdirs DIR [SCOPE]: creates DIR and the missing directories above it (0755) and records each as created (for the
# integration that runs, or SCOPE)
sk__srv_mkdirs() {
  sk_sv_md_miss=
  sk_sv_md_p=$1
  while [ ! -d "$sk_sv_md_p" ] && [ -n "$sk_sv_md_p" ]; do
    sk_sv_md_miss="$sk_sv_md_p${sk_sv_md_miss:+$UI_NL$sk_sv_md_miss}"
    sk_sv_md_p=${sk_sv_md_p%/*}
  done
  while IFS= read -r sk_sv_md_d; do
    if [ -z "$sk_sv_md_d" ]; then
      continue
    fi
    sk__srv_priv_for "$sk_sv_md_d"
    if ! sk__srv_sudo mkdir -m 0755 "$sk_sv_md_d"; then
      sk__report 1 SK-E609 "Cannot create the directory $sk_sv_md_d: $(sk__srv_head "$SK_SRV_OUTF" 2)" "Create it by hand, or use --dir DIR for a place you can write. Nothing else was changed."
      return 1
    fi
    sk__srv_record_created "$sk_sv_md_d" - "${2:-${SK_INTEGRATION:-}}"
    SK_SRV_TXN="${SK_SRV_TXN:+$SK_SRV_TXN$UI_NL}D|$sk_sv_md_d|"
  done <<SK_SRV_MD
$sk_sv_md_miss
SK_SRV_MD
  return 0
}

# sk__srv_hash FILE: SK_SRV_H is the SHA-256 of FILE, read with sudo when this user cannot read it (a file that holds a secret)
sk__srv_hash() {
  SK_SRV_H=
  if [ -r "$1" ]; then
    SK_SRV_H=$(sk_sha256 "$1") || SK_SRV_H=
    return 0
  fi
  SK_SRV_PRIV=1
  if command -v sha256sum >/dev/null 2>&1; then
    sk__srv_sudo sha256sum "$1" || return 1
  elif command -v shasum >/dev/null 2>&1; then
    sk__srv_sudo shasum -a 256 "$1" || return 1
  else
    sk__srv_sudo openssl dgst -sha256 -r "$1" || return 1
  fi
  SK_SRV_H=$(cut -d ' ' -f 1 "$SK_SRV_OUTF" | head -n 1)
  [ -n "$SK_SRV_H" ]
}

# sk__srv_created_key FILE: SK_SRV_CK is the key of FILE's "created" record (empty if there is none), SK_SRV_CH its hash
sk__srv_created_key() {
  SK_SRV_CK=
  SK_SRV_CH=
  sk_rec_load
  if [ -n "$SK_REC" ]; then
    while IFS=$UI_TAB read -r sk_sv_ck_k sk_sv_ck_v; do
      case $sk_sv_ck_k in
        created.*)
          sk__unjq "$sk_sv_ck_v"
          sk__fields "$SK_RV"
          if [ "$SK_F1" = "$1" ]; then
            SK_SRV_CK=$sk_sv_ck_k
            SK_SRV_CH=$SK_F2
          fi
          ;;
      esac
    done <<SK_SRV_CK
$SK_REC
SK_SRV_CK
  fi
}

# sk__srv_record_created FILE HASH [SCOPE]: sk_created for a file this user may not be able to read (HASH - is a directory)
sk__srv_record_created() {
  sk__srv_created_key "$1"
  if [ -z "$SK_SRV_CK" ]; then
    sk__seq created
    SK_SRV_CK=created.$SK_SEQ
  fi
  sk_record "$SK_SRV_CK" "$1|$2|${3:-${SK_INTEGRATION:-}}"
}

# sk__srv_put_own SRC DEST MODE [UID GID [SCOPE]]: writes a file of ShakeKee's own (new, or one it wrote earlier) and records it.
# The caller has checked that DEST is not a file of the person's.
sk__srv_put_own() {
  sk_sv_po_dest=$2
  sk_sv_po_dir=${sk_sv_po_dest%/*}
  sk_sv_po_scope=${6:-${SK_INTEGRATION:-}}
  if [ ! -d "$sk_sv_po_dir" ]; then
    sk__srv_mkdirs "$sk_sv_po_dir" "$sk_sv_po_scope" || return 1
  fi
  sk_sv_po_h=$(sk_sha256 "$1")
  sk__srv_priv_for "$sk_sv_po_dest"
  if ! sk__srv_install "$1" "$sk_sv_po_dest.shakekee-new" "$3" "${4:-}" "${5:-}"; then
    sk__report 1 SK-E609 "Cannot write $sk_sv_po_dest: $(sk__srv_head "$SK_SRV_OUTF" 2)" "Check the permissions. Nothing was changed."
    return 1
  fi
  if ! sk__srv_sudo mv -f "$sk_sv_po_dest.shakekee-new" "$sk_sv_po_dest"; then
    sk__srv_sudo rm -f "$sk_sv_po_dest.shakekee-new" || true
    sk__report 1 SK-E609 "Cannot write $sk_sv_po_dest: $(sk__srv_head "$SK_SRV_OUTF" 2)" "Check the permissions. Nothing was changed."
    return 1
  fi
  sk__srv_record_created "$sk_sv_po_dest" "$sk_sv_po_h" "$sk_sv_po_scope"
  return 0
}

# sk__srv_forget_created FILE: removes a file ShakeKee made (as root where needed) and its record, when it is still as ShakeKee
# wrote it. Returns 0 (gone), 1 (it was changed since: left), 2 (could not remove it).
sk__srv_forget_created() {
  sk__srv_created_key "$1"
  sk_sv_fc_key=$SK_SRV_CK
  sk_sv_fc_hash=$SK_SRV_CH
  if [ ! -e "$1" ] && [ ! -L "$1" ]; then
    if [ -n "$sk_sv_fc_key" ]; then
      sk_unrecord "$sk_sv_fc_key"
    fi
    return 0
  fi
  if [ -L "$1" ] || [ ! -f "$1" ]; then
    return 1
  fi
  sk__srv_hash "$1" || return 2
  if [ -z "$sk_sv_fc_key" ] || [ "$sk_sv_fc_hash" != "$SK_SRV_H" ]; then
    return 1
  fi
  sk__srv_priv_for "$1"
  if sk__srv_sudo rm -f "$1"; then
    sk_unrecord "$sk_sv_fc_key"
    return 0
  fi
  return 2
}

# sk__srv_undo_edit KEY: puts the backup recorded under KEY back over the file it was made of, byte for byte, when
# the file is still as ShakeKee left it. Returns 0 (done, or the file already is the original), 1 (the file was changed
# since: not touched), 2 (the backup is missing), 3 (writing failed).
sk__srv_undo_edit() {
  sk_rec_get "$1" || return 2
  sk__fields "$SK_RV"
  sk_sv_ue_orig=$SK_F1
  sk_sv_ue_copy=$SK_F2
  sk_sv_ue_after=$SK_F3
  if [ ! -f "$sk_sv_ue_copy" ]; then
    return 2
  fi
  if [ -L "$sk_sv_ue_orig" ] || [ ! -f "$sk_sv_ue_orig" ]; then
    return 1
  fi
  if cmp -s "$sk_sv_ue_copy" "$sk_sv_ue_orig"; then
    sk_unrecord "$1"
    return 0
  fi
  if [ -z "$sk_sv_ue_after" ] || [ "$(sk_sha256 "$sk_sv_ue_orig")" != "$sk_sv_ue_after" ]; then
    return 1
  fi
  sk__modesym "$sk_sv_ue_copy"
  sk_sv_ue_mode=$SK_MODE
  sk__srv_owner "$sk_sv_ue_orig"
  sk_sv_ue_uid=$SK_SRV_UID
  sk_sv_ue_gid=$SK_SRV_GID
  sk__srv_priv_for "$sk_sv_ue_orig"
  if ! sk__srv_install "$sk_sv_ue_copy" "$sk_sv_ue_orig.shakekee-restore" "$sk_sv_ue_mode" "$sk_sv_ue_uid" "$sk_sv_ue_gid" ||
    ! sk__srv_sudo mv -f "$sk_sv_ue_orig.shakekee-restore" "$sk_sv_ue_orig"; then
    sk__srv_sudo rm -f "$sk_sv_ue_orig.shakekee-restore" || true
    return 3
  fi
  if ! cmp -s "$sk_sv_ue_copy" "$sk_sv_ue_orig"; then
    return 3
  fi
  sk_unrecord "$1"
  return 0
}

# ---- one change as a unit: the files written, the test, the reload, and the undo when the test fails ----
# SK_SRV_TXN lists what was done, one line each, to undo it newest first:
#   F|FILE|KEY    an edit of the person's file (KEY is its backup record)
#   S|FILE|COPY   a file of ShakeKee's that existed and was rewritten (COPY holds the old text)
#   N|FILE|       a file of ShakeKee's that is new
#   D|DIR|        a directory that was made

# sk__srv_txn_rollback: undoes the unit. Fails when something could not be put back.
sk__srv_txn_rollback() {
  sk_sv_rb_bad=0
  sk__reverse "$SK_SRV_TXN"
  sk_sv_rb_list=$SK_REVERSED
  SK_SRV_TXN=
  while IFS='|' read -r sk_sv_rb_t sk_sv_rb_f sk_sv_rb_k; do
    case $sk_sv_rb_t in
      F)
        if sk__srv_undo_edit "$sk_sv_rb_k"; then
          :
        else
          sk_sv_rb_bad=1
          sk__report 1 SK-E605 "Could not put $sk_sv_rb_f back as it was." "The original is in the backup copy under $SK_HOME/backups/ (see $SK_LOG). Copy it back by hand."
        fi
        ;;
      S)
        sk__srv_priv_for "$sk_sv_rb_f"
        sk_sv_rb_h=$(sk_sha256 "$sk_sv_rb_k")
        if sk__srv_install "$sk_sv_rb_k" "$sk_sv_rb_f.shakekee-restore" 0644 && sk__srv_sudo mv -f "$sk_sv_rb_f.shakekee-restore" "$sk_sv_rb_f"; then
          sk__srv_record_created "$sk_sv_rb_f" "$sk_sv_rb_h" server
        else
          sk_sv_rb_bad=1
        fi
        ;;
      N)
        if sk__srv_forget_created "$sk_sv_rb_f"; then
          :
        else
          sk_sv_rb_bad=1
        fi
        ;;
      D)
        sk__srv_priv_for "$sk_sv_rb_f"
        if sk__srv_sudo rmdir "$sk_sv_rb_f"; then
          sk__srv_created_key "$sk_sv_rb_f"
          if [ -n "$SK_SRV_CK" ]; then
            sk_unrecord "$SK_SRV_CK"
          fi
        fi
        ;;
    esac
  done <<SK_SRV_RB
$sk_sv_rb_list
SK_SRV_RB
  [ "$sk_sv_rb_bad" = 0 ]
}

# sk__srv_txn_site SRC DEST: writes ShakeKee's own per-site file; a file there that is not ours (not as ShakeKee wrote it) is
# never overwritten
sk__srv_txn_site() {
  sk_sv_ts_dest=$2
  if [ -L "$sk_sv_ts_dest" ] || { [ -e "$sk_sv_ts_dest" ] && [ ! -f "$sk_sv_ts_dest" ]; }; then
    sk__report 1 SK-E607 "$sk_sv_ts_dest is a link or not a regular file; ShakeKee does not write there." "Move it away, or use another place with SK_SRV_SITESDIR. Nothing was changed."
    return 1
  fi
  sk_sv_ts_dir=${sk_sv_ts_dest%/*}
  if [ ! -d "$sk_sv_ts_dir" ]; then
    # (before the entry of the file, so that an undo removes the file first and then the directory)
    sk__srv_mkdirs "$sk_sv_ts_dir" server || return 1
  fi
  if [ -e "$sk_sv_ts_dest" ]; then
    sk__srv_created_key "$sk_sv_ts_dest"
    sk_sv_ts_ch=$SK_SRV_CH
    sk__srv_hash "$sk_sv_ts_dest" || return 1
    if [ "$SK_SRV_H" = "$(sk_sha256 "$1")" ]; then
      return 0
    fi
    if [ -z "$sk_sv_ts_ch" ] || [ "$sk_sv_ts_ch" != "$SK_SRV_H" ]; then
      sk__report 1 SK-E607 "$sk_sv_ts_dest exists and is not the file ShakeKee wrote there." "Move it away, or use another place with SK_SRV_SITESDIR. Nothing was changed."
      return 1
    fi
    sk__srv_tmp
    cp "$sk_sv_ts_dest" "$SK_SRV_T" || return 1
    SK_SRV_TXN="${SK_SRV_TXN:+$SK_SRV_TXN$UI_NL}S|$sk_sv_ts_dest|$SK_SRV_T"
  else
    SK_SRV_TXN="${SK_SRV_TXN:+$SK_SRV_TXN$UI_NL}N|$sk_sv_ts_dest|"
  fi
  sk__srv_put_own "$1" "$sk_sv_ts_dest" 0644 "" "" server
}

# sk__srv_insert_awk FILE LINES TEXT OUT [flat]: OUT is FILE with TEXT added after each of the given line numbers, indented like the
# lines inside the block, or not at all with a fifth argument (a line at the top level)
sk__srv_insert_awk() {
  awk -v lines=" $2 " -v inc="$3" -v flat="${5:-}" '
    { line[NR] = $0 }
    END {
      for (i = 1; i <= NR; i++) {
        print line[i]
        if (index(lines, " " i " ")) {
          match(line[i], /^[ \t]*/)
          ind = substr(line[i], 1, RLENGTH)
          nxt = line[i + 1]
          match(nxt, /^[ \t]*/)
          nind = substr(nxt, 1, RLENGTH)
          # like the lines inside the block, else one level deeper than the opening line
          if (flat != "") pre = ""
          else if (nxt !~ /^[ \t]*$/ && length(nind) > length(ind)) pre = nind
          else if (ind ~ /\t/) pre = ind "\t"
          else pre = ind "    "
          print pre inc
        }
      }
    }' "$1" >"$4"
}

# sk__srv_txn_edit FILE NEW: replaces the person's FILE by NEW (a file in the working directory): a backup first, the same
# mode and owner, one rename. Refuses when FILE changed since NEW was made from it (HASH).
sk__srv_txn_edit() {
  sk_sv_te_file=$1
  sk_sv_te_new=$2
  sk_sv_te_hash=$3
  sk_sv_te_scope=${SK_INTEGRATION:-}
  SK_INTEGRATION=server
  sk_backup "$sk_sv_te_file"
  SK_INTEGRATION=$sk_sv_te_scope
  sk_sv_te_key=$SK_BACKUP_KEY
  if [ "$(sk_sha256 "$sk_sv_te_file")" != "$sk_sv_te_hash" ]; then
    sk_unrecord "$sk_sv_te_key"
    sk__report 1 SK-E012 "$sk_sv_te_file changed while it was being edited (by you or by another program); nothing was written." "Look at the file, then run the installer again."
    return 1
  fi
  sk__modesym "$sk_sv_te_file"
  sk_sv_te_mode=$SK_MODE
  sk__srv_owner "$sk_sv_te_file"
  sk_sv_te_uid=$SK_SRV_UID
  sk_sv_te_gid=$SK_SRV_GID
  sk__srv_priv_for "$sk_sv_te_file"
  if ! sk__srv_install "$sk_sv_te_new" "$sk_sv_te_file.shakekee-new" "$sk_sv_te_mode" "$sk_sv_te_uid" "$sk_sv_te_gid" ||
    ! sk__srv_sudo mv -f "$sk_sv_te_file.shakekee-new" "$sk_sv_te_file"; then
    sk__srv_sudo rm -f "$sk_sv_te_file.shakekee-new" || true
    sk_unrecord "$sk_sv_te_key"
    sk__report 1 SK-E609 "Cannot write $sk_sv_te_file: $(sk__srv_head "$SK_SRV_OUTF" 2)" "$sk_sv_te_file was not changed."
    return 1
  fi
  sk_changed "$sk_sv_te_file"
  # the record is kept under a name of its own: sk_restore_all, which does not know which include needs which file, never
  # undoes it; sk_server_remove and the rollback do
  sk_rec_get "$sk_sv_te_key"
  sk_sv_te_value=$SK_RV
  sk__seq wiring
  SK_SRV_BKEY=wiring.$SK_SEQ
  sk_record "$SK_SRV_BKEY" "$sk_sv_te_value"
  sk_unrecord "$sk_sv_te_key"
  SK_SRV_TXN="${SK_SRV_TXN:+$SK_SRV_TXN$UI_NL}F|$sk_sv_te_file|$SK_SRV_BKEY"
}

# sk__srv_cmd test|reload: the server's own test or reload command (SK_SRV_PRIV decides about sudo); output in SK_SRV_OUTF
sk__srv_cmd() {
  case "$SK_SRV:$1" in
    nginx:test) sk__srv_sudo "$SK_SRV_BIN" -t ;;
    nginx:reload) sk__srv_sudo "$SK_SRV_BIN" -s reload ;;
    apache:test)
      if [ -n "$SK_SRV_CTL" ]; then sk__srv_sudo "$SK_SRV_CTL" configtest; else sk__srv_sudo "$SK_SRV_BIN" -t; fi
      ;;
    apache:reload)
      if [ -n "$SK_SRV_CTL" ]; then sk__srv_sudo "$SK_SRV_CTL" graceful; else sk__srv_sudo "$SK_SRV_BIN" -k graceful; fi
      ;;
    caddy:test) sk__srv_sudo "$SK_SRV_BIN" validate --config "$SK_SRV_CONF" --adapter caddyfile ;;
    caddy:reload) sk__srv_sudo "$SK_SRV_BIN" reload --config "$SK_SRV_CONF" --adapter caddyfile ;;
    *) return 1 ;;
  esac
}

# sk__srv_run_cmd test|reload: the command as a step, with sudo when the configuration is not this user's to write
sk__srv_run_cmd() {
  sk__srv_priv_for "${SK_SRV_CONF:-/etc/x}"
  if [ "$1" = test ]; then
    sk_sv_rc_text=$SK_SRV_TEST_TEXT
  else
    sk_sv_rc_text=$SK_SRV_RELOAD_TEXT
  fi
  if [ "$SK_SRV_PRIV" = 1 ]; then
    sk_sv_rc_text="sudo $sk_sv_rc_text"
  fi
  ui_step_start "$1" "$sk_sv_rc_text"
  if sk__srv_cmd "$1"; then
    sk_sv_rc_d=$(sk__srv_head "$SK_SRV_OUTF" 1)
    ui_step_ok "$sk_sv_rc_d"
    return 0
  fi
  sk_sv_rc_d=$(sk__srv_head "$SK_SRV_OUTF" 8)
  ui_step_fail "${sk_sv_rc_d:-the command failed without saying why}"
  return 1
}

# sk__srv_txn_finish: tests the configuration; when the test fails the unit is undone and nothing is reloaded; else reloads
# a running server. Returns 1 (undone, SK-E604), 2 (could not be undone, SK-E605).
sk__srv_txn_finish() {
  if ! sk__srv_run_cmd test; then
    sk_sv_tf_msg=$(sk__srv_head "$SK_SRV_OUTF" 8)
    if sk__srv_txn_rollback; then
      sk__report 1 SK-E604 "$SK_SRV_TEST_TEXT rejected the change, so ShakeKee put everything back: the configuration is as it was and nothing was reloaded.${sk_sv_tf_msg:+ It said: $sk_sv_tf_msg}" "Show the lines to your web server's administrator, or add them by hand where they fit. The log has the details: $SK_LOG"
      return 1
    fi
    return 2
  fi
  SK_SRV_TXN=
  if [ "$SK_SRV_ISRUN" = yes ]; then
    if ! sk__srv_run_cmd reload; then
      ui_warn "$SK_SRV_RELOAD_TEXT failed; the new configuration was tested and is in place, so it applies at the next start of $SK_SRV."
    fi
  else
    ui_note "$SK_SRV is not running here; the change applies when it starts."
  fi
  return 0
}

# ---- sk_server_wire ----

# sk__srv_http_line FILE: the number of the line that opens the http block of an nginx file (empty if there is none)
sk__srv_http_line() {
  sk_sv_hl_prog="$(sk__srv_awk_common)$UI_NL"'
    BEGIN { depth = 0 }
    {
      plain = strip($0)
      t = plain; no = gsub(/\{/, "", t)
      t = plain; nc = gsub(/\}/, "", t)
      if (depth == 0 && !found && plain ~ /^[ \t]*http[ \t]*\{[ \t]*$/) { print NR; found = 1 }
      depth += no - nc
    }'
  awk "$sk_sv_hl_prog" "$1" 2>/dev/null | head -n 1
}

# sk__srv_top_line FILE: the number of the line after which an import can go at the top of a Caddyfile: 0, or the end of
# the global options block when the file starts with one
sk__srv_top_line() {
  sk_sv_tl_prog="$(sk__srv_awk_common)$UI_NL"'
    { lines[NR] = $0 }
    END {
      at = 0
      for (i = 1; i <= NR; i++) {
        p = strip(lines[i])
        if (p ~ /^[ \t]*$/) continue
        if (p ~ /^[ \t]*\{[ \t]*$/) {
          depth = 0
          for (j = i; j <= NR; j++) {
            q = strip(lines[j])
            t = q; depth += gsub(/\{/, "", t)
            t = q; depth -= gsub(/\}/, "", t)
            if (depth <= 0) { at = j; break }
          }
        }
        break
      }
      print at
    }'
  awk "$sk_sv_tl_prog" "$1" 2>/dev/null | head -n 1
}

# sk__srv_has_include FILE-OR-LIST TEXT: the text is in one of the files (a list of names, one per line)
sk__srv_has_text() {
  while IFS= read -r sk_sv_ht_f; do
    if [ -n "$sk_sv_ht_f" ] && [ -r "$sk_sv_ht_f" ] && grep -Fq -- "$2" "$sk_sv_ht_f" 2>/dev/null; then
      return 0
    fi
  done <<SK_SRV_HT
$1
SK_SRV_HT
  return 1
}

# sk__srv_shape SERVER: how a site is written in the configuration, for a message
sk__srv_shape() {
  case $1 in
    nginx) printf '%s' 'server {  ...  server_name HOST;  ...  }' ;;
    apache) printf '%s' '<VirtualHost ...>  ...  ServerName HOST  ...' ;;
    caddy) printf '%s' 'HOST {  ...  }' ;;
  esac
}

# sk__srv_wire_global SERVER: the include of the package that has to be in the configuration once per server (nginx: in
# http { }, Caddy: at the top of the Caddyfile), as part of the unit. Sets SK_SRV_G_FACT to the fact to record when it
# added something (else empty). Returns 0, 1 (failed) or 2 (cannot be done automatically; SK-E603 or SK-E607 was reported).
sk__srv_wire_global() {
  SK_SRV_G_FACT=
  case $1 in
    nginx) sk_sv_wg_text="include $SK_SRV_PKGDIR/shakekee-http.conf;" ;;
    caddy) sk_sv_wg_text="import $SK_SRV_PKGDIR/shakekee.Caddyfile" ;;
    *) return 0 ;;
  esac
  if sk__srv_has_text "$SK_SRV_FILELIST" "$sk_sv_wg_text"; then
    return 0
  fi
  sk_sv_wg_target=
  sk_sv_wg_line=
  if [ "$1" = nginx ]; then
    while IFS= read -r sk_sv_wg_f; do
      if [ -z "$sk_sv_wg_f" ] || [ -n "$sk_sv_wg_target" ]; then
        continue
      fi
      sk_sv_wg_l=$(sk__srv_http_line "$sk_sv_wg_f")
      if [ -n "$sk_sv_wg_l" ]; then
        sk_sv_wg_target=$sk_sv_wg_f
        sk_sv_wg_line=$sk_sv_wg_l
      fi
    done <<SK_SRV_WG
$SK_SRV_FILELIST
SK_SRV_WG
    if [ -z "$sk_sv_wg_target" ]; then
      sk__report 1 SK-E603 "Cannot find the http { } block of the nginx configuration, so the installer cannot add the shared include itself." "Add this line inside http { } yourself: $sk_sv_wg_text"
      return 2
    fi
  else
    sk_sv_wg_target=$SK_SRV_CONF
    sk_sv_wg_line=$(sk__srv_top_line "$sk_sv_wg_target")
  fi
  if ! sk__srv_real "$sk_sv_wg_target"; then
    sk__report 1 SK-E607 "$sk_sv_wg_target is not a regular file, or a link to one that cannot be resolved." "Edit the real file by hand and add this line: $sk_sv_wg_text"
    return 2
  fi
  sk_sv_wg_target=$SK_SRV_REAL
  sk__srv_tmp
  sk_sv_wg_new=$SK_SRV_T
  sk_sv_wg_hash=$(sk_sha256 "$sk_sv_wg_target")
  if [ "$1" = caddy ] && [ "$sk_sv_wg_line" = 0 ]; then
    {
      printf '%s\n' "$sk_sv_wg_text"
      cat "$sk_sv_wg_target"
    } >"$sk_sv_wg_new"
  else
    sk_sv_wg_flat=
    if [ "$1" = caddy ]; then
      sk_sv_wg_flat=flat
    fi
    sk__srv_insert_awk "$sk_sv_wg_target" "$sk_sv_wg_line" "$sk_sv_wg_text" "$sk_sv_wg_new" "$sk_sv_wg_flat"
  fi
  sk__srv_txn_edit "$sk_sv_wg_target" "$sk_sv_wg_new" "$sk_sv_wg_hash" || return 1
  SK_SRV_G_FACT="full|$sk_sv_wg_target||$SK_SRV_BKEY"
  return 0
}

# sk_server_wire SERVER SITE public|full: see the top of this file. Returns 0 (done, or it was there already: SK_SERVER_WIRED is
# changed, present or dry), 1 (the server's test rejected the change and everything was put back, or it failed before any
# change), 2 (nothing can be done automatically for this site, for example because no server block names it; SK-E603 or
# SK-E607 was reported).
sk_server_wire() {
  SK_SERVER_WIRED=
  sk__srv_init
  case ${3:-} in
    public | full) ;;
    *) sk_fail 1 SK-E010 "sk_server_wire was called with the kind '${3:-}'." "This is a bug in the installer; please report it." ;;
  esac
  if ! sk__srv_site_ok "${2:-}"; then
    sk_fail 1 SK-E602 "'${2:-}' is not a host name the installer can use for a web server rule." "Use the name as the server configuration spells it: letters, digits, dots and hyphens."
  fi
  if ! sk__srv_addr_ok "$SK_SRV_PUBLIC"; then
    sk_fail 1 SK-E602 "'$SK_SRV_PUBLIC' is not a daemon address (HOST:PORT)." "Use an address like 127.0.0.1:7879."
  fi
  if ! sk_server_select "$1"; then
    sk__report 1 SK-E601 "$1 is not installed on this machine (no program of that name was found)." "Install it, or put its program in the PATH."
    return 1
  fi
  sk_sv_ww_site=$2
  sk_sv_ww_kind=$3
  # the record of an earlier run: when the daemon step and the package both wire a site, the per-site file holds the
  # package's part (which includes the daemon's) and the record says "both"
  sk_sv_ww_old=
  sk_sv_ww_oldbkey=
  if sk_rec_get "server.$SK_SRV.$sk_sv_ww_site"; then
    sk__fields "$SK_RV"
    sk_sv_ww_old=$SK_F1
    sk_sv_ww_oldbkey=$SK_F4
  fi
  sk_sv_ww_content=$sk_sv_ww_kind
  sk_sv_ww_rec=$sk_sv_ww_kind
  case "$sk_sv_ww_old:$sk_sv_ww_kind" in
    full:public | both:public | public:full | both:full)
      sk_sv_ww_content=full
      sk_sv_ww_rec=both
      ;;
  esac
  sk__srv_layout "$SK_SRV" "$sk_sv_ww_site" "$sk_sv_ww_content"
  sk_sv_ww_inc=$SK_SRV_INCLUDE
  sk_sv_ww_sitefile=$SK_SRV_SITEFILE
  sk_sv_ww_body=$SK_SRV_BODY
  if ! sk__srv_blocks "$sk_sv_ww_site"; then
    sk__report 1 SK-E603 "Cannot read the $SK_SRV configuration (${SK_SRV_CONF:-no main file found}), so the installer cannot see where $sk_sv_ww_site is." "Add the lines yourself where they belong."
    return 2
  fi
  if ! sk__srv_pick; then
    sk__report 1 SK-E603 "Cannot find a server block for $sk_sv_ww_site in the $SK_SRV configuration. It has to be written as $(sk__srv_shape "$SK_SRV"), with the opening on one line.${SK_SRV_UNREAD:+ These files cannot be read by this user: $SK_SRV_UNREAD.}" "Add the lines yourself where they belong."
    return 2
  fi
  if ! sk__srv_real "$SK_SRV_TARGET"; then
    sk__report 1 SK-E607 "$SK_SRV_TARGET is not a regular file, or a link to one that cannot be resolved." "Edit the real file by hand."
    return 2
  fi
  sk_sv_ww_target=$SK_SRV_REAL
  sk_sv_ww_lines=$SK_SRV_LINES
  if [ ! -r "$sk_sv_ww_target" ]; then
    sk__report 1 SK-E603 "$sk_sv_ww_target cannot be read by this user, so the installer cannot edit it safely." "Add the lines yourself, or run the installer as a user who can read it."
    return 2
  fi
  if [ "$SK_DRY" = 1 ]; then
    ui_say "[dry-run] write $sk_sv_ww_sitefile and add '$sk_sv_ww_inc' to $sk_sv_ww_target (line $sk_sv_ww_lines)"
    SK_SERVER_WIRED=dry
    return 0
  fi
  sk_log_open
  sk_home
  SK_SRV_TXN=
  if sk__srv_has_text "$sk_sv_ww_target" "$sk_sv_ww_inc"; then
    if [ -z "$sk_sv_ww_old" ]; then
      # somebody else's include of a file of this name: not ours to rewrite
      SK_SERVER_WIRED=present
      return 0
    fi
    sk_sv_ww_bkey=$sk_sv_ww_oldbkey
    sk_sv_ww_present=1
  else
    sk_sv_ww_bkey=
    sk_sv_ww_present=0
  fi
  sk_sv_ww_gfact=
  sk_sv_ww_changed=0
  if [ "$sk_sv_ww_content" = full ]; then
    sk_sv_ww_rc=0
    sk__srv_wire_global "$SK_SRV" || sk_sv_ww_rc=$?
    if [ "$sk_sv_ww_rc" != 0 ]; then
      sk__srv_txn_rollback || true
      return "$sk_sv_ww_rc"
    fi
    if [ -n "$SK_SRV_G_FACT" ]; then
      sk_sv_ww_gfact=$SK_SRV_G_FACT
      sk_sv_ww_changed=1
      # the line numbers of the blocks may have moved (the include went into the same file): look again
      if ! { sk__srv_blocks "$sk_sv_ww_site" && sk__srv_pick && sk__srv_real "$SK_SRV_TARGET"; }; then
        sk__srv_txn_rollback || true
        sk__report 1 SK-E603 "The $SK_SRV configuration cannot be read again after the first include, so nothing was changed." "Add the lines yourself where they belong."
        return 2
      fi
      sk_sv_ww_target=$SK_SRV_REAL
      sk_sv_ww_lines=$SK_SRV_LINES
    fi
  fi
  sk__srv_txn_site "$sk_sv_ww_body" "$sk_sv_ww_sitefile" || {
    sk__srv_txn_rollback || true
    return 1
  }
  case "$SK_SRV_TXN" in
    *"|$sk_sv_ww_sitefile|"*) sk_sv_ww_changed=1 ;;
  esac
  if [ "$sk_sv_ww_present" = 0 ]; then
    sk__srv_tmp
    sk_sv_ww_new=$SK_SRV_T
    sk_sv_ww_hash=$(sk_sha256 "$sk_sv_ww_target")
    sk__srv_insert_awk "$sk_sv_ww_target" "$sk_sv_ww_lines" "$sk_sv_ww_inc" "$sk_sv_ww_new"
    sk__srv_txn_edit "$sk_sv_ww_target" "$sk_sv_ww_new" "$sk_sv_ww_hash" || {
      sk__srv_txn_rollback || true
      return 1
    }
    sk_sv_ww_bkey=$SK_SRV_BKEY
    sk_sv_ww_changed=1
  fi
  if [ "$sk_sv_ww_changed" = 1 ]; then
    sk_sv_ww_rc=0
    sk__srv_txn_finish || sk_sv_ww_rc=$?
    if [ "$sk_sv_ww_rc" != 0 ]; then
      return 1
    fi
  fi
  if [ -n "$sk_sv_ww_gfact" ]; then
    sk_record "server.$SK_SRV._global_" "$sk_sv_ww_gfact"
  fi
  sk_record "server.$SK_SRV.$sk_sv_ww_site" "$sk_sv_ww_rec|$sk_sv_ww_target|$sk_sv_ww_sitefile|$sk_sv_ww_bkey"
  if [ "$sk_sv_ww_changed" = 1 ]; then
    SK_SERVER_WIRED=changed
  else
    SK_SERVER_WIRED=present
  fi
  return 0
}

# ---- sk_server_rule: the daemon rule step, and the same question for the whole integration ----

# sk__srv_show SERVER SITE UPSTREAM KIND: what the person adds by hand
sk__srv_show() {
  if [ "$4" = full ]; then
    sk__srv_pkgdir "$1"
    case $1 in
      nginx)
        ui_say "Add these lines to the nginx configuration of $2 (the files are the ones of the package), then test and reload ($SK_SRV_TEST_TEXT, $SK_SRV_RELOAD_TEXT):"
        ui_say "    inside http { }, once:           include $SK_SRV_PKGDIR/shakekee-http.conf;"
        ui_say "    inside the server { } of $2:     include $SK_SRV_PKGDIR/shakekee-server.conf;"
        ;;
      apache)
        ui_say "Add this line inside the <VirtualHost> of $2 (needs mod_rewrite, mod_proxy, mod_proxy_http, mod_alias, mod_cgid and mod_headers), then test and reload ($SK_SRV_TEST_TEXT, $SK_SRV_RELOAD_TEXT):"
        ui_say "    Include $SK_SRV_PKGDIR/shakekee.conf"
        ;;
      caddy)
        ui_say "Add these lines to the Caddyfile, then check and reload ($SK_SRV_TEST_TEXT, $SK_SRV_RELOAD_TEXT):"
        ui_say "    at the top, once:                  import $SK_SRV_PKGDIR/shakekee.Caddyfile"
        ui_say "    inside the site block of $2:       import shakekee_public"
        ;;
    esac
    return 0
  fi
  case $1 in
    nginx)
      ui_say "nginx must pass /.shakekee/ and /.well-known/shakekee/ to $3."
      ui_say "Put these lines inside the server { } block of $2, then test and reload ($SK_SRV_TEST_TEXT, $SK_SRV_RELOAD_TEXT):"
      ;;
    apache)
      ui_say "Apache must pass /.shakekee/ and /.well-known/shakekee/ to $3."
      ui_say "Put these lines inside the <VirtualHost> of $2 (they need mod_proxy, mod_proxy_http and mod_headers), then test and reload ($SK_SRV_TEST_TEXT, $SK_SRV_RELOAD_TEXT):"
      ;;
    caddy)
      ui_say "Caddy must pass /.shakekee/ and /.well-known/shakekee/ to $3."
      ui_say "Put these lines inside the site block of $2, then check and reload ($SK_SRV_TEST_TEXT, $SK_SRV_RELOAD_TEXT):"
      ;;
  esac
  sk__srv_manual "$1" "$3" | while IFS= read -r sk_sv_sh_l; do
    if [ -n "$sk_sv_sh_l" ]; then
      ui_say "    $sk_sv_sh_l"
    else
      ui_say ""
    fi
  done
}

# sk_server_offer SERVER SITE public|full UPSTREAM: shows what has to be added to the configuration of SITE, asks whether the
# installer should add it (the default is to only show it), and when the person agrees, does it with sk_server_wire. Sets
# SK_SERVER_RULE to shown, declined, added, present or failed. Returns 1 only for failed: the configuration is then as it was.
sk_server_offer() {
  SK_SERVER_RULE=
  sk__srv_init
  if ! sk__srv_site_ok "${2:-}"; then
    sk_fail 1 SK-E602 "'${2:-}' is not a host name the installer can use for a web server rule." "Use the name as the server configuration spells it: letters, digits, dots and hyphens."
  fi
  if ! sk__srv_addr_ok "${4:-}"; then
    sk_fail 1 SK-E602 "'${4:-}' is not a daemon address (HOST:PORT)." "Use an address like 127.0.0.1:7879."
  fi
  if ! sk_server_select "$1"; then
    sk__report 1 SK-E601 "$1 is not installed on this machine (no program of that name was found)." "Install it, or put its program in the PATH."
    SK_SERVER_RULE=failed
    return 1
  fi
  sk_sv_of_site=$2
  sk_sv_of_kind=$3
  sk_sv_of_saved=$SK_SRV_PUBLIC
  SK_SRV_PUBLIC=$4
  sk__srv_show "$SK_SRV" "$sk_sv_of_site" "$4" "$sk_sv_of_kind"
  # the question: only show (the default), or add them
  sk_sv_of_add=0
  case ${SK_SERVER_EDIT:-} in
    yes) sk_sv_of_add=1 ;;
    no) ;;
    *)
      if [ "$SK_DRY" = 1 ]; then
        ui_note "Dry run: the installer would ask whether to add these lines for you."
      else
        ui_menu_single "Who adds them?" "+show|Show me the lines, I will add them|default: nothing is changed" "add|Add them for me|edits your $SK_SRV configuration (read the warning first)"
        if [ "$UI_REPLY" = add ]; then
          sk_sv_of_add=1
        fi
      fi
      ;;
  esac
  if [ "$sk_sv_of_add" = 0 ]; then
    SK_SERVER_RULE=shown
    SK_SRV_PUBLIC=$sk_sv_of_saved
    return 0
  fi
  # what would be touched, said before anything is
  if sk__srv_blocks "$sk_sv_of_site" && sk__srv_pick && sk__srv_real "$SK_SRV_TARGET"; then
    sk_sv_of_file=$SK_SRV_REAL
  else
    sk_sv_of_file=
  fi
  if [ -z "$sk_sv_of_file" ]; then
    sk__report 1 SK-E603 "Cannot find a server block for $sk_sv_of_site in the $SK_SRV configuration (${SK_SRV_CONF:-no main file found}). It has to be written as $(sk__srv_shape "$SK_SRV"), with the opening on one line.${SK_SRV_UNREAD:+ These files cannot be read by this user: $SK_SRV_UNREAD.} So the installer cannot add the lines itself." "Add the lines above by hand where they fit."
    SK_SERVER_RULE=shown
    SK_SRV_PUBLIC=$sk_sv_of_saved
    return 0
  fi
  if [ "$sk_sv_of_kind" = full ] && [ "$SK_SRV" != caddy ] && [ -n "$SK_SRV_FIRST" ] && [ "$SK_SRV_FIRST" != "$(printf '%s' "$sk_sv_of_site" | tr 'A-Z' 'a-z')" ]; then
    ui_warn "$sk_sv_of_site is not the first name of its block ($SK_SRV_FIRST is). The daemon judges the first name, so the licensed name has to come first, else $sk_sv_of_site is not protected."
  fi
  sk__srv_layout "$SK_SRV" "$sk_sv_of_site" "$sk_sv_of_kind"
  sk__srv_priv_for "$sk_sv_of_file"
  sk_sv_of_priv=$SK_SRV_PRIV
  sk__srv_priv_for "$SK_SRV_SITEFILE"
  if [ "$SK_SRV_PRIV" = 1 ]; then
    sk_sv_of_priv=1
  fi
  sk_home
  if [ "$sk_sv_of_priv" = 1 ]; then
    sk_sv_of_needs='Needs: sudo, for those commands only. Each one is shown before it runs.'
  else
    sk_sv_of_needs='Needs: nothing special, you can write these files.'
  fi
  sk_sv_of_also=
  if [ "$sk_sv_of_kind" = full ] && [ "$SK_SRV" != apache ]; then
    if ! sk__srv_has_text "$SK_SRV_FILELIST" "$SK_SRV_PKGDIR/shakekee-http.conf" && ! sk__srv_has_text "$SK_SRV_FILELIST" "$SK_SRV_PKGDIR/shakekee.Caddyfile"; then
      sk_sv_of_also="Also: one more line, the include of the package, goes into the main configuration ($SK_SRV_CONF), with a backup of its own."
    fi
  fi
  ui_warn_box "You are about to let an installer change a live web server." \
    "File: $sk_sv_of_file. One line is inserted in the server block of $sk_sv_of_site: $SK_SRV_INCLUDE" \
    "New file: $SK_SRV_SITEFILE. ShakeKee's own, $(sed -n '$=' "$SK_SRV_BODY") lines." \
    "Backup: a copy of $sk_sv_of_file is saved under $SK_HOME/backups/ first." \
    "Then: $SK_SRV_TEST_TEXT. If it fails, the backup is put back and nothing is reloaded. If it passes, $SK_SRV_RELOAD_TEXT follows." \
    "$sk_sv_of_needs" \
    "Undo: run the installer again with --uninstall; the file is put back byte for byte unless you changed it since." \
    ${sk_sv_of_also:+"$sk_sv_of_also"}
  if [ "${SK_SERVER_EDIT:-}" != yes ]; then
    if ! ui_confirm_yes "Allow the installer to edit $sk_sv_of_file?"; then
      ui_say "Nothing was changed. The lines above are what to add."
      SK_SERVER_RULE=declined
      SK_SRV_PUBLIC=$sk_sv_of_saved
      return 0
    fi
  fi
  sk_sv_of_rc=0
  sk_server_wire "$SK_SRV" "$sk_sv_of_site" "$sk_sv_of_kind" || sk_sv_of_rc=$?
  SK_SRV_PUBLIC=$sk_sv_of_saved
  case $sk_sv_of_rc in
    0)
      if [ "$SK_SERVER_WIRED" = present ]; then
        SK_SERVER_RULE=present
        ui_say "The lines for $sk_sv_of_site are in place already."
      else
        SK_SERVER_RULE=added
      fi
      if [ "$SK_DRY" != 1 ]; then
        ui_step_start probe "The web server passes /.shakekee/ to the daemon"
        # a reload is asked for, not waited for: the new workers may need a moment (SK_SRV_PROBE_TRIES, default 3, one second apart)
        sk_sv_of_n=1
        sk_sv_of_ok=0
        while :; do
          if sk_server_probe "$sk_sv_of_site"; then
            sk_sv_of_ok=1
            break
          fi
          if [ "$sk_sv_of_n" -ge "${SK_SRV_PROBE_TRIES:-3}" ]; then
            break
          fi
          sk_sv_of_n=$((sk_sv_of_n + 1))
          sleep 1
        done
        if [ "$sk_sv_of_ok" = 1 ]; then
          ui_step_ok "the daemon answered"
        else
          ui_step_warn "no answer from the daemon yet (it may not be running, or this machine does not answer for $sk_sv_of_site on its own addresses)"
        fi
      fi
      return 0
      ;;
    2)
      SK_SERVER_RULE=shown
      return 0
      ;;
    *)
      SK_SERVER_RULE=failed
      return 1
      ;;
  esac
}

# sk_server_rule SERVER SITE UPSTREAM: the daemon rule step (see the top of this file).
sk_server_rule() {
  sk_server_offer "${1:-}" "${2:-}" public "${3:-}"
}

# ---- sk_server_probe ----

# sk_server_probe SITE: asks the web server on this machine (port 80, then 443) for the daemon's
# status path under SITE's name; a JSON answer is the daemon. Succeeds when the daemon answered. Nothing leaves this machine.
sk_server_probe() {
  if ! command -v curl >/dev/null 2>&1; then
    return 2
  fi
  sk__srv_init
  sk_sv_pb2_zero=0000000000000000000000000000000000000000000000000000000000000000
  for sk_sv_pb2_try in "80:$SK_SRV_HTTP" "443:https:"; do
    sk_sv_pb2_port=${sk_sv_pb2_try%%:*}
    sk_sv_pb2_scheme=${sk_sv_pb2_try#*:}
    # (not a download: only the headers of the answer are looked at)
    sk_sv_pb2_h=$(curl -sS --max-time 5 -o /dev/null -D - --resolve "$1:$sk_sv_pb2_port:127.0.0.1" "$sk_sv_pb2_scheme//$1/.shakekee/api/v1/status/$sk_sv_pb2_zero" 2>/dev/null </dev/null) || sk_sv_pb2_h=
    case $sk_sv_pb2_h in
      *[Cc]ontent-[Tt]ype:*json*) return 0 ;;
    esac
  done
  return 1
}

# ---- sk_server_remove and the undo ----

# sk__srv_downgrade SERVER SITE: the per-site file of a site that the daemon step and the package both wired goes back to the
# daemon's part alone (the package is being removed). Returns 1 when that failed (everything is as it was).
sk__srv_downgrade() {
  sk__srv_layout "$1" "$2" public
  SK_SRV_TXN=
  sk__srv_txn_site "$SK_SRV_BODY" "$SK_SRV_SITEFILE" || return 1
  case "$SK_SRV_TXN" in
    "") ;;
    *)
      if ! sk__srv_txn_finish; then
        return 1
      fi
      ;;
  esac
}

# sk__srv_keep FILE|SCOPE: forgets the records "created" of a file (and the directory it is in), or, given a scope (a word without a
# slash), of everything that integration made, without removing anything. Used when an edit of the person's file could not be
# put back: it still includes those files, and the generic undo (sk_restore_all) must not remove them.
sk__srv_keep() {
  sk_rec_load
  if [ -z "$SK_REC" ]; then
    return 0
  fi
  sk_sv_kp_keys=
  while IFS=$UI_TAB read -r sk_sv_kp_k sk_sv_kp_v; do
    case $sk_sv_kp_k in
      created.*)
        sk__unjq "$sk_sv_kp_v"
        sk__fields "$SK_RV"
        case $1 in
          */*)
            if [ "$SK_F1" = "$1" ] || [ "$SK_F1" = "${1%/*}" ]; then
              sk_sv_kp_keys="$sk_sv_kp_keys $sk_sv_kp_k"
            fi
            ;;
          *)
            if [ "$SK_F3" = "$1" ]; then
              sk_sv_kp_keys="$sk_sv_kp_keys $sk_sv_kp_k"
            fi
            ;;
        esac
        ;;
    esac
  done <<SK_SRV_KP
$SK_REC
SK_SRV_KP
  for sk_sv_kp_k in $sk_sv_kp_keys; do
    sk_unrecord "$sk_sv_kp_k"
  done
}

# sk_server_remove SERVER SITE [rule|package]: see the top of this file. A site that the daemon step (rule, the default) and the
# package both wired stays wired for the one that is left. Returns 0 (done, or there was nothing to do), 1 (something is left: a
# file was changed since ShakeKee edited it, or could not be put back; SK-E606 was reported and the record stays)
sk_server_remove() {
  sk__srv_init
  if ! sk_rec_get "server.${1:-}.${2:-}"; then
    ui_say "ShakeKee made no change to the $1 configuration for $2; nothing to take back."
    return 0
  fi
  sk__fields "$SK_RV"
  sk_sv_rm_kind=$SK_F1
  sk_sv_rm_target=$SK_F2
  sk_sv_rm_sitefile=$SK_F3
  sk_sv_rm_bkey=$SK_F4
  sk_log_open
  sk_sv_rm_installed=0
  if sk_server_select "$1"; then
    sk_sv_rm_installed=1
  fi
  case "${3:-rule}:$sk_sv_rm_kind" in
    rule:both)
      ui_say "$2 stays wired for the $1 package; only the daemon's claim on it is given up."
      if [ "$SK_DRY" != 1 ]; then
        sk_record "server.$1.$2" "full|$sk_sv_rm_target|$sk_sv_rm_sitefile|$sk_sv_rm_bkey"
      fi
      return 0
      ;;
    rule:full)
      ui_say "The lines for $2 belong to the $1 package, not to the daemon; left as they are."
      return 0
      ;;
    package:public)
      ui_say "The lines for $2 belong to the daemon, not to the $1 package; left as they are."
      return 0
      ;;
    package:both)
      ui_step_start downgrade "Keeping only the daemon's lines for $2 in $sk_sv_rm_sitefile"
      if [ "$SK_DRY" = 1 ]; then
        ui_step_ok "dry run"
        return 0
      fi
      if [ "$sk_sv_rm_installed" = 1 ] && sk__srv_downgrade "$1" "$2"; then
        ui_step_ok
        sk_record "server.$1.$2" "public|$sk_sv_rm_target|$sk_sv_rm_sitefile|$sk_sv_rm_bkey"
        return 0
      fi
      ui_step_warn "left as it is"
      return 1
      ;;
  esac
  sk_sv_rm_bad=0
  if [ -n "$sk_sv_rm_bkey" ]; then
    ui_step_start restore "Putting $sk_sv_rm_target back as it was"
    if [ "$SK_DRY" = 1 ]; then
      ui_step_ok "dry run"
    else
      sk_sv_rm_rc=0
      sk__srv_undo_edit "$sk_sv_rm_bkey" || sk_sv_rm_rc=$?
      case $sk_sv_rm_rc in
        0) ui_step_ok "the original is back" ;;
        1)
          ui_step_warn "$sk_sv_rm_target was changed after ShakeKee edited it, so it was not touched"
          sk__report 1 SK-E606 "$sk_sv_rm_target was changed after ShakeKee edited it, so ShakeKee did not touch it." "Remove the line that includes ${sk_sv_rm_sitefile:-the files of ShakeKee} from it yourself, and only then delete that file."
          sk_sv_rm_bad=1
          ;;
        2)
          ui_step_warn "the backup copy is missing"
          sk__report 1 SK-E606 "The backup copy of $sk_sv_rm_target is missing, so it was not put back." "Remove the include line of ShakeKee from it yourself."
          sk_sv_rm_bad=1
          ;;
        *)
          ui_step_fail "writing $sk_sv_rm_target failed"
          sk__report 1 SK-E605 "Could not put $sk_sv_rm_target back as it was." "The original is in the backup copy under $SK_HOME/backups/. Copy it back by hand."
          sk_sv_rm_bad=1
          ;;
      esac
    fi
  fi
  if [ "$sk_sv_rm_bad" = 1 ]; then
    if [ -n "$sk_sv_rm_sitefile" ]; then
      sk__srv_keep "$sk_sv_rm_sitefile"
    fi
    return 1
  fi
  if [ -n "$sk_sv_rm_sitefile" ]; then
    if [ "$SK_DRY" = 1 ]; then
      ui_say "[dry-run] remove $sk_sv_rm_sitefile"
    else
      sk_sv_rm_rc=0
      sk__srv_forget_created "$sk_sv_rm_sitefile" || sk_sv_rm_rc=$?
      case $sk_sv_rm_rc in
        0) ui_say "Removed $sk_sv_rm_sitefile." ;;
        *)
          ui_warn "$sk_sv_rm_sitefile was left (it was changed, or could not be removed). Nothing includes it any more."
          sk_sv_rm_bad=1
          ;;
      esac
      sk__srv_rmdir_empty "${sk_sv_rm_sitefile%/*}"
    fi
  fi
  if [ "$SK_DRY" != 1 ]; then
    sk_unrecord "server.$1.$2"
    if [ "$sk_sv_rm_installed" = 1 ] && [ -f "$SK_SRV_CONF" ]; then
      if sk__srv_run_cmd test; then
        if [ "$SK_SRV_ISRUN" = yes ]; then
          sk__srv_run_cmd reload || ui_warn "$SK_SRV_RELOAD_TEXT failed; it applies at the next start of $SK_SRV."
        fi
      else
        ui_warn "$SK_SRV_TEST_TEXT fails now; this is not caused by the change just undone. It said: $(sk__srv_head "$SK_SRV_OUTF" 3)"
      fi
    fi
  fi
  [ "$sk_sv_rm_bad" = 0 ]
}

# sk__srv_rmdir_empty DIR: removes a directory ShakeKee made when it is empty now
sk__srv_rmdir_empty() {
  sk__srv_created_key "$1"
  if [ -z "$SK_SRV_CK" ] || [ ! -d "$1" ]; then
    return 0
  fi
  sk__srv_priv_for "$1"
  if sk__srv_sudo rmdir "$1"; then
    sk_unrecord "$SK_SRV_CK"
  fi
  return 0
}

# sk_server_restore_all [SCOPE]: sk_restore_all for the changes that need sudo: first the edits of the person's files, then
# the files and directories ShakeKee made, newest first, each only when it is still as ShakeKee left it. The ones that
# are not are named and keep their record. SK_LEFT counts them.
sk_server_restore_all() {
  sk__srv_init
  SK_LEFT=0
  SK_LEFT_LIST=
  sk__changes "${1:-}"
  if [ -z "$SK_CHG" ]; then
    return 0
  fi
  sk_sv_ra_keys=$SK_CHG
  sk_sv_ra_kept=0
  while IFS= read -r sk_sv_ra_k; do
    if ! sk_rec_get "$sk_sv_ra_k"; then
      continue
    fi
    sk__fields "$SK_RV"
    case $sk_sv_ra_k in
      backup.*)
        ui_step_start restore "Restoring $SK_F1"
        sk_sv_ra_f=$SK_F1
        sk_sv_ra_c=$SK_F2
        if [ "$SK_DRY" = 1 ]; then
          ui_step_ok "would put back the original from $sk_sv_ra_c"
        else
          sk_sv_ra_rc=0
          sk__srv_undo_edit "$sk_sv_ra_k" || sk_sv_ra_rc=$?
          case $sk_sv_ra_rc in
            0) ui_step_ok "the original is back (copy kept at $sk_sv_ra_c)" ;;
            1)
              sk_sv_ra_kept=1
              sk__left "$sk_sv_ra_f was changed after ShakeKee edited it, so it was not touched (the original is kept at $sk_sv_ra_c)"
              ;;
            2)
              sk_sv_ra_kept=1
              sk__left "the backup copy $sk_sv_ra_c is missing; $sk_sv_ra_f is left as it is"
              ;;
            *)
              sk_sv_ra_kept=1
              sk__left "could not write $sk_sv_ra_f; the original is kept at $sk_sv_ra_c"
              ;;
          esac
        fi
        ;;
      created.*)
        sk_sv_ra_f=$SK_F1
        sk_sv_ra_h=$SK_F2
        ui_step_start remove "Removing $sk_sv_ra_f"
        if [ "$SK_DRY" = 1 ]; then
          ui_step_ok "would remove"
        elif [ "$sk_sv_ra_kept" = 1 ]; then
          # a file of the person's that includes this one could not be put back: the include must not dangle
          sk_unrecord "$sk_sv_ra_k"
          sk__left "$sk_sv_ra_f is kept: a configuration file that could not be put back may still include it"
        elif [ "$sk_sv_ra_h" = - ]; then
          if [ ! -d "$sk_sv_ra_f" ]; then
            sk_unrecord "$sk_sv_ra_k"
            ui_step_ok "already gone"
          else
            sk__srv_priv_for "$sk_sv_ra_f"
            if sk__srv_sudo rmdir "$sk_sv_ra_f"; then
              sk_unrecord "$sk_sv_ra_k"
              ui_step_ok
            else
              sk__left "the directory $sk_sv_ra_f is not empty; left as it is"
            fi
          fi
        else
          sk_sv_ra_rc=0
          sk__srv_forget_created "$sk_sv_ra_f" || sk_sv_ra_rc=$?
          case $sk_sv_ra_rc in
            0) ui_step_ok ;;
            1) sk__left "$sk_sv_ra_f differs from what ShakeKee wrote (you changed it); left as it is" ;;
            *) sk__left "$sk_sv_ra_f could not be removed; left as it is" ;;
          esac
        fi
        ;;
    esac
  done <<SK_SRV_RA
$sk_sv_ra_keys
SK_SRV_RA
  return 0
}

# sk_server_recorded SERVER: the sites ShakeKee wired in SERVER's configuration, newest first (not the pseudo site _global_)
sk_server_recorded() {
  sk_rec_load
  sk_sv_rr_list=
  if [ -n "$SK_REC" ]; then
    while IFS=$UI_TAB read -r sk_sv_rr_k sk_sv_rr_v; do
      case $sk_sv_rr_k in
        "server.$1."*)
          sk_sv_rr_n=${sk_sv_rr_k#"server.$1."}
          if [ "$sk_sv_rr_n" != _global_ ]; then
            sk_sv_rr_list="$sk_sv_rr_n${sk_sv_rr_list:+$UI_NL$sk_sv_rr_list}"
          fi
          ;;
      esac
    done <<SK_SRV_RR
$SK_REC
SK_SRV_RR
  fi
  if [ -n "$sk_sv_rr_list" ]; then
    printf '%s\n' "$sk_sv_rr_list"
  fi
}

# ---- what the recipes of the packages need ----

# sk__srv_secret_from_daemon: asks a shakekeed binary for its preflight secret (sk_sv_sc_s); for the system daemon with sudo,
# when that can be asked here. The answer is read from a file in the working directory, never shown or logged.
sk__srv_secret_from_daemon() {
  SK_SRV_QUIET=1
  if [ -x "$SK_HOME/bin/shakekeed" ]; then
    SK_SRV_PRIV=0
    if sk__srv_sudo env "SHAKEKEE_HOME=$SK_HOME" "$SK_HOME/bin/shakekeed" print-config --preflight-secret; then
      sk_sv_sc_s=$(sed -n '1p' "$SK_SRV_OUTF")
    fi
  fi
  if [ -z "$sk_sv_sc_s" ] && [ -x /usr/local/bin/shakekeed ]; then
    SK_SRV_PRIV=0
    if sk_is_root || [ "$UI_TTY" = 1 ] || command sudo -n true </dev/null >/dev/null 2>&1; then
      if ! sk_is_root; then
        SK_SRV_PRIV=1
      fi
      if sk__srv_sudo /usr/local/bin/shakekeed print-config --preflight-secret; then
        sk_sv_sc_s=$(sed -n '1p' "$SK_SRV_OUTF")
      fi
    fi
  fi
  : >"$SK_SRV_OUTF"
  SK_SRV_QUIET=0
  SK_SRV_PRIV=0
}

# sk_server_secret [FILE]: SK_SRV_SECRET is the daemon's preflight secret, or empty when it cannot be found. Looked for in:
# SHAKEKEE_PREFLIGHT_SECRET (or the daemon's own SHAKEKEED_PREFLIGHT_SECRET), FILE, the daemon's state under the ShakeKee folder, the system folder, and last by asking the
# daemon (shakekeed print-config --preflight-secret). Never printed, never on a command line, never in the log. A secret
# with characters that cannot be written into a configuration file safely is refused.
sk_server_secret() {
  sk__srv_init
  SK_SRV_SECRET=
  sk_sv_sc_s=
  if [ -n "${SHAKEKEE_PREFLIGHT_SECRET:-${SHAKEKEED_PREFLIGHT_SECRET:-}}" ]; then
    sk_sv_sc_s=${SHAKEKEE_PREFLIGHT_SECRET:-${SHAKEKEED_PREFLIGHT_SECRET:-}}
  else
    sk_home
    for sk_sv_sc_f in "${1:-}" "$SK_HOME/state/preflight.secret" /var/lib/shakekeed/state/preflight.secret /var/lib/shakekeed/preflight.secret; do
      if [ -n "$sk_sv_sc_f" ] && [ -f "$sk_sv_sc_f" ] && [ -r "$sk_sv_sc_f" ]; then
        read -r sk_sv_sc_s <"$sk_sv_sc_f" || true
        if [ -n "$sk_sv_sc_s" ]; then
          break
        fi
      fi
    done
    if [ -z "$sk_sv_sc_s" ]; then
      sk__srv_secret_from_daemon
    fi
  fi
  case $sk_sv_sc_s in
    '') return 1 ;;
    *[!A-Za-z0-9._~+=-]*)
      sk_sv_sc_s=
      ui_warn "The daemon's secret has characters the installer does not write into a configuration file; put it in by hand."
      return 1
      ;;
  esac
  if [ "${#sk_sv_sc_s}" -lt 16 ]; then
    sk_sv_sc_s=
    ui_warn "The daemon's secret is shorter than 16 characters; the installer does not use it."
    return 1
  fi
  SK_SRV_SECRET=$sk_sv_sc_s
  sk_sv_sc_s=
}

# sk_server_fill SRC OUT: OUT is SRC with the daemon's secret (SK_SRV_SECRET, if there is one) in place of CHANGE-ME and the daemon's
# addresses (SK_SRV_PREFLIGHT, SK_SRV_PUBLIC) in place of the defaults, then the sed commands of SK_SRV_EXTRA_SED if there are
# any (no secret in them). The secret goes through a file of mode 0600, never an argument.
sk_server_fill() {
  sk__srv_init
  sk__srv_tmp
  sk_sv_fi_sed=$SK_SRV_T
  (umask 077 && : >"$sk_sv_fi_sed") || return 1
  {
    if [ -n "${SK_SRV_SECRET:-}" ]; then
      printf 's|CHANGE-ME|%s|g\n' "$SK_SRV_SECRET"
    fi
    if [ "$SK_SRV_PREFLIGHT" != 127.0.0.1:7878 ]; then
      printf 's|127\\.0\\.0\\.1:7878|%s|g\n' "$SK_SRV_PREFLIGHT"
    fi
    if [ "$SK_SRV_PUBLIC" != 127.0.0.1:7879 ]; then
      printf 's|127\\.0\\.0\\.1:7879|%s|g\n' "$SK_SRV_PUBLIC"
    fi
    if [ -n "${SK_SRV_EXTRA_SED:-}" ]; then
      printf '%s\n' "$SK_SRV_EXTRA_SED"
    fi
  } >"$sk_sv_fi_sed"
  # the filled file holds the secret: written for this user alone, whatever the umask of the run
  if [ -s "$sk_sv_fi_sed" ]; then
    (umask 077 && sed -f "$sk_sv_fi_sed" "$1" >"$2") || return 1
  else
    (umask 077 && cp "$1" "$2") || return 1
  fi
  rm -f "$sk_sv_fi_sed"
}

# sk_server_place SRC DEST MODE: puts a file of the package in its place. The same text is left alone; a file ShakeKee wrote
# earlier and that was not changed since is replaced (an update); any other file that is there is kept and the call stops,
# unless SERVER_FORCE=1, which keeps a copy of it under backups/ and replaces it. A file of the person's that is replaced is
# recorded as edited (the undo puts the copy back), any other as created. Writes with sudo where this user cannot. A link
# is never followed.
sk_server_place() {
  sk__srv_init
  sk_sv_pl_src=$1
  sk_sv_pl_dest=$2
  sk_sv_pl_mode=$3
  sk_sv_pl_new=$(sk_sha256 "$sk_sv_pl_src")
  if [ -L "$sk_sv_pl_dest" ]; then
    die "$sk_sv_pl_dest is a symbolic link; ShakeKee does not write through links. Remove the link or use --dir DIR."
  fi
  # new: nothing there; own: ShakeKee's, unchanged; edited: ShakeKee's, changed since; foreign: the person's
  sk_sv_pl_was=new
  if [ -e "$sk_sv_pl_dest" ]; then
    sk__srv_hash "$sk_sv_pl_dest" || die "cannot read $sk_sv_pl_dest to compare it (run with sudo, or use --dir DIR)"
    if [ "$SK_SRV_H" = "$sk_sv_pl_new" ]; then
      say "$sk_sv_pl_dest is already up to date."
      return 0
    fi
    sk__srv_created_key "$sk_sv_pl_dest"
    if [ -z "$SK_SRV_CK" ]; then
      sk_sv_pl_was=foreign
    elif [ "$SK_SRV_CH" = "$SK_SRV_H" ]; then
      sk_sv_pl_was=own
    else
      sk_sv_pl_was=edited
    fi
    if [ "$sk_sv_pl_was" != own ] && [ "${SERVER_FORCE:-0}" != 1 ]; then
      die "$sk_sv_pl_dest exists and differs (local edits?). Nothing was changed; use --force to replace it."
    fi
  fi
  if [ "$SK_DRY" = 1 ]; then
    say "[dry-run] install $sk_sv_pl_dest"
    return 0
  fi
  sk_log_open
  SK_SRV_TXN=
  case $sk_sv_pl_was in
    foreign | edited)
      if [ ! -r "$sk_sv_pl_dest" ]; then
        die "$sk_sv_pl_dest differs and cannot be read to make a backup; nothing was changed."
      fi
      sk_backup "$sk_sv_pl_dest"
      sk_sv_pl_bkey=$SK_BACKUP_KEY
      sk_sv_pl_copy=$SK_BACKUP
      ;;
  esac
  if [ "$sk_sv_pl_was" = foreign ]; then
    sk__srv_owner "$sk_sv_pl_dest"
    sk__srv_priv_for "$sk_sv_pl_dest"
    if ! sk__srv_install "$sk_sv_pl_src" "$sk_sv_pl_dest.shakekee-new" "$sk_sv_pl_mode" "$SK_SRV_UID" "$SK_SRV_GID" ||
      ! sk__srv_sudo mv -f "$sk_sv_pl_dest.shakekee-new" "$sk_sv_pl_dest"; then
      die "cannot write $sk_sv_pl_dest: $(sk__srv_head "$SK_SRV_OUTF" 2)"
    fi
    sk_changed "$sk_sv_pl_dest"
  else
    sk__srv_put_own "$sk_sv_pl_src" "$sk_sv_pl_dest" "$sk_sv_pl_mode" "${SK_SRV_OWNER_UID:-}" "${SK_SRV_OWNER_GID:-}" ||
      die "cannot install $sk_sv_pl_dest (run with sudo, or use --dir DIR)"
    if [ "$sk_sv_pl_was" = edited ]; then
      # the file is ShakeKee's again: the copy of what the person had stays under backups/, but is not something to restore
      sk_unrecord "$sk_sv_pl_bkey"
      say "A copy of the file as it was is kept at $sk_sv_pl_copy."
    fi
  fi
  say "Installed $sk_sv_pl_dest."
}

# ---- helpers for the recipes of nginx, apache and caddy ----

# sk_server_choose_sites SERVER: SERVER_SITES holds the --site names. With none, a terminal and no --yes, the sites the
# configuration serves are offered in a menu (none preselected: Enter installs the files only).
sk_server_choose_sites() {
  if [ -n "${SERVER_SITES:-}" ] || [ "$SK_YES" = 1 ] || [ "$UI_TTY" != 1 ] || [ "$UI_MODE" = json ] || [ "$SK_DRY" = 1 ] ||
    [ "${SK_SERVER_EDIT:-}" = no ]; then
    return 0
  fi
  if ! sk_server_select "$1"; then
    return 0
  fi
  sk_sv_cs_names=$(sk_server_names)
  if [ -z "$sk_sv_cs_names" ]; then
    return 0
  fi
  set --
  while IFS= read -r sk_sv_cs_n; do
    set -- "$@" "$sk_sv_cs_n|$sk_sv_cs_n"
  done <<SK_SRV_CS
$sk_sv_cs_names
SK_SRV_CS
  ui_menu_multi "Which sites should be wired up? (none: only the files)" "$@"
  SERVER_SITES=$UI_REPLY
}

# sk_server_offer_sites SERVER: sk_server_offer (the whole integration) for each of SERVER_SITES. SK_SRV_WIRED_N counts the
# sites that are wired now, SK_SRV_FAILED those where it failed (the configuration is then as it was).
sk_server_offer_sites() {
  SK_SRV_WIRED_N=0
  SK_SRV_FAILED=0
  sk__srv_init
  for sk_sv_os_site in ${SERVER_SITES:-}; do
    ui_title "$sk_sv_os_site"
    sk_sv_os_rc=0
    sk_server_offer "$1" "$sk_sv_os_site" full "$SK_SRV_PUBLIC" || sk_sv_os_rc=$?
    case $SK_SERVER_RULE in
      added | present) SK_SRV_WIRED_N=$((SK_SRV_WIRED_N + 1)) ;;
    esac
    if [ "$sk_sv_os_rc" != 0 ]; then
      SK_SRV_FAILED=$((SK_SRV_FAILED + 1))
    fi
  done
  return 0
}

# sk_server_unwire SERVER: takes back everything the package wired in SERVER's configuration (the sites, newest first, then the
# include of the package); what the daemon step wired stays. Fails when something could not be taken back.
sk_server_unwire() {
  sk_sv_uw_bad=0
  sk_sv_uw_sites=$(sk_server_recorded "$1")
  while IFS= read -r sk_sv_uw_s; do
    if [ -n "$sk_sv_uw_s" ]; then
      sk_server_remove "$1" "$sk_sv_uw_s" package || sk_sv_uw_bad=1
    fi
  done <<SK_SRV_UW
$sk_sv_uw_sites
SK_SRV_UW
  if sk_rec_get "server.$1._global_" && [ "$sk_sv_uw_bad" = 0 ]; then
    sk_server_remove "$1" _global_ package || sk_sv_uw_bad=1
  fi
  [ "$sk_sv_uw_bad" = 0 ]
}

# sk_server_check SERVER: one status line per site ShakeKee wired: the per-site file is there and the configuration includes it.
# Fails when one is not.
sk_server_check() {
  sk_sv_ck_bad=0
  sk_sv_ck_sites=$(sk_server_recorded "$1")
  while IFS= read -r sk_sv_ck_s; do
    if [ -z "$sk_sv_ck_s" ]; then
      continue
    fi
    sk_rec_get "server.$1.$sk_sv_ck_s" || continue
    sk__fields "$SK_RV"
    ui_step_start wired "$1 passes $sk_sv_ck_s to ShakeKee ($SK_F1)"
    if [ ! -e "$SK_F3" ]; then
      ui_step_fail "$SK_F3 is gone.${UI_NL}Fix: run the installer again with --site $sk_sv_ck_s, or remove the include line from $SK_F2."
      sk_sv_ck_bad=1
    elif [ -r "$SK_F2" ] && ! grep -Fq -- "$SK_F3" "$SK_F2" 2>/dev/null; then
      ui_step_fail "$SK_F2 no longer includes $SK_F3.${UI_NL}Fix: run the installer again with --site $sk_sv_ck_s."
      sk_sv_ck_bad=1
    else
      ui_step_ok "$SK_F3"
    fi
  done <<SK_SRV_CK
$sk_sv_ck_sites
SK_SRV_CK
  [ "$sk_sv_ck_bad" = 0 ]
}

# sk_server_plan_show: shows the plan (sk_plan_add) without asking. The recipes of the servers ask nothing but the questions of
# the opt-in editing, so that a run with --install keeps working where there is no terminal.
sk_server_plan_show() {
  sk_sv_ps_y=$SK_YES
  SK_YES=1
  sk_plan_confirm
  SK_YES=$sk_sv_ps_y
  SK_PLAN=
}
# ---- account.sh: how an installer without a daemon gets its ShakeKee key (POSIX sh; defines functions only) ----
# Result of sk_account_credentials HOSTNAME, in the variables the recipe configures its integration from:
#   SK_API_KEY (the raw key, may be empty), SK_API_KEY_ID, SK_CREDENTIAL_ID, SK_PRIVATE_KEY_B64
# and in $SK_HOME/credentials/<host>.json (mode 0600, directory 0700, written atomically). The raw key
# is never printed and never on a command line: curl reads the bodies from 0600 files and the secrets
# are written by shell built-ins.
#
# Functions: sk_account_credentials HOST, sk_account_cred_get NAME, sk_account_no_raw_key,
#            sk_account_revoke HOST. Everything else here is named sk_acc_*.
# Needs SK_DASHBOARD_API and SK_DASHBOARD_ORIGIN (baked into the script header) for the "create a key for
# me" path; without them only the paste path is offered. https only, except a loopback http address
# (http://127.0.0.1[:port], http://localhost[:port]) so that the tests can use a fake dashboard.
#
# Errors (help pages: <origin>/help/SK-E5xx):
#   SK-E501  the dashboard did not accept the login request, or cannot be reached
#   SK-E502  the code from the mail is wrong, or the login is locked
#   SK-E503  the authenticator code is wrong
#   SK-E504  the dashboard refused to create the API key
#   SK-E505  the dashboard refused to register the signing key
#   SK-E506  no tool for an Ed25519 key (openssl, node, python3 with cryptography)
#   SK-E507  the pasted (or environment) values are not usable
#   SK-E508  the key could not be removed again
#   SK-E509  the dashboard cannot take the host as a domain, or did not answer the domain requests
#   SK-E510  the domain is not verified (a key can only be limited to a domain the account has verified)
# The key is limited to the host, and the dashboard only takes a host the account has verified: so the create
# path first looks the host up in the account's domains, registers it when it is not there, lets the person
# pick the proof (meta tag, file under /.well-known/shakekee/, DNS TXT record), places it when asked to, and has
# the dashboard check it. Only then is the key made.
# Without a terminal (--yes, no tty, --json) the values come from SHAKEKEE_API_KEY_ID, SHAKEKEE_CREDENTIAL_ID,
# SHAKEKEE_PRIVATE_KEY_B64 (or SHAKEKEE_PRIVATE_KEY) and SHAKEKEE_API_KEY, else from the file saved for the
# host; with neither it stops with SK-E001.

# ---- small helpers ----

# sk_acc_state: defaults for the state of the flow (called first by every public function).
sk_acc_state() {
  : "${SK_ACC_DIR:=}"
  : "${SK_ACC_JAR:=}"
  : "${SK_ACC_LOGGED:=0}"
  : "${SK_ACC_NORAW:=0}"
  : "${SK_ACC_TMO:=30}"
  : "${SK_ACC_ARMED:=0}"
  : "${SK_API_KEY:=}"
  : "${SK_API_KEY_ID:=}"
  : "${SK_CREDENTIAL_ID:=}"
  : "${SK_PRIVATE_KEY_B64:=}"
}

# sk_acc_host HOST: the host name becomes a file name and a JSON string, so it is checked.
sk_acc_host() {
  case ${1-} in
    '' | -* | .* | *..* | *[!A-Za-z0-9._-]*)
      sk_fail 1 SK-E010 "The account flow was given a bad host name: ${1-}" "This is a bug in the installer; please report it."
      ;;
  esac
  SK_ACC_HOST=$1
  SK_ACC_DOM=$(printf '%s' "$1" | tr '[:upper:]' '[:lower:]')
  sk_home
  SK_ACC_FILE=$SK_HOME/credentials/$1.json
}

# sk_acc_safe VALUE: an id or key as the dashboard hands them out (letters, digits, _ . -).
sk_acc_safe() {
  case $1 in
    '' | *[!A-Za-z0-9_.-]*) return 1 ;;
  esac
  [ ${#1} -le 256 ]
}

# sk_acc_trim TEXT: SK_ACC_T is TEXT without white space at both ends.
sk_acc_trim() {
  SK_ACC_T=$(printf '%s\n' "$1" | sed -e 's/^[[:space:]]*//' -e 's/[[:space:]]*$//' | head -n 1) || SK_ACC_T=
}

# sk_acc_jget FILE NAME: SK_ACC_V is the first value of "NAME" (a string, or true/false/a number).
# The answers of the dashboard are flat JSON without commas inside the values we read.
sk_acc_jget() {
  SK_ACC_V=$(tr '{}[],' '\n\n\n\n\n' <"$1" 2>/dev/null |
    sed -n "s/^[[:space:]]*\"$2\"[[:space:]]*:[[:space:]]*\"\{0,1\}\([^\"]*\)\"\{0,1\}[[:space:]]*\$/\1/p" |
    head -n 1) || SK_ACC_V=
}

# sk_acc_endpoints: succeeds when the dashboard can be used. SK_ACC_API (no trailing slash), SK_ACC_ORIGIN, SK_ACC_PROTO.
sk_acc_endpoints() {
  SK_ACC_API=${SK_DASHBOARD_API-}
  SK_ACC_API=${SK_ACC_API%/}
  SK_ACC_ORIGIN=${SK_DASHBOARD_ORIGIN-}
  SK_ACC_ORIGIN=${SK_ACC_ORIGIN%/}
  if [ -z "$SK_ACC_API" ] || [ -z "$SK_ACC_ORIGIN" ]; then
    return 1
  fi
  case $SK_ACC_API$SK_ACC_ORIGIN in
    *[!A-Za-z0-9._~:/-]*) return 1 ;;
  esac
  case $SK_ACC_API in
    https://?*) SK_ACC_PROTO='=https' ;;
    http://127.0.0.1 | http://localhost | http://127.0.0.1:* | http://localhost:*)
      sk_ae=${SK_ACC_API#http://}
      sk_ae=${sk_ae#127.0.0.1}
      sk_ae=${sk_ae#localhost}
      case ${sk_ae#:} in
        *[!0-9]*) return 1 ;;
      esac
      SK_ACC_PROTO='=http'
      ;;
    *) return 1 ;;
  esac
  case $SK_ACC_ORIGIN in
    https://?* | http://127.0.0.1 | http://127.0.0.1:* | http://localhost | http://localhost:*) ;;
    *) return 1 ;;
  esac
}

# ---- the session with the dashboard ----

# sk_acc_session: a private directory for the cookie jar and the bodies (inside the installer's own
# temporary directory, which the exit trap removes), and the exit trap that also signs out.
sk_acc_session() {
  if [ -n "$SK_ACC_DIR" ] && [ -d "$SK_ACC_DIR" ]; then
    return 0
  fi
  if [ -z "${SK_TASK-}" ] || [ ! -d "$SK_TASK" ]; then
    SK_TASK=$(mktemp -d "${TMPDIR:-/tmp}/shakekee-install.XXXXXX") ||
      sk_fail 1 SK-E006 "Cannot create a temporary directory." "Check that ${TMPDIR:-/tmp} is writable."
  fi
  SK_ACC_DIR=$SK_TASK/account
  SK_ACC_JAR=$SK_ACC_DIR/jar
  if ! (umask 077 && mkdir "$SK_ACC_DIR" && : >"$SK_ACC_JAR" && : >"$SK_ACC_DIR/body" && : >"$SK_ACC_DIR/out") 2>/dev/null; then
    sk_fail 1 SK-E006 "Cannot create a private directory in ${TMPDIR:-/tmp}." "Check that it is writable."
  fi
  if [ "$SK_ACC_ARMED" = 0 ] && command -v sk_on_exit >/dev/null 2>&1; then
    SK_ACC_ARMED=1
    trap sk_acc_on_exit EXIT
  fi
}

# The exit trap of the flow: sign out, remove the jar, then do what the framework does (it needs the
# status the script is leaving with).
sk_acc_on_exit() {
  sk_acc_rc=$?
  sk_acc_end
  # sk_on_exit starts from the status the script is leaving with ("set -e" must not stop us here)
  (exit "$sk_acc_rc") && sk_on_exit || sk_on_exit
}

# sk_acc_end: signs out (when signed in) and removes everything the session left on disk. Safe to repeat.
sk_acc_end() {
  if [ "${SK_ACC_LOGGED:-0}" = 1 ] && [ -n "${SK_ACC_DIR:-}" ] && [ -d "$SK_ACC_DIR" ]; then
    SK_ACC_LOGGED=0
    SK_ACC_TMO=10
    sk_acc_http POST /api/v1/dashboard/auth/logout || true
  fi
  if [ -n "${SK_ACC_DIR:-}" ]; then
    rm -rf "$SK_ACC_DIR" 2>/dev/null || true
    SK_ACC_DIR=
    SK_ACC_JAR=
  fi
}

# sk_acc_disarm: the flow is over; the framework's exit trap is back.
sk_acc_disarm() {
  if [ "$SK_ACC_ARMED" = 1 ]; then
    SK_ACC_ARMED=0
    trap sk_on_exit EXIT
  fi
}

# sk_acc_body JSON: the request body in a private file.
sk_acc_body() {
  printf '%s' "$1" >"$SK_ACC_DIR/body"
}

# sk_acc_http METHOD PATH [BODY]: one request. SK_ACC_CODE is the HTTP status (000 without an answer),
# the answer is in $SK_ACC_DIR/out. BODY is JSON (any text, it is written to a file for curl).
sk_acc_http() {
  sk_ah_m=$1
  sk_ah_p=$2
  if [ $# -ge 3 ]; then
    sk_acc_body "$3"
  fi
  : >"$SK_ACC_DIR/out"
  set -- --silent --max-time "$SK_ACC_TMO" --proto "$SK_ACC_PROTO" --tlsv1.2 \
    --request "$sk_ah_m" \
    --header "Origin: $SK_ACC_ORIGIN" --header 'Content-Type: application/json' --header 'Accept: application/json' \
    --cookie "$SK_ACC_JAR" --cookie-jar "$SK_ACC_JAR" \
    --output "$SK_ACC_DIR/out" --write-out '%{http_code}'
  if [ "$sk_ah_m" != DELETE ] && [ "$sk_ah_m" != GET ]; then
    set -- "$@" --data "@$SK_ACC_DIR/body"
  fi
  SK_ACC_CURL=0
  SK_ACC_CODE=$(curl "$@" "$SK_ACC_API$sk_ah_p" 2>/dev/null </dev/null) || SK_ACC_CURL=$?
  case $SK_ACC_CODE in
    [1-5][0-9][0-9]) ;;
    *) SK_ACC_CODE=000 ;;
  esac
  : >"$SK_ACC_DIR/body"
  SK_ACC_TMO=30
  sk_log "account: $sk_ah_m $sk_ah_p -> $SK_ACC_CODE"
  case $SK_ACC_CODE in
    2??) return 0 ;;
  esac
  return 1
}

# sk_acc_why: SK_ACC_WHY says what the last answer was (the status and the dashboard's own short message).
sk_acc_why() {
  if [ "$SK_ACC_CODE" = 000 ]; then
    SK_ACC_WHY="no answer from $SK_ACC_API (curl status $SK_ACC_CURL)"
    return 0
  fi
  sk_acc_jget "$SK_ACC_DIR/out" error
  SK_ACC_V=$(printf '%s' "$SK_ACC_V" | tr -cd 'A-Za-z0-9 .,:;_()/-' | cut -c 1-100)
  SK_ACC_WHY="HTTP $SK_ACC_CODE${SK_ACC_V:+ ($SK_ACC_V)}"
}

# ---- reading what the person types ----

# sk_acc_email_ok TEXT
sk_acc_email_ok() {
  case $1 in
    '' | *[[:space:]]* | *[[:cntrl:]]* | *\"* | *\\* | *,* | *\<* | *\>* | *\'* | *\;*) return 1 ;;
    ?*@?*.?*) ;;
    *) return 1 ;;
  esac
  case ${1#*@} in
    *@*) return 1 ;;
  esac
  [ ${#1} -le 254 ]
}

# sk_acc_unpct: undoes the percent-escapes a link wrapper puts on the real link.
sk_acc_unpct() {
  sed -e 's/%3[Aa]/:/g' -e 's/%2[Ff]/\//g' -e 's/%3[Ff]/?/g' -e 's/%3[Dd]/=/g' -e 's/%26/\&/g' -e 's/%23/#/g' -e 's/%25/%/g'
}

# sk_acc_parse TEXT: what the person pasted. SK_ACC_KIND is code, token or bad; SK_ACC_VAL the code as
# XXXX-XXXX (input is case-insensitive, dash and spaces optional, O reads as 0, I and L as 1) or the
# token of a link. A link is only read for its token, it is never fetched. A wrapper of a mail scanner
# (Microsoft Safe Links: the real link is URL-encoded in url=) is unwrapped, at most twice.
sk_acc_parse() {
  SK_ACC_KIND=bad
  SK_ACC_VAL=
  sk_pp=$(printf '%s' "$1" | tr -d ' \t\r\n"<>')
  case $sk_pp in
    *token=* | *url=* | *://*)
      sk_pn=0
      while [ $sk_pn -lt 2 ]; do
        case $sk_pp in
          *token=*) break ;;
          *url=*)
            sk_pp=${sk_pp#*url=}
            sk_pp=${sk_pp%%&*}
            sk_pp=$(printf '%s' "$sk_pp" | sk_acc_unpct)
            ;;
          *) break ;;
        esac
        sk_pn=$((sk_pn + 1))
      done
      case $sk_pp in
        *token=*)
          sk_pp=${sk_pp#*token=}
          sk_pp=${sk_pp%%[&#]*}
          case $sk_pp in
            *[!A-Za-z0-9_-]*) return 0 ;;
          esac
          if [ ${#sk_pp} -ge 16 ] && [ ${#sk_pp} -le 200 ]; then
            SK_ACC_KIND=token
            SK_ACC_VAL=$sk_pp
          fi
          ;;
      esac
      return 0
      ;;
  esac
  sk_pc=$(printf '%s' "$sk_pp" | tr 'a-z' 'A-Z' | tr -d '-' | tr 'OIL' '011')
  case $sk_pc in
    *[!0123456789ABCDEFGHJKMNPQRSTVWXYZ]*) ;;
    ????????)
      SK_ACC_KIND=code
      SK_ACC_VAL=${sk_pc%????}-${sk_pc#????}
      return 0
      ;;
  esac
  # a bare token: hexadecimal, as long as the ones in the links
  case $sk_pp in
    *[!0-9A-Fa-f]*) ;;
    *)
      if [ ${#sk_pp} -ge 32 ] && [ ${#sk_pp} -le 128 ]; then
        SK_ACC_KIND=token
        SK_ACC_VAL=$sk_pp
      fi
      ;;
  esac
}

# ---- sign in with the code from the mail ----

# sk_acc_login: asks for the email, has the dashboard mail a code, redeems it (or the pasted link) and
# completes the authenticator step when the account has one. Leaves the session in the cookie jar.
sk_acc_login() {
  sk_acc_session
  ui_title "Sign in to ShakeKee"
  ui_note "ShakeKee mails you a code. Enter the email of your ShakeKee account; the code or the link from the mail is all you need next."
  sk_al_n=0
  while :; do
    ui_ask "Your email address"
    sk_acc_trim "$UI_REPLY"
    SK_ACC_EMAIL=$SK_ACC_T
    if sk_acc_email_ok "$SK_ACC_EMAIL"; then
      break
    fi
    ui_warn "That does not look like an email address."
    sk_al_n=$((sk_al_n + 1))
    if [ $sk_al_n -ge 3 ]; then
      sk_error SK-E501 "No usable email address was given." "Run the installer again and type the address of your ShakeKee account."
    fi
  done
  ui__jq "$SK_ACC_EMAIL"
  sk_al_ej=$UI_J
  ui_step_start mail "Asking ShakeKee to mail a code to $SK_ACC_EMAIL"
  if sk_acc_http POST /api/v1/dashboard/auth/magic-link "{\"email\":$sk_al_ej,\"want_code\":true}"; then
    ui_step_ok
  else
    sk_acc_why
    ui_step_fail "$SK_ACC_WHY"
    case $SK_ACC_CODE in
      429) sk_error SK-E501 "ShakeKee will not send another code right now." "Wait an hour, use the code from the last mail, or choose \"I already have one\" and paste a key you made in the dashboard." ;;
      403) sk_error SK-E501 "The dashboard refused the request." "The installer and the dashboard disagree about the dashboard address ($SK_ACC_ORIGIN). Please report this." ;;
    esac
    sk_error SK-E501 "The dashboard did not accept the login request ($SK_ACC_WHY)." "Check your connection and the email address, wait a minute and run the installer again, or choose \"I already have one\" and paste a key you made in the dashboard."
  fi
  ui_say ""
  ui_say "Look for the mail from ShakeKee (also in the spam folder). It shows a code like K7QX-92MD."
  sk_al_n=0
  while :; do
    ui_ask "Code from the mail (or paste the link)"
    sk_acc_parse "$UI_REPLY"
    case $SK_ACC_KIND in
      bad)
        ui_warn "That is neither a code (8 letters and digits) nor a link from the mail. It was not sent."
        sk_al_n=$((sk_al_n + 1))
        if [ $sk_al_n -ge 5 ]; then
          sk_error SK-E502 "No usable code was given." "Run the installer again to get a new code."
        fi
        continue
        ;;
      code)
        ui__jq "$SK_ACC_VAL"
        sk_acc_http POST /api/v1/dashboard/auth/verify-code "{\"email\":$sk_al_ej,\"code\":$UI_J}" || :
        ;;
      token)
        ui__jq "$SK_ACC_VAL"
        sk_acc_http POST /auth/verify "{\"token\":$UI_J}" || :
        ;;
    esac
    case $SK_ACC_CODE in
      2??) break ;;
      401)
        sk_al_n=$((sk_al_n + 1))
        # five wrong tries cancel the login at the dashboard
        if [ $sk_al_n -ge 3 ]; then
          sk_error SK-E502 "The code was not accepted three times; the login may be locked now." "Run the installer again to get a new code."
        fi
        ui_warn "That code was not accepted (it may have expired). Try again."
        ;;
      429)
        sk_error SK-E502 "The dashboard is limiting login tries from this address." "Wait a minute and run the installer again."
        ;;
      *)
        sk_acc_why
        sk_error SK-E501 "The login could not be completed ($SK_ACC_WHY)." "Check your connection and run the installer again."
        ;;
    esac
  done
  SK_ACC_LOGGED=1
  sk_acc_jget "$SK_ACC_DIR/out" totp_pending
  if [ "$SK_ACC_V" = true ]; then
    ui_note "Your account is protected by an authenticator app."
    sk_al_n=0
    while :; do
      ui_ask "6-digit code from your authenticator"
      sk_acc_trim "$UI_REPLY"
      sk_al_c=$(printf '%s' "$SK_ACC_T" | tr -d ' ')
      case $sk_al_c in
        [0-9][0-9][0-9][0-9][0-9][0-9])
          if sk_acc_http POST /api/v1/dashboard/auth/totp/verify "{\"code\":\"$sk_al_c\"}"; then
            break
          fi
          if [ "$SK_ACC_CODE" != 401 ]; then
            sk_acc_why
            sk_error SK-E503 "The authenticator code could not be checked ($SK_ACC_WHY)." "Run the installer again."
          fi
          ;;
        *) ui_warn "That is not a 6-digit code." ;;
      esac
      sk_al_n=$((sk_al_n + 1))
      if [ $sk_al_n -ge 3 ]; then
        sk_error SK-E503 "The authenticator code was wrong three times." "Wait for the next code in your app and run the installer again. Five wrong codes lock the login."
      fi
      ui_warn "That code was not accepted. Wait for the next one in your app and try again."
    done
  fi
  ui_say "Signed in."
}

# sk_acc_logout: ends the session at the dashboard and removes the jar (the flow's normal ending).
sk_acc_logout() {
  if [ "$SK_ACC_LOGGED" = 1 ]; then
    ui_step_start logout "Signing out"
    SK_ACC_LOGGED=0
    SK_ACC_TMO=10
    if sk_acc_http POST /api/v1/dashboard/auth/logout; then
      ui_step_ok
    else
      ui_step_warn "the dashboard did not confirm; the session ends by itself"
    fi
  fi
  sk_acc_end
  sk_acc_disarm
}

# ---- the domain of the key ----

# sk_acc_domain_ok HOST: succeeds when the dashboard can take HOST as a domain (the rules of its domain service:
# a name with a dot, no IP address, not under .localhost, .local or .internal).
sk_acc_domain_ok() {
  case $1 in
    *.localhost | *.local | *.internal) return 1 ;;
    *.*) ;;
    *) return 1 ;;
  esac
  case $1 in
    *[!0-9.]*) return 0 ;;
  esac
  return 1
}

# sk_acc_dom_find: looks for $SK_ACC_DOM in the list of domains in $SK_ACC_DIR/out. SK_AD_ID, SK_AD_KEY,
# SK_AD_OK (true or false) and SK_AD_METHOD are its values, all empty when the account does not have it.
sk_acc_dom_find() {
  SK_AD_ID=
  SK_AD_KEY=
  SK_AD_OK=
  SK_AD_METHOD=
  tr '{}[]' '\n\n\n\n' <"$SK_ACC_DIR/out" >"$SK_ACC_DIR/lines" 2>/dev/null || :
  while IFS= read -r sk_df_l; do
    case $sk_df_l in
      *"$SK_ACC_DOM"*) ;;
      *) continue ;;
    esac
    printf '%s\n' "$sk_df_l" >"$SK_ACC_DIR/dom"
    sk_acc_jget "$SK_ACC_DIR/dom" domain
    if [ "$SK_ACC_V" = "$SK_ACC_DOM" ]; then
      sk_acc_jget "$SK_ACC_DIR/dom" id
      SK_AD_ID=$SK_ACC_V
      sk_acc_jget "$SK_ACC_DIR/dom" validation_key
      SK_AD_KEY=$SK_ACC_V
      sk_acc_jget "$SK_ACC_DIR/dom" validated
      SK_AD_OK=$SK_ACC_V
      sk_acc_jget "$SK_ACC_DIR/dom" validation_method
      SK_AD_METHOD=$SK_ACC_V
      break
    fi
  done <"$SK_ACC_DIR/lines"
  : >"$SK_ACC_DIR/lines"
  : >"$SK_ACC_DIR/dom"
}

# sk_acc_find_index: SK_AD_INDEX is the static home page (index.html) of the host when it is in one of the usual
# places, SK_AD_ROOT its directory.
sk_acc_find_index() {
  SK_AD_INDEX=
  SK_AD_ROOT=
  sk_fi_roots=${SHAKEKEE_SITE_ROOTS:-"/var/www /srv/www /srv ${HOME:-/nonexistent}/public_html ${HOME:-/nonexistent}/www"}
  for sk_fi_r in $sk_fi_roots; do
    for sk_fi_d in "$sk_fi_r" "$sk_fi_r/$SK_ACC_DOM" "$sk_fi_r/html" "$sk_fi_r/public_html" "$sk_fi_r/htdocs" "$sk_fi_r/httpdocs" "$sk_fi_r/$SK_ACC_DOM/html" "$sk_fi_r/$SK_ACC_DOM/public_html"; do
      for sk_fi_f in index.html index.htm; do
        if [ -f "$sk_fi_d/$sk_fi_f" ] && [ ! -L "$sk_fi_d/$sk_fi_f" ]; then
          SK_AD_INDEX=$sk_fi_d/$sk_fi_f
          SK_AD_ROOT=$sk_fi_d
          return 0
        fi
      done
    done
  done
  return 1
}

# sk_acc_dom_path TEXT: succeeds when TEXT is an absolute path of letters, digits and / . _ - only.
sk_acc_dom_path() {
  case $1 in
    /?*) ;;
    *) return 1 ;;
  esac
  case $1 in
    *[!A-Za-z0-9/._-]* | *..*) return 1 ;;
  esac
  return 0
}

# sk_acc_has FILE REGEX: succeeds when a line of FILE matches the basic regular expression.
sk_acc_has() {
  [ -n "$(sed -n "/$2/{p;q;}" "$1" 2>/dev/null)" ]
}

# sk_acc_dom_tag FILE: puts the meta tag right after the <head> of FILE (backup first, undone by --uninstall).
# Returns 1, having said why, when that is not possible.
sk_acc_dom_tag() {
  sk_dt_f=$1
  if [ ! -f "$sk_dt_f" ] || [ -L "$sk_dt_f" ]; then
    ui_warn "$sk_dt_f is not a plain file."
    return 1
  fi
  if sk_acc_has "$sk_dt_f" "$SK_AD_KEY"; then
    ui_say "The tag is already in $sk_dt_f."
    return 0
  fi
  if sk_acc_has "$sk_dt_f" 'shakekee-validation'; then
    ui_warn "$sk_dt_f has a shakekee-validation tag with another key. Remove that tag first."
    return 1
  fi
  sk_dt_re='<[Hh][Ee][Aa][Dd]\([[:space:]][^>]*\)\{0,1\}>'
  sk_dt_n=$(sed -n "/$sk_dt_re/{=;q;}" "$sk_dt_f" 2>/dev/null)
  if [ -z "$sk_dt_n" ]; then
    ui_warn "$sk_dt_f has no <head> tag on one line to put the tag after."
    return 1
  fi
  sk_dt_dir=${sk_dt_f%/*}
  if [ ! -w "$sk_dt_f" ] || [ ! -w "${sk_dt_dir:-/}" ]; then
    ui_warn "This user cannot change $sk_dt_f."
    return 1
  fi
  sk_edit_begin "$sk_dt_f"
  sk_dt_w=$(sk_edit_path "$sk_dt_f")
  sed "${sk_dt_n}s/$sk_dt_re/&<meta name=\"shakekee-validation\" content=\"$SK_AD_KEY\">/" <"$sk_dt_f" >"$sk_dt_w"
  sk_edit_commit "$sk_dt_f"
  ui_say "The tag is in $sk_dt_f (a copy of the page from before is in $SK_HOME/backups/)."
  return 0
}

# sk_acc_dom_file ROOT: writes the proof file under ROOT/.well-known/shakekee/ (undone by --uninstall).
sk_acc_dom_file() {
  sk_dw_root=$1
  if [ ! -d "$sk_dw_root" ] || [ ! -w "$sk_dw_root" ]; then
    ui_warn "$sk_dw_root is not a directory this user can write to."
    return 1
  fi
  for sk_dw_d in "$sk_dw_root/.well-known" "$sk_dw_root/.well-known/shakekee"; do
    if [ -L "$sk_dw_d" ] || { [ -e "$sk_dw_d" ] && [ ! -d "$sk_dw_d" ]; }; then
      ui_warn "$sk_dw_d is not a plain directory."
      return 1
    fi
    if [ ! -d "$sk_dw_d" ]; then
      mkdir "$sk_dw_d" || return 1
      sk_created "$sk_dw_d"
    fi
  done
  sk_dw_f=$sk_dw_root/.well-known/shakekee/validation
  if [ -L "$sk_dw_f" ] || [ ! -w "$sk_dw_root/.well-known/shakekee" ]; then
    ui_warn "This user cannot write $sk_dw_f."
    return 1
  fi
  sk_edit_begin "$sk_dw_f"
  sk_dw_w=$(sk_edit_path "$sk_dw_f")
  printf '{"validation_key":"%s"}\n' "$SK_AD_KEY" >"$sk_dw_w"
  sk_edit_commit "$sk_dw_f"
  ui_say "The file is $sk_dw_f"
  return 0
}

# sk_acc_dom_method: SK_AD_M is the proof the person picks (meta, file or dns).
sk_acc_dom_method() {
  sk_acc_find_index || :
  if [ -n "$SK_AD_INDEX" ]; then
    set -- "+meta|A meta tag on the home page|found $SK_AD_INDEX" "file|A file at /.well-known/shakekee/validation|in the web root of the site" "dns|A DNS TXT record|you add it at your DNS provider"
  else
    set -- "meta|A meta tag on the home page|in the <head> of the page" "file|A file at /.well-known/shakekee/validation|in the web root of the site" "+dns|A DNS TXT record|you add it at your DNS provider"
  fi
  ui_menu_single "How do you prove that $SK_ACC_DOM is yours?" "$@"
  SK_AD_M=$UI_REPLY
}

# sk_acc_dom_place: shows the proof, or places it when the person lets the installer do it.
sk_acc_dom_place() {
  case $SK_AD_M in
    meta)
      sk_dp_tag="<meta name=\"shakekee-validation\" content=\"$SK_AD_KEY\">"
      sk_dp_done=0
      if [ -n "$SK_AD_INDEX" ]; then
        ui_menu_single "Who puts the meta tag on the home page?" "+file|The installer adds it to $SK_AD_INDEX|a backup of the file is made first" "other|The installer adds it to another file|you name the file" "self|I add the tag myself|the tag is shown"
      else
        ui_menu_single "Who puts the meta tag on the home page?" "+other|The installer adds it to a file|you name the file" "self|I add the tag myself|the tag is shown"
      fi
      case $UI_REPLY in
        file) sk_acc_dom_tag "$SK_AD_INDEX" && sk_dp_done=1 ;;
        other)
          ui_ask "Full path of the home page file (empty: you add the tag yourself)" ""
          sk_acc_trim "$UI_REPLY"
          if [ -z "$SK_ACC_T" ]; then
            :
          elif sk_acc_dom_path "$SK_ACC_T"; then
            sk_acc_dom_tag "$SK_ACC_T" && sk_dp_done=1
          else
            ui_warn "That is not an absolute path of letters, digits and / . _ - only."
          fi
          ;;
      esac
      if [ "$sk_dp_done" = 0 ]; then
        ui_title "Add this tag to the <head> of your home page"
        ui_copy_hint "$sk_dp_tag" "the meta tag"
      fi
      ;;
    file)
      sk_dp_done=0
      if [ -n "$SK_AD_ROOT" ]; then
        ui_menu_single "Who puts the file in the web root?" "+root|The installer writes it to $SK_AD_ROOT|under .well-known/shakekee/" "other|The installer writes it to another directory|you name the web root" "self|I put the file there myself|the content is shown"
      else
        ui_menu_single "Who puts the file in the web root?" "+other|The installer writes it|you name the web root of the site" "self|I put the file there myself|the content is shown"
      fi
      case $UI_REPLY in
        root) sk_acc_dom_file "$SK_AD_ROOT" && sk_dp_done=1 ;;
        other)
          ui_ask "Full path of the web root (empty: you put the file there yourself)" ""
          sk_acc_trim "$UI_REPLY"
          if [ -z "$SK_ACC_T" ]; then
            :
          elif sk_acc_dom_path "$SK_ACC_T"; then
            sk_acc_dom_file "$SK_ACC_T" && sk_dp_done=1
          else
            ui_warn "That is not an absolute path of letters, digits and / . _ - only."
          fi
          ;;
      esac
      if [ "$sk_dp_done" = 0 ]; then
        ui_title "Serve this file at https://$SK_ACC_DOM/.well-known/shakekee/validation"
        ui_copy_hint "{\"validation_key\":\"$SK_AD_KEY\"}" "the content of the file"
      fi
      ;;
    dns)
      ui_title "Add this DNS record"
      ui_say "  Type: TXT    Name: $SK_ACC_DOM"
      ui_copy_hint "shakekee-validation=$SK_AD_KEY" "the value"
      ui_note "The record has to stay in place: ShakeKee checks it again from time to time."
      ;;
  esac
}

# sk_acc_dom_register: makes the account know $SK_ACC_DOM with the method SK_AD_M (SK_AD_ID and SK_AD_KEY).
sk_acc_dom_register() {
  ui__jq "$SK_ACC_DOM"
  sk_dr_d=$UI_J
  ui_step_start register_domain "Registering $SK_ACC_DOM in your account"
  if sk_acc_http POST /api/v1/dashboard/domains "{\"domain\":$sk_dr_d,\"method\":\"$SK_AD_M\"}"; then
    sk_acc_jget "$SK_ACC_DIR/out" id
    SK_AD_ID=$SK_ACC_V
    sk_acc_jget "$SK_ACC_DIR/out" validation_key
    SK_AD_KEY=$SK_ACC_V
    : >"$SK_ACC_DIR/out"
    if sk_acc_safe "$SK_AD_ID" && sk_acc_safe "$SK_AD_KEY"; then
      ui_step_ok
      return 0
    fi
    ui_step_fail "the answer did not hold a validation key"
  else
    sk_acc_why
    ui_step_fail "$SK_ACC_WHY"
  fi
  sk_error SK-E509 "The dashboard did not take $SK_ACC_DOM as a domain ($SK_ACC_WHY)." "Add the domain on the Domains page of the dashboard and verify it there, then run the installer again."
}

# sk_acc_dom_check: asks the dashboard to check the proof; succeeds when the domain is verified. SK_AD_WHY says why not.
# The dashboard checks a domain at most every 30 seconds (it answers 409 before that), so the installer waits.
sk_acc_dom_check() {
  sk_dc_n=0
  while :; do
    ui_step_start verify_domain "Asking ShakeKee to check the proof for $SK_ACC_DOM"
    SK_AD_WHY=
    if sk_acc_http POST "/api/v1/dashboard/domains/$SK_AD_ID/verify"; then
      sk_acc_jget "$SK_ACC_DIR/out" validated
      if [ "$SK_ACC_V" = true ]; then
        : >"$SK_ACC_DIR/out"
        ui_step_ok
        return 0
      fi
      sk_acc_jget "$SK_ACC_DIR/out" error
      SK_AD_WHY=$(printf '%s' "$SK_ACC_V" | tr -cd 'A-Za-z0-9 .,:;_()/-' | cut -c 1-100)
      if [ "$SK_AD_WHY" = null ]; then
        SK_AD_WHY=
      fi
      : >"$SK_ACC_DIR/out"
      ui_step_fail "${SK_AD_WHY:-the proof was not found}"
      return 1
    fi
    if [ "$SK_ACC_CODE" = 409 ] && [ $sk_dc_n -lt 2 ]; then
      sk_dc_n=$((sk_dc_n + 1))
      ui_step_warn "the last check was a moment ago; there are 30 seconds between two checks"
      sleep "${SHAKEKEE_NAP:-30}"
      continue
    fi
    sk_acc_why
    SK_AD_WHY=$SK_ACC_WHY
    ui_step_fail "$SK_ACC_WHY"
    return 1
  done
}

# sk_acc_domain: makes sure the account has $SK_ACC_DOM verified (the dashboard makes keys only for such domains).
sk_acc_domain() {
  ui_step_start find_domain "Looking for $SK_ACC_DOM in your account"
  if ! sk_acc_http GET /api/v1/dashboard/domains; then
    sk_acc_why
    ui_step_fail "$SK_ACC_WHY"
    sk_error SK-E509 "The list of your domains could not be read ($SK_ACC_WHY)." "Run the installer again; if it happens again, make the key in the dashboard and choose \"I already have one\"."
  fi
  sk_acc_dom_find
  : >"$SK_ACC_DIR/out"
  if [ "$SK_AD_OK" = true ]; then
    ui_step_ok "verified"
    return 0
  fi
  if [ -n "$SK_AD_ID" ]; then
    ui_step_ok "known, not verified yet"
  else
    ui_step_ok "not there yet"
  fi
  ui_note "A key can only be made for a domain you have proved to be yours."
  sk_ad_have=$SK_AD_METHOD
  while :; do
    sk_acc_dom_method
    if [ -n "$SK_AD_ID" ] && [ "$sk_ad_have" != "$SK_AD_M" ]; then
      # the method is fixed when a domain is registered: start again with the new one
      ui_step_start drop_domain "Removing the earlier registration of $SK_ACC_DOM"
      if sk_acc_http DELETE "/api/v1/dashboard/domains/$SK_AD_ID"; then
        ui_step_ok
      else
        sk_acc_why
        ui_step_fail "$SK_ACC_WHY"
        sk_error SK-E509 "The earlier registration of $SK_ACC_DOM could not be changed ($SK_ACC_WHY)." "Remove $SK_ACC_DOM on the Domains page of the dashboard and run the installer again."
      fi
      SK_AD_ID=
    fi
    if [ -z "$SK_AD_ID" ]; then
      sk_acc_dom_register
      sk_ad_have=$SK_AD_M
    fi
    sk_acc_dom_place
    sk_ad_switch=0
    while [ "$sk_ad_switch" = 0 ]; do
      ui_menu_single "Is the proof in place?" "+check|Yes, check it now|ShakeKee looks it up itself" "again|Show it again" "stop|Stop here|the domain stays registered; run the installer again later"
      case $UI_REPLY in
        again) sk_acc_dom_place ;;
        stop) sk_error SK-E510 "The domain $SK_ACC_DOM is not verified yet." "Put the proof in place and run the installer again: it picks up the registered domain." ;;
        check)
          if sk_acc_dom_check; then
            if [ "$SK_AD_M" = dns ]; then
              sk_remains "Keep the DNS TXT record shakekee-validation=$SK_AD_KEY at $SK_ACC_DOM while ShakeKee protects the site; remove it afterwards."
            fi
            return 0
          fi
          ui_menu_single "What now?" "+retry|Check again|DNS changes can take a few minutes" "method|Use another way to prove it" "stop|Stop here|the domain stays registered; run the installer again later"
          case $UI_REPLY in
            method) sk_ad_switch=1 ;;
            stop) sk_error SK-E510 "The domain $SK_ACC_DOM could not be verified${SK_AD_WHY:+ ($SK_AD_WHY)}." "Put the proof in place and run the installer again: it picks up the registered domain." ;;
          esac
          ;;
      esac
    done
  done
}

# ---- the Ed25519 key pair ----

# sk_acc_tool: SK_ACC_TOOL is the first tool that can make an Ed25519 key (openssl, node, python3), else empty.
sk_acc_tool() {
  SK_ACC_TOOL=
  if command -v openssl >/dev/null 2>&1 && command -v tail >/dev/null 2>&1 &&
    openssl genpkey -algorithm ed25519 >/dev/null 2>&1; then
    SK_ACC_TOOL=openssl
  elif command -v node >/dev/null 2>&1 &&
    node -e 'require("crypto").generateKeyPairSync("ed25519")' >/dev/null 2>&1; then
    SK_ACC_TOOL=node
  elif command -v python3 >/dev/null 2>&1 &&
    python3 -I -c 'from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey' >/dev/null 2>&1; then
    SK_ACC_TOOL=python3
  fi
  [ -n "$SK_ACC_TOOL" ]
}

# sk_acc_keygen: a new pair. SK_ACC_PRIV is the base64 of the PKCS#8 DER private key (48 bytes), SK_ACC_PUB the
# base64 of the 32 raw bytes of the public key: what the dashboard's browser code (WebCrypto exportKey
# pkcs8 / raw) makes. The private key stays in a shell variable and a 0600 file that is removed at once.
sk_acc_keygen() {
  SK_ACC_PRIV=
  SK_ACC_PUB=
  case $SK_ACC_TOOL in
    openssl)
      sk_kd=$SK_ACC_DIR/ed25519.der
      if (umask 077 && openssl genpkey -algorithm ed25519 -outform DER -out "$sk_kd") 2>/dev/null; then
        SK_ACC_PRIV=$(openssl base64 -A -in "$sk_kd" 2>/dev/null) || SK_ACC_PRIV=
        SK_ACC_PUB=$(openssl pkey -inform DER -in "$sk_kd" -pubout -outform DER 2>/dev/null | tail -c 32 | openssl base64 -A 2>/dev/null) || SK_ACC_PUB=
      fi
      rm -f "$sk_kd"
      ;;
    node)
      sk_ko=$(node -e 'const c = require("crypto"); const k = c.generateKeyPairSync("ed25519");
process.stdout.write(k.privateKey.export({ type: "pkcs8", format: "der" }).toString("base64") + "\n" +
  k.publicKey.export({ type: "spki", format: "der" }).subarray(-32).toString("base64") + "\n");' 2>/dev/null) || sk_ko=
      SK_ACC_PRIV=${sk_ko%%"$UI_NL"*}
      SK_ACC_PUB=${sk_ko#*"$UI_NL"}
      ;;
    python3)
      sk_ko=$(python3 -I -c 'import base64, sys
from cryptography.hazmat.primitives import serialization as s
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
k = Ed25519PrivateKey.generate()
sys.stdout.write(base64.b64encode(k.private_bytes(s.Encoding.DER, s.PrivateFormat.PKCS8, s.NoEncryption())).decode() + "\n"
  + base64.b64encode(k.public_key().public_bytes(s.Encoding.Raw, s.PublicFormat.Raw)).decode() + "\n")' 2>/dev/null) || sk_ko=
      SK_ACC_PRIV=${sk_ko%%"$UI_NL"*}
      SK_ACC_PUB=${sk_ko#*"$UI_NL"}
      ;;
  esac
  sk_ko=
  # 48 bytes of PKCS#8 are 64 characters that start with the Ed25519 header, 32 bytes are 43 and a "="
  case $SK_ACC_PRIV in
    MC4CAQAwBQYDK2VwBCIEI*) ;;
    *) SK_ACC_PRIV= ;;
  esac
  case $SK_ACC_PRIV in
    *[!A-Za-z0-9+/]*) SK_ACC_PRIV= ;;
  esac
  case $SK_ACC_PUB in
    *[!A-Za-z0-9+/=]*) SK_ACC_PUB= ;;
  esac
  if [ ${#SK_ACC_PRIV} != 64 ] || [ ${#SK_ACC_PUB} != 44 ]; then
    SK_ACC_PRIV=
    SK_ACC_PUB=
    return 1
  fi
}

# sk_acc_b64e / sk_acc_b64d: base64 on a pipe, with openssl or the base64 command.
sk_acc_b64e() {
  if command -v openssl >/dev/null 2>&1; then
    openssl base64 -A
  else
    base64 | tr -d '\n'
  fi
}
sk_acc_b64d() {
  if command -v openssl >/dev/null 2>&1; then
    openssl base64 -d -A
  else
    base64 -d
  fi
}

# sk_acc_priv_check TEXT: a private key as pasted or taken from the environment. Succeeds with SK_ACC_PRIVN,
# the base64 of the 48-byte PKCS#8 DER key (a 32-byte seed is wrapped in the PKCS#8 header); else
# SK_ACC_WHY says what is wrong, never with the value in it.
sk_acc_priv_check() {
  sk_pk=$(printf '%s' "$1" | tr -d ' \t\r\n')
  SK_ACC_PRIVN=
  case $sk_pk in
    '')
      SK_ACC_WHY="it is empty"
      return 1
      ;;
    *[!A-Za-z0-9+/=]*)
      SK_ACC_WHY="it is not base64 (standard alphabet; PEM text and the URL-safe alphabet are not accepted)"
      return 1
      ;;
  esac
  case ${#sk_pk} in
    64)
      case $sk_pk in
        MC4CAQAwBQYDK2VwBCIEI*) ;;
        *)
          SK_ACC_WHY="it is not an Ed25519 PKCS#8 key"
          return 1
          ;;
      esac
      case $sk_pk in
        *=*)
          SK_ACC_WHY="it is not base64 of a 48-byte key"
          return 1
          ;;
      esac
      SK_ACC_PRIVN=$sk_pk
      ;;
    44)
      case ${sk_pk%=} in
        *=* | *[!A-Za-z0-9+/]*)
          SK_ACC_WHY="it is not base64 of a 32-byte seed"
          return 1
          ;;
      esac
      case $sk_pk in
        *=) ;;
        *)
          SK_ACC_WHY="it is not base64 of a 32-byte seed"
          return 1
          ;;
      esac
      if ! command -v openssl >/dev/null 2>&1 && ! command -v base64 >/dev/null 2>&1; then
        SK_ACC_WHY="it is a bare 32-byte seed and this machine has neither openssl nor base64 to convert it; paste the 48-byte PKCS#8 form"
        return 1
      fi
      # the PKCS#8 header of an Ed25519 key (30 2e 02 01 00 30 05 06 03 2b 65 70 04 22 04 20), then the seed
      SK_ACC_PRIVN=$({
        printf '\060\056\002\001\000\060\005\006\003\053\145\160\004\042\004\040'
        printf '%s' "$sk_pk" | sk_acc_b64d
      } | sk_acc_b64e) || SK_ACC_PRIVN=
      if [ ${#SK_ACC_PRIVN} != 64 ]; then
        SK_ACC_PRIVN=
        SK_ACC_WHY="the 32-byte seed could not be converted"
        return 1
      fi
      ;;
    *)
      SK_ACC_WHY="it has the wrong length (a PKCS#8 Ed25519 key is 64 characters of base64, a bare seed 44)"
      return 1
      ;;
  esac
}

# ---- the file in credentials/ ----

# sk_acc_save: writes the result to $SK_HOME/credentials/<host>.json (0600 in a 0700 directory, atomically)
# and remembers it in install.json (the path and the ids, not the secrets).
sk_acc_save() {
  if [ "$SK_DRY" = 1 ]; then
    ui_say "[dry-run] save the key to $SK_ACC_FILE"
    return 0
  fi
  sk_ensure_dir credentials
  chmod 700 "$SK_HOME/credentials" 2>/dev/null || true
  sk_as_tmp=$SK_HOME/credentials/.$SK_ACC_HOST.json.$$
  sk_as_when=$(date -u +%Y-%m-%dT%H:%M:%SZ 2>/dev/null) || sk_as_when=
  if ! (
    umask 077
    cat >"$sk_as_tmp" <<EOF
{
  "hostname": "$SK_ACC_HOST",
  "api_key_id": "$SK_API_KEY_ID",
  "credential_id": "$SK_CREDENTIAL_ID",
  "private_key_b64": "$SK_PRIVATE_KEY_B64",
  "api_key": "$SK_API_KEY",
  "saved_at": "$sk_as_when"
}
EOF
  ) 2>/dev/null || ! chmod 600 "$sk_as_tmp" 2>/dev/null || ! mv -f "$sk_as_tmp" "$SK_ACC_FILE" 2>/dev/null; then
    rm -f "$sk_as_tmp" 2>/dev/null || true
    sk_fail 1 SK-E006 "Cannot write $SK_ACC_FILE." "Check the permissions, or use --home DIR for another place."
  fi
  sk_record "account.$SK_ACC_HOST" "$SK_ACC_FILE"
  sk_record "account.$SK_ACC_HOST.api_key_id" "$SK_API_KEY_ID"
  sk_record "account.$SK_ACC_HOST.credential_id" "$SK_CREDENTIAL_ID"
  ui_note "The key is saved in $SK_ACC_FILE (readable by you only). It is not printed anywhere."
}

# sk_acc_load: reads the saved file into SK_ACC_L_*; succeeds when it holds a key id.
sk_acc_load() {
  SK_ACC_L_KEY_ID=
  SK_ACC_L_CRED=
  SK_ACC_L_PRIV=
  SK_ACC_L_KEY=
  if [ ! -f "$SK_ACC_FILE" ] || [ -L "$SK_ACC_FILE" ]; then
    return 1
  fi
  sk_acc_jget "$SK_ACC_FILE" api_key_id
  SK_ACC_L_KEY_ID=$SK_ACC_V
  sk_acc_jget "$SK_ACC_FILE" credential_id
  SK_ACC_L_CRED=$SK_ACC_V
  sk_acc_jget "$SK_ACC_FILE" private_key_b64
  SK_ACC_L_PRIV=$SK_ACC_V
  sk_acc_jget "$SK_ACC_FILE" api_key
  SK_ACC_L_KEY=$SK_ACC_V
  sk_acc_safe "$SK_ACC_L_KEY_ID" || return 1
  if [ -n "$SK_ACC_L_CRED" ]; then
    sk_acc_safe "$SK_ACC_L_CRED" || return 1
  fi
  if [ -n "$SK_ACC_L_KEY" ]; then
    sk_acc_safe "$SK_ACC_L_KEY" || return 1
  fi
  if [ -n "$SK_ACC_L_PRIV" ]; then
    sk_acc_priv_check "$SK_ACC_L_PRIV" || return 1
    SK_ACC_L_PRIV=$SK_ACC_PRIVN
  fi
}

# ---- the three ways to get the values ----

# sk_acc_take KEY_ID CRED_ID PRIVATE API_KEY SOURCE: checks the four values and sets the SK_* variables; stops with
# SK-E507 (naming SOURCE, never the values) when they cannot work.
sk_acc_take() {
  sk_at_src=$5
  if [ -z "$1" ]; then
    sk_error SK-E507 "The API key id is missing ($sk_at_src)." "Give the id of the API key the credential belongs to."
  fi
  sk_acc_safe "$1" || sk_error SK-E507 "The API key id is not valid ($sk_at_src)." "It holds letters, digits, _ . and - only."
  if [ -n "$2" ]; then
    sk_acc_safe "$2" || sk_error SK-E507 "The credential id is not valid ($sk_at_src)." "It holds letters, digits, _ . and - only."
  fi
  if [ -n "$4" ]; then
    sk_acc_safe "$4" || sk_error SK-E507 "The API key is not valid ($sk_at_src)." "It holds letters, digits, _ . and - only. Copy it again from the dashboard."
  fi
  sk_at_priv=
  if [ -n "$3" ]; then
    if sk_acc_priv_check "$3"; then
      sk_at_priv=$SK_ACC_PRIVN
    else
      sk_error SK-E507 "The private key is not valid ($sk_at_src): $SK_ACC_WHY." "Paste the private key exactly as the dashboard showed it (base64 of the PKCS#8 key)."
    fi
  fi
  if [ -n "$2" ] && [ -z "$sk_at_priv" ]; then
    sk_error SK-E507 "A credential id is given but its private key is not ($sk_at_src)." "Give the private key too, or leave both out and give the API key."
  fi
  if [ -z "$2" ] && [ -n "$sk_at_priv" ]; then
    sk_error SK-E507 "A private key is given but its credential id is not ($sk_at_src)." "Give the credential id too."
  fi
  if [ -z "$2" ] && [ -z "$4" ]; then
    sk_error SK-E507 "Only the API key id is given ($sk_at_src)." "Give the API key as well (the plugin makes its own credential from it), or the credential id with its private key."
  fi
  SK_API_KEY_ID=$1
  SK_CREDENTIAL_ID=$2
  SK_PRIVATE_KEY_B64=$sk_at_priv
  SK_API_KEY=$4
}

# sk_acc_from_env: succeeds (values taken) when the environment holds any of the four.
sk_acc_from_env() {
  sk_ev_priv=${SHAKEKEE_PRIVATE_KEY_B64:-${SHAKEKEE_PRIVATE_KEY-}}
  if [ -z "${SHAKEKEE_API_KEY_ID-}" ] && [ -z "${SHAKEKEE_CREDENTIAL_ID-}" ] && [ -z "$sk_ev_priv" ] && [ -z "${SHAKEKEE_API_KEY-}" ]; then
    return 1
  fi
  sk_acc_take "${SHAKEKEE_API_KEY_ID-}" "${SHAKEKEE_CREDENTIAL_ID-}" "$sk_ev_priv" "${SHAKEKEE_API_KEY-}" "the SHAKEKEE_* environment variables"
  sk_ev_priv=
  ui_say "Using the key from the SHAKEKEE_* environment variables."
}

# sk_acc_paste: asks for the four values (the private key and the API key are typed hidden).
sk_acc_paste() {
  ui_title "Your ShakeKee key"
  ui_note "Create the key in the ShakeKee dashboard (API keys, then add a credential) and copy the values here. The private key and the API key are not shown while you type."
  sk_ap_n=0
  while :; do
    ui_ask "API key id"
    sk_acc_trim "$UI_REPLY"
    sk_ap_id=$SK_ACC_T
    if sk_acc_safe "$sk_ap_id"; then
      break
    fi
    ui_warn "An API key id holds letters, digits, _ . and - only."
    sk_ap_n=$((sk_ap_n + 1))
    if [ $sk_ap_n -ge 3 ]; then
      sk_error SK-E507 "No usable API key id was given." "Copy the id from the API keys page of the dashboard."
    fi
  done
  sk_ap_n=0
  while :; do
    ui_ask "Credential id"
    sk_acc_trim "$UI_REPLY"
    sk_ap_cred=$SK_ACC_T
    if sk_acc_safe "$sk_ap_cred"; then
      break
    fi
    ui_warn "A credential id holds letters, digits, _ . and - only."
    sk_ap_n=$((sk_ap_n + 1))
    if [ $sk_ap_n -ge 3 ]; then
      sk_error SK-E507 "No usable credential id was given." "Copy the id from the credentials of the API key in the dashboard."
    fi
  done
  sk_ap_n=0
  while :; do
    ui_ask_secret "Private key (base64)"
    if sk_acc_priv_check "$UI_REPLY"; then
      sk_ap_priv=$SK_ACC_PRIVN
      break
    fi
    ui_warn "That private key cannot be used: $SK_ACC_WHY."
    sk_ap_n=$((sk_ap_n + 1))
    if [ $sk_ap_n -ge 3 ]; then
      sk_error SK-E507 "No usable private key was given." "Paste the private key exactly as the dashboard showed it, when the credential was made."
    fi
  done
  sk_ap_n=0
  while :; do
    ui_ask_secret "API key (optional, recommended; Enter to skip)"
    sk_acc_trim "$UI_REPLY"
    sk_ap_key=$SK_ACC_T
    if [ -z "$sk_ap_key" ] || sk_acc_safe "$sk_ap_key"; then
      break
    fi
    ui_warn "An API key holds letters, digits, _ . and - only."
    sk_ap_n=$((sk_ap_n + 1))
    if [ $sk_ap_n -ge 3 ]; then
      sk_error SK-E507 "No usable API key was given." "Copy the key again from the dashboard, or leave it out."
    fi
  done
  sk_acc_take "$sk_ap_id" "$sk_ap_cred" "$sk_ap_priv" "$sk_ap_key" "the pasted values"
  sk_ap_priv=
  sk_ap_key=
}

# sk_acc_create HOST: sign in, make the API key, the signing key and the credential.
sk_acc_create() {
  sk_acc_login
  sk_acc_domain
  ui_step_start keygen "Making the signing key on this machine ($SK_ACC_TOOL)"
  if sk_acc_keygen; then
    ui_step_ok
  else
    ui_step_fail "$SK_ACC_TOOL did not make a usable Ed25519 key"
    sk_error SK-E506 "The Ed25519 key could not be made with $SK_ACC_TOOL." "Install OpenSSL 1.1.1 or newer, or node, and run the installer again, or paste a key from the dashboard."
  fi
  sk_ac_date=$(date -u +%Y-%m-%d 2>/dev/null) || sk_ac_date=
  ui__jq "Installer $SK_ACC_HOST $sk_ac_date"
  sk_ac_name=$UI_J
  ui__jq "$SK_ACC_DOM"
  ui_step_start apikey "Creating the API key for $SK_ACC_DOM"
  if sk_acc_http POST /api/v1/dashboard/api-keys "{\"name\":$sk_ac_name,\"domains\":[$UI_J],\"preflight_enabled\":true}"; then
    sk_acc_jget "$SK_ACC_DIR/out" id
    sk_ac_id=$SK_ACC_V
    sk_acc_jget "$SK_ACC_DIR/out" key
    sk_ac_key=$SK_ACC_V
    : >"$SK_ACC_DIR/out"
    if sk_acc_safe "$sk_ac_id" && sk_acc_safe "$sk_ac_key"; then
      ui_step_ok
    else
      ui_step_fail "the answer did not hold a key"
      sk_error SK-E504 "The dashboard answered without a usable API key." "Run the installer again; if it happens again, create the key in the dashboard and choose \"I already have one\"."
    fi
  else
    sk_acc_why
    ui_step_fail "$SK_ACC_WHY"
    sk_error SK-E504 "The dashboard refused to create the API key ($SK_ACC_WHY)." "Your plan may allow no more keys, or the domain is not verified in your account. Look at the API keys and Domains pages of the dashboard."
  fi
  ui__jq "Installer $SK_ACC_HOST"
  sk_ac_label=$UI_J
  ui_step_start credential "Registering the signing key"
  if sk_acc_http POST "/api/v1/dashboard/api-keys/$sk_ac_id/credentials" "{\"public_key_b64\":\"$SK_ACC_PUB\",\"label\":$sk_ac_label}"; then
    sk_acc_jget "$SK_ACC_DIR/out" credential_id
    sk_ac_cred=$SK_ACC_V
    if sk_acc_safe "$sk_ac_cred"; then
      ui_step_ok
    else
      ui_step_fail "the answer did not hold a credential id"
      sk_ac_cred=
    fi
  else
    sk_acc_why
    ui_step_fail "$SK_ACC_WHY"
    sk_ac_cred=
  fi
  if [ -z "$sk_ac_cred" ]; then
    # do not leave an unused key behind
    sk_acc_http DELETE "/api/v1/dashboard/api-keys/$sk_ac_id" || :
    sk_error SK-E505 "The dashboard refused to register the signing key." "Run the installer again. The API key that was just made has been removed."
  fi
  SK_API_KEY=$sk_ac_key
  SK_API_KEY_ID=$sk_ac_id
  SK_CREDENTIAL_ID=$sk_ac_cred
  SK_PRIVATE_KEY_B64=$SK_ACC_PRIV
  SK_ACC_PRIV=
  sk_ac_key=
  # saved before signing out: the key exists only here now
  sk_acc_save
  sk_acc_logout
}

# sk_acc_keygen_ready: succeeds when a tool for the key exists; else says so and lets the person paste.
sk_acc_keygen_ready() {
  if sk_acc_tool; then
    return 0
  fi
  ui_warn_box "This machine has no tool that can make an Ed25519 key (openssl 1.1.1 or newer, node, or python3 with the cryptography package)." "You can make the key in the ShakeKee dashboard instead and paste it here."
  ui_menu_single "What now?" "+paste|Paste a key I made in the dashboard" "stop|Stop here"
  if [ "$UI_REPLY" = stop ]; then
    sk_error SK-E506 "No tool to make an Ed25519 key was found." "Install openssl 1.1.1 or newer (or node), then run the installer again, or paste a key made in the dashboard."
  fi
  return 1
}

# ---- public functions ----

# sk_account_bases: SK_BASE_API and SK_BASE_CDN are the API and CDN origins (no trailing slash) that a plugin set up
# here must use, empty when they are the production ones that every plugin knows by default or when the script was
# not told (SK_DASHBOARD_API, SK_CDN_URL). A key made at another ShakeKee (staging, self-hosted) does not exist at
# the production API, so the plugin has to be told where its key lives.
sk_account_bases() {
  SK_BASE_API=${SK_DASHBOARD_API-}
  SK_BASE_API=${SK_BASE_API%/}
  case $SK_BASE_API in https://api.shakekee.com) SK_BASE_API= ;; esac
  SK_BASE_CDN=${SK_CDN_URL-}
  SK_BASE_CDN=${SK_BASE_CDN%/}
  case $SK_BASE_CDN in https://cdn.shakekee.com) SK_BASE_CDN= ;; esac
}

# sk_account_credentials HOSTNAME: the whole flow. See the top of this file.
sk_account_credentials() {
  sk_acc_state
  sk_acc_host "${1-}"
  sk_log_open
  SK_API_KEY=
  SK_API_KEY_ID=
  SK_CREDENTIAL_ID=
  SK_PRIVATE_KEY_B64=
  SK_ACC_NORAW=0
  if sk_acc_endpoints; then
    sk_ac_dash=1
  else
    sk_ac_dash=0
  fi
  if [ "$SK_DRY" = 1 ]; then
    if ! sk_acc_from_env; then
      SK_API_KEY_ID='<api-key-id>'
      SK_CREDENTIAL_ID='<credential-id>'
      SK_PRIVATE_KEY_B64='<private-key>'
      ui_say "[dry-run] get an API key id, credential id and private key for $SK_ACC_HOST"
    fi
    sk_acc_save
    return 0
  fi
  if [ "$SK_YES" = 1 ] || [ "$UI_TTY" = 0 ] || [ "$UI_MODE" = json ]; then
    if sk_acc_from_env; then
      :
    elif sk_acc_load; then
      sk_acc_take "$SK_ACC_L_KEY_ID" "$SK_ACC_L_CRED" "$SK_ACC_L_PRIV" "$SK_ACC_L_KEY" "the file $SK_ACC_FILE"
      ui_say "Using the key saved in $SK_ACC_FILE."
    else
      sk_fail 2 SK-E001 "A ShakeKee key is needed for $SK_ACC_HOST and there is no terminal to ask for it." "Set SHAKEKEE_API_KEY_ID with SHAKEKEE_API_KEY (or with SHAKEKEE_CREDENTIAL_ID and SHAKEKEE_PRIVATE_KEY_B64) in the environment, or run the installer in a terminal."
    fi
    sk_acc_save
    return 0
  fi
  sk_ac_prompt="Which ShakeKee key for $SK_ACC_HOST?"
  if sk_acc_load; then
    if [ "$sk_ac_dash" = 1 ]; then
      ui_menu_single "$sk_ac_prompt" "+saved|Use the key saved for $SK_ACC_HOST|key id $SK_ACC_L_KEY_ID" "create|Create a key for me|ShakeKee mails you a code, nothing else to do" "paste|I already have one|paste the values from the dashboard"
    else
      ui_menu_single "$sk_ac_prompt" "+saved|Use the key saved for $SK_ACC_HOST|key id $SK_ACC_L_KEY_ID" "paste|I already have one|paste the values from the dashboard"
    fi
    sk_ac_how=$UI_REPLY
  elif [ "$sk_ac_dash" = 1 ]; then
    ui_menu_single "$sk_ac_prompt" "+create|Create a key for me|ShakeKee mails you a code, nothing else to do" "paste|I already have one|paste the values from the dashboard"
    sk_ac_how=$UI_REPLY
  else
    sk_ac_how=paste
  fi
  if [ "$sk_ac_how" = create ] && ! sk_acc_keygen_ready; then
    sk_ac_how=paste
  fi
  if [ "$sk_ac_how" = create ] && ! sk_acc_domain_ok "$SK_ACC_DOM"; then
    ui_warn_box "$SK_ACC_HOST cannot be verified as a domain (the dashboard takes public names with a dot, no IP addresses, not .local, .localhost or .internal)." "Make the key in the ShakeKee dashboard and paste it here."
    ui_menu_single "What now?" "+paste|Paste a key I made in the dashboard" "stop|Stop here"
    if [ "$UI_REPLY" = stop ]; then
      sk_error SK-E509 "$SK_ACC_HOST cannot be verified as a domain." "Make the key in the dashboard and choose \"I already have one\", or use the public name of the site."
    fi
    sk_ac_how=paste
  fi
  case $sk_ac_how in
    saved)
      sk_acc_take "$SK_ACC_L_KEY_ID" "$SK_ACC_L_CRED" "$SK_ACC_L_PRIV" "$SK_ACC_L_KEY" "the file $SK_ACC_FILE"
      ;;
    create)
      sk_acc_create
      return 0
      ;;
    *)
      sk_acc_paste
      ;;
  esac
  sk_acc_save
}

# sk_account_cred_get NAME: prints API_KEY, API_KEY_ID, CREDENTIAL_ID or PRIVATE_KEY_B64 (for command substitution).
sk_account_cred_get() {
  sk_acc_state
  case ${1-} in
    API_KEY)
      if [ "$SK_ACC_NORAW" = 1 ]; then
        printf '\n'
      else
        printf '%s\n' "$SK_API_KEY"
      fi
      ;;
    API_KEY_ID) printf '%s\n' "$SK_API_KEY_ID" ;;
    CREDENTIAL_ID) printf '%s\n' "$SK_CREDENTIAL_ID" ;;
    PRIVATE_KEY_B64) printf '%s\n' "$SK_PRIVATE_KEY_B64" ;;
    *) sk_fail 1 SK-E010 "sk_account_cred_get was asked for ${1-}" "This is a bug in the installer; please report it." ;;
  esac
}

# sk_account_no_raw_key: for integrations that must never hold the raw API key (Cloudflare, Shopify). The key
# is dropped from this script's variables and from the environment; the file in credentials/ keeps it.
sk_account_no_raw_key() {
  sk_acc_state
  SK_API_KEY=
  SK_ACC_NORAW=1
  unset SHAKEKEE_API_KEY
}

# sk_account_revoke HOSTNAME: signs in again (the same code path) and deletes the credential and the key
# saved for the host. Returns 1, having said why, when that is not possible; nothing is removed then.
sk_account_revoke() {
  sk_acc_state
  sk_acc_host "${1-}"
  sk_log_open
  if ! sk_acc_load; then
    ui_say "No saved key for $SK_ACC_HOST, so there is nothing to remove at the dashboard."
    return 0
  fi
  if [ "$SK_DRY" = 1 ]; then
    ui_say "[dry-run] remove the credential and the API key $SK_ACC_L_KEY_ID of $SK_ACC_HOST at the dashboard"
    return 0
  fi
  if ! sk_acc_endpoints; then
    ui_warn "This installer does not know the dashboard address, so the key $SK_ACC_L_KEY_ID of $SK_ACC_HOST has to be removed in the dashboard."
    return 1
  fi
  if [ "$SK_YES" = 1 ] || [ "$UI_TTY" = 0 ] || [ "$UI_MODE" = json ]; then
    ui_warn "Removing the key $SK_ACC_L_KEY_ID of $SK_ACC_HOST needs a login code from your mail, so it needs a terminal. Remove it in the dashboard, or run this again in a terminal."
    return 1
  fi
  sk_acc_login
  sk_ar_ok=1
  sk_ar_403=0
  if [ -n "$SK_ACC_L_CRED" ]; then
    ui_step_start revoke_credential "Removing the credential $SK_ACC_L_CRED"
    if sk_acc_http DELETE "/api/v1/dashboard/api-keys/$SK_ACC_L_KEY_ID/credentials/$SK_ACC_L_CRED" || [ "$SK_ACC_CODE" = 404 ]; then
      ui_step_ok
    else
      sk_acc_why
      ui_step_fail "$SK_ACC_WHY"
      sk_ar_ok=0
      [ "$SK_ACC_CODE" = 403 ] && sk_ar_403=1
    fi
  fi
  ui_step_start revoke_key "Removing the API key $SK_ACC_L_KEY_ID"
  if sk_acc_http DELETE "/api/v1/dashboard/api-keys/$SK_ACC_L_KEY_ID" || [ "$SK_ACC_CODE" = 404 ]; then
    ui_step_ok
  else
    sk_acc_why
    ui_step_fail "$SK_ACC_WHY"
    sk_ar_ok=0
    [ "$SK_ACC_CODE" = 403 ] && sk_ar_403=1
  fi
  sk_acc_logout
  if [ "$sk_ar_ok" = 0 ] && [ "$sk_ar_403" = 1 ]; then
    # 403 is what the dashboard says to an account that does not own the key. A login with an unknown email makes a new, empty account.
    ui_warn "The dashboard refused (403): the account you signed in with does not own this key. Was it a different email than the one used at install? Run this again with that email."
  fi
  if [ "$sk_ar_ok" = 0 ]; then
    sk_error SK-E508 "The key of $SK_ACC_HOST could not be removed at the dashboard." "Remove API key $SK_ACC_L_KEY_ID on the API keys page of the dashboard."
  fi
}
# ---- integrate.sh: what the ported recipes that configure an integration share (POSIX sh; defines functions only) ----
# The served script carries this file (after common.sh, and after account.sh where the recipe uses it) when the
# recipe calls sk_int_*, sk_wp_* or sk_cms_*; the hub carries it always. Design: docs/installer-cms.md.
#
# * who runs what: the site's owner runs the site's own tools (sk_int_as_setup, sk_int_as)
# * the mode: the daemon next to the web server, or the account (API key + credential) (sk_int_choose)
# * secrets travel in private files that a command reads as its standard input, never in an argument
#   (sk_int_stash, sk_int_step_in)
# * WP-CLI as the owner of a WordPress site (sk_wp_*), shared by the WordPress and WooCommerce recipes
# * the example files, recorded so that --uninstall takes them away again (sk_int_example_place)
#
# Errors (help pages: <origin>/help/SK-E2xx), shared by the CMS recipes:
#   SK-E201  WP-CLI is not installed (the manual way is printed)
#   SK-E202  no usable WordPress site at the path
#   SK-E203  WP-CLI would run as root
#   SK-E204  a WordPress network: the site must be named
#   SK-E205  a plugin or module could not be installed or activated
#   SK-E206  WooCommerce is not active
#   SK-E207  the daemon is needed and this host is not registered with it
#   SK-E208  this integration has no such mode
#   SK-E209  the settings could not be written
#   SK-E210  no Joomla root (or no CLI), the manual way is printed
#   SK-E211  no Drupal project, or Composer is missing
#   SK-E212  no PrestaShop shop
#   SK-E213  PHP, or a PHP extension, is missing
#   SK-E214  the live check found the integration not working
#   SK-E215  the example could not be created
#   SK-E216  the daemon's shared secret cannot be read

# ---- the directory of this run ----

# sk_int_task: SK_TASK exists (the framework's temporary directory, removed on exit).
sk_int_task() {
  if [ -z "$SK_TASK" ] || [ ! -d "$SK_TASK" ]; then
    SK_TASK=$(mktemp -d "${TMPDIR:-/tmp}/shakekee-install.XXXXXX") ||
      sk_fail 1 SK-E006 "Cannot create a temporary directory." "Check that ${TMPDIR:-/tmp} is writable."
  fi
}

# sk_int_site_key TEXT: SK_SITEKEY is a key for install.json: TEXT with everything but letters and digits as
# "_", and a checksum of the whole text, so that two sites never share one.
sk_int_site_key() {
  sk_sk1=$(printf '%s' "$1" | tr -c 'A-Za-z0-9' '_' | cut -c1-48) || sk_sk1=site
  sk_sk2=$(printf '%s' "$1" | cksum | cut -d ' ' -f 1) || sk_sk2=0
  SK_SITEKEY=${sk_sk1}_$sk_sk2
}

# sk_int_keys PREFIX: SK_KEYS lists the keys of install.json that start with PREFIX, in file order.
sk_int_keys() {
  SK_KEYS=
  sk_rec_load
  if [ -n "$SK_REC" ]; then
    while IFS=$UI_TAB read -r sk_ky sk_kv; do
      case $sk_ky in
        "$1"*) SK_KEYS="${SK_KEYS:+$SK_KEYS$UI_NL}$sk_ky" ;;
      esac
    done <<REC
$SK_REC
REC
  fi
}

# sk_int_nobar TEXT: succeeds when TEXT holds no bar and no line break (it goes into a record).
sk_int_nobar() {
  case $1 in
    *'|'* | *"$UI_NL"*) return 1 ;;
  esac
  return 0
}

# sk_int_field N TEXT: SK_FIELD is the Nth (from 1) bar-separated field of TEXT.
sk_int_field() {
  sk_fn=$1
  SK_FIELD=$2
  while [ "$sk_fn" -gt 1 ]; do
    case $SK_FIELD in
      *'|'*) SK_FIELD=${SK_FIELD#*'|'} ;;
      *)
        SK_FIELD=
        return 0
        ;;
    esac
    sk_fn=$((sk_fn - 1))
  done
  SK_FIELD=${SK_FIELD%%'|'*}
}

# ---- who runs the site's tools ----

# sk_int_owner PATH: SK_INT_OWNER is the user that owns PATH (empty when that cannot be told).
sk_int_owner() {
  SK_INT_OWNER=$(ls -ld "$1" 2>/dev/null | awk '{ print $3 }') || SK_INT_OWNER=
}

# sk_int_as_setup PATH: decides how the tools of the site at PATH are run. SK_INT_AS is none (this user owns
# the site), sudo or runuser (another user does: root uses runuser or sudo, anybody else sudo, with the
# password prompt of sudo), SK_INT_OWNER is the owner. Nothing is run here.
sk_int_as_setup() {
  SK_INT_AS=none
  SK_INT_ASTXT=
  sk_int_owner "$1"
  sk_ia_me=$(id -un 2>/dev/null) || sk_ia_me=
  if [ -z "$SK_INT_OWNER" ] || [ -z "$sk_ia_me" ] || [ "$SK_INT_OWNER" = "$sk_ia_me" ]; then
    return 0
  fi
  if sk_is_root; then
    if command -v runuser >/dev/null 2>&1; then
      SK_INT_AS=runuser
    elif command -v sudo >/dev/null 2>&1; then
      SK_INT_AS=sudo
    else
      sk_fail 1 SK-E005 "The site belongs to $SK_INT_OWNER and neither runuser nor sudo is installed to act as that user." "Run the installer as $SK_INT_OWNER, or install sudo."
    fi
  elif [ "$SK_INT_OWNER" != root ]; then
    if ! command -v sudo >/dev/null 2>&1; then
      sk_fail 1 SK-E005 "The site belongs to $SK_INT_OWNER and sudo is not installed to act as that user." "Run the installer as $SK_INT_OWNER, or install sudo."
    fi
    SK_INT_AS=sudo
    # the owner reads the files this run writes for it (a script, a package); the directory only needs to be passable
    sk_int_task
    chmod 711 "$SK_TASK" 2>/dev/null || true
  fi
  if [ "$SK_INT_AS" != none ]; then
    SK_INT_ASTXT=" as $SK_INT_OWNER"
  fi
}

# sk_int_as COMMAND ARGS...: runs COMMAND as the owner of the site (see sk_int_as_setup); its standard input
# and output are this function's own.
sk_int_as() {
  sk_iac=$1
  shift
  case $sk_iac in
    /*) ;;
    *) sk_iaa=$(command -v "$sk_iac" 2>/dev/null) && sk_iac=$sk_iaa ;;
  esac
  case ${SK_INT_AS:-none} in
    runuser) runuser -u "$SK_INT_OWNER" -- "$sk_iac" "$@" ;;
    sudo) command sudo -u "$SK_INT_OWNER" "$sk_iac" "$@" ;;
    *) "$sk_iac" "$@" ;;
  esac
}

# sk_int_as_note: one line that says who runs the site's tools, when that is not this user.
sk_int_as_note() {
  if [ "${SK_INT_AS:-none}" != none ]; then
    if [ "$SK_INT_AS" = sudo ]; then
      ui_note "The tools of the site run as $SK_INT_OWNER through sudo, which may ask for your password."
    else
      ui_note "The tools of the site run as $SK_INT_OWNER (through $SK_INT_AS)."
    fi
  fi
}

# ---- secrets in private files ----

# sk_int_stash TEXT: writes TEXT to a file only this user can read (in a private directory of the temporary
# directory); SK_INT_STASH is its name. A command reads it as its standard input (sk_int_step_in). Shell
# built-ins write it, so the text is in no argument list.
sk_int_stash() {
  sk_int_task
  if [ ! -d "$SK_TASK/stash" ]; then
    (umask 077 && mkdir "$SK_TASK/stash") 2>/dev/null ||
      sk_fail 1 SK-E006 "Cannot create a private directory in ${TMPDIR:-/tmp}." "Check that it is writable."
  fi
  SK_INT_STASHN=$((${SK_INT_STASHN:-0} + 1))
  SK_INT_STASH=$SK_TASK/stash/$SK_INT_STASHN
  (umask 077 && printf '%s' "$1" >"$SK_INT_STASH") 2>/dev/null ||
    sk_fail 1 SK-E006 "Cannot write a private file in ${TMPDIR:-/tmp}." "Check that it is writable."
}

# sk_int_run_in FILE COMMAND ARGS...: COMMAND with FILE as its standard input, its output in the log;
# FILE is emptied afterwards. Returns the status of COMMAND.
sk_int_run_in() {
  sk_ir_f=$1
  shift
  sk_log_open
  sk_quote_args "$@"
  sk_log "\$ $SK_QA < (a private file)"
  if "$@" <"$sk_ir_f" >>"$SK_LOG" 2>&1; then
    sk_ir_rc=0
  else
    sk_ir_rc=$?
  fi
  : >"$sk_ir_f"
  return "$sk_ir_rc"
}

# sk_int_step_in FILE ID TEXT COMMAND ARGS...: sk_step_run with FILE as the standard input of COMMAND.
sk_int_step_in() {
  sk_is_f=$1
  sk_is_id=$2
  sk_is_tx=$3
  shift 3
  if [ "$SK_DRY" = 1 ]; then
    ui_say "[dry-run] $sk_is_tx"
    return 0
  fi
  sk_log_open
  sk_is_off=$(wc -c <"$SK_LOG" 2>/dev/null) || sk_is_off=0
  ui_step_start "$sk_is_id" "$sk_is_tx"
  if sk_int_run_in "$sk_is_f" "$@"; then
    ui_step_ok
    return 0
  fi
  sk_is_rc=$?
  set -- $sk_is_off
  sk_is_tail=$(tail -c +$(($1 + 1)) "$SK_LOG" 2>/dev/null | tail -n 6) || sk_is_tail=
  ui_step_fail "exit status $sk_is_rc${sk_is_tail:+$UI_NL$sk_is_tail}${UI_NL}the full output is in $SK_LOG"
  return "$sk_is_rc"
}

# ---- the mode: daemon or account ----

# sk_int_daemon_find: SK_INT_DAEMON is the shakekeed binary (recorded by the daemon installer, in the install
# home, or on the PATH), empty when there is none.
sk_int_daemon_find() {
  SK_INT_DAEMON=
  sk_home
  if sk_rec_get daemon && [ -x "$SK_RV" ]; then
    SK_INT_DAEMON=$SK_RV
  elif [ -x "$SK_HOME/bin/shakekeed" ]; then
    SK_INT_DAEMON=$SK_HOME/bin/shakekeed
  elif command -v shakekeed >/dev/null 2>&1; then
    SK_INT_DAEMON=$(command -v shakekeed)
  fi
}

# sk_int_daemon ARGS...: runs the daemon's binary for this install home (root with the default home keeps the
# system layout, which has no --home).
sk_int_daemon() {
  if [ "$SK_HOME" = /var/lib/shakekeed ] && sk_is_root; then
    "$SK_INT_DAEMON" "$@"
  else
    "$SK_INT_DAEMON" --home "$SK_HOME" "$@"
  fi
}

# sk_int_host_registered HOST: succeeds when the daemon here has an active registration for HOST: a host
# record names it, or `status --json` lists it as active.
sk_int_host_registered() {
  for sk_hr_d in "$SK_HOME/state/hosts" "$SK_HOME/hosts"; do
    if [ -d "$sk_hr_d" ] && grep -lF "\"$1\"" "$sk_hr_d"/*.json >/dev/null 2>&1; then
      return 0
    fi
  done
  if [ -n "${SK_INT_DAEMON:-}" ]; then
    sk_hr_j=$(sk_int_daemon status --json 2>/dev/null </dev/null) || sk_hr_j=
    case $sk_hr_j in
      *"\"$1\""*'"state":"active"'*) return 0 ;;
    esac
  fi
  return 1
}

# sk_int_choose SLUG HOST SUPPORT FLAG: SK_INT_MODE is daemon or account.
#   SUPPORT  both, daemon or account: what the integration can do
#   FLAG     the person's --daemon or --account, or empty
# Without a flag: the daemon when HOST is registered with it and the integration can use it, else the account.
sk_int_choose() {
  sk_ic_slug=$1
  sk_ic_host=$2
  sk_ic_sup=$3
  sk_ic_flag=${4:-}
  sk_int_daemon_find
  sk_ic_reg=0
  if [ -n "$SK_INT_DAEMON" ] && sk_int_host_registered "$sk_ic_host"; then
    sk_ic_reg=1
  fi
  case $sk_ic_flag in
    daemon)
      if [ "$sk_ic_sup" = account ]; then
        sk_fail 1 SK-E208 "The $sk_ic_slug integration has no daemon mode." "Leave out --daemon: it uses an API key and a credential of your ShakeKee account."
      fi
      SK_INT_MODE=daemon
      ;;
    account)
      if [ "$sk_ic_sup" = daemon ]; then
        sk_fail 1 SK-E208 "The $sk_ic_slug integration has no account mode: it works with the ShakeKee daemon only." "Leave out --account and install the daemon first (curl -fsSL ${SK_ORIGIN:-https://get.shakekee.com} | sh -s -- --daemon)."
      fi
      SK_INT_MODE=account
      ;;
    *)
      case $sk_ic_sup in
        daemon) SK_INT_MODE=daemon ;;
        account) SK_INT_MODE=account ;;
        *)
          if [ "$sk_ic_reg" = 1 ]; then
            SK_INT_MODE=daemon
          else
            SK_INT_MODE=account
            if [ -n "$SK_INT_DAEMON" ]; then
              ui_note "The daemon is installed, but $sk_ic_host is not registered with it, so the API key of your account is used. Register the host with the daemon installer to use the daemon."
            fi
          fi
          ;;
      esac
      ;;
  esac
  if [ "$SK_INT_MODE" = daemon ] && [ "$sk_ic_reg" = 0 ]; then
    sk_fail 1 SK-E207 "The ShakeKee daemon is needed for $sk_ic_slug and $sk_ic_host is not registered with it here." "Install the daemon and register the host first: curl -fsSL ${SK_ORIGIN:-https://get.shakekee.com} | sh -s -- --daemon"
  fi
}

# sk_int_daemon_secret: SK_INT_SECRET is the shared secret of the daemon's preflight listener, asked from the
# daemon itself (never printed).
sk_int_daemon_secret() {
  SK_INT_SECRET=
  if [ "$SK_DRY" = 1 ]; then
    SK_INT_SECRET='<shared-secret>'
    return 0
  fi
  SK_INT_SECRET=$(sk_int_daemon print-config --preflight-secret 2>/dev/null </dev/null) || SK_INT_SECRET=
  if [ "${#SK_INT_SECRET}" -lt 16 ]; then
    SK_INT_SECRET=
    sk_fail 1 SK-E216 "The shared secret of the ShakeKee daemon cannot be read." "Run the installer as the user the daemon runs as, or start the daemon once so that it creates the secret."
  fi
}

# sk_int_daemon_url: SK_INT_URL is the address of the daemon's preflight listener (the config's preflight_listen,
# else 127.0.0.1:7878).
sk_int_daemon_url() {
  sk_du=
  if [ -r "$SK_HOME/shakekeed.toml" ]; then
    sk_du=$(sed -n 's/^[[:space:]]*preflight_listen[[:space:]]*=[[:space:]]*"\([^"]*\)".*/\1/p' "$SK_HOME/shakekeed.toml" 2>/dev/null | head -n 1) || sk_du=
  fi
  case $sk_du in
    127.0.0.1:[0-9]* | localhost:[0-9]* | '[::1]':[0-9]*) ;;
    *) sk_du=127.0.0.1:7878 ;;
  esac
  SK_INT_URL=http://$sk_du # plain http on purpose: always a loopback address (127.0.0.1, localhost, [::1]), the case above sees to it
}

# sk_int_daemon_probe: asks the daemon's preflight with the shared secret (SK_INT_SECRET) about a host it does
# not know. SK_INT_PROBE is ok (a verdict came back), secret (401: the secret is wrong), down (no answer) or
# other. The secret reaches curl as its configuration on the standard input.
sk_int_daemon_probe() {
  SK_INT_PROBE=other
  if ! command -v curl >/dev/null 2>&1; then
    return 0
  fi
  case $SK_INT_SECRET in
    '' | *[!A-Za-z0-9._~+/=-]*) return 0 ;;
  esac
  sk_dp=$(printf 'header = "X-SK-Secret: %s"\n' "$SK_INT_SECRET" |
    curl -s -o /dev/null -w '%{http_code}' --max-time 5 -X POST -K - \
      -H 'X-SK-Host: shakekee-probe.invalid' -H 'X-SK-Path: /' -H 'X-SK-IP: 127.0.0.1' \
      "$SK_INT_URL/v1/preflight" 2>/dev/null) || sk_dp=000
  case $sk_dp in
    200 | 403 | 429 | 503) SK_INT_PROBE=ok ;;
    401) SK_INT_PROBE=secret ;;
    000) SK_INT_PROBE=down ;;
  esac
}

# sk_int_http_status URL: SK_INT_CODE is the status of a GET (000 when nothing answers) and SK_INT_ACTION the
# X-SK-Action header, if any. Redirects are followed (a few).
sk_int_http_status() {
  SK_INT_CODE=000
  SK_INT_ACTION=
  if ! command -v curl >/dev/null 2>&1; then
    return 0
  fi
  sk_hs=$(curl -s -L --max-redirs 3 --max-time 15 -o /dev/null -D - -w 'SK_STATUS %{http_code}\n' "$1" 2>/dev/null) || sk_hs=
  SK_INT_CODE=$(printf '%s\n' "$sk_hs" | sed -n 's/^SK_STATUS \([0-9]*\).*/\1/p' | tail -n 1) || SK_INT_CODE=
  SK_INT_CODE=${SK_INT_CODE:-000}
  SK_INT_ACTION=$(printf '%s\n' "$sk_hs" | tr -d '\r' | sed -n 's/^[Xx]-[Ss][Kk]-[Aa]ction:[[:space:]]*//p' | tail -n 1) || SK_INT_ACTION=
}

# sk_int_host_of URL: SK_INT_HOST is the host name of URL (no scheme, user, port or path).
sk_int_host_of() {
  sk_ho=${1#*://}
  sk_ho=${sk_ho%%/*}
  sk_ho=${sk_ho##*@}
  SK_INT_HOST=${sk_ho%%:*}
}

# sk_int_host_ok HOST: a name the account flow and the daemon take (letters, digits, dots, dashes).
sk_int_host_ok() {
  case $1 in
    '' | -* | .* | *..* | *[!A-Za-z0-9._-]*) return 1 ;;
  esac
  return 0
}

# ---- a file that Composer or another tool changes: backed up once, undone byte for byte ----

# sk_int_backup_find FILE: succeeds when FILE has a recorded backup (the oldest, which is the original). SK_BKEY is
# its key in install.json, SK_BCOPY the copy, SK_BAFTER the hash recorded for the state our change left.
sk_int_backup_find() {
  sk__abs "$1"
  SK_BKEY=
  SK_BCOPY=
  SK_BAFTER=
  sk_rec_load
  if [ -n "$SK_REC" ]; then
    while IFS=$UI_TAB read -r sk_bk sk_bv; do
      case $sk_bk in
        backup.*)
          sk__unjq "$sk_bv"
          sk__fields "$SK_RV"
          if [ "$SK_F1" = "$SK_ABS" ]; then
            SK_BKEY=$sk_bk
            SK_BCOPY=$SK_F2
            SK_BAFTER=$SK_F3
            break
          fi
          ;;
      esac
    done <<REC
$SK_REC
REC
  fi
  [ -n "$SK_BKEY" ]
}

# sk_int_backup_once FILE: sk_backup, unless an earlier run backed FILE up already (the original is the copy
# that matters; a second run would only copy our own change).
sk_int_backup_once() {
  if sk_int_backup_find "$1"; then
    return 0
  fi
  sk_backup "$1"
}

# sk_int_changed FILE: FILE was changed by a tool after sk_int_backup_once; records the state it was left in.
sk_int_changed() {
  if [ "$SK_DRY" = 1 ]; then
    return 0
  fi
  if sk_int_backup_find "$1"; then
    sk__set_after "$SK_BKEY" "$SK_ABS"
  fi
}

# sk_int_untouched FILE: succeeds when FILE has a backup and is still exactly as our change left it.
sk_int_untouched() {
  if ! sk_int_backup_find "$1" || [ -z "$SK_BAFTER" ] || [ ! -f "$SK_BCOPY" ] || [ -L "$SK_ABS" ] || [ ! -f "$SK_ABS" ]; then
    return 1
  fi
  [ "$(sk_sha256 "$SK_ABS")" = "$SK_BAFTER" ]
}

# sk_int_copy_back FILE: writes the backup copy of FILE over it, byte for byte, as the owner of the site (a tool
# like Composer may have rewritten FILE in between; sk_int_untouched said it was ours before that).
sk_int_copy_back() {
  if sk_int_backup_find "$1" && [ -f "$SK_BCOPY" ]; then
    cat "$SK_BCOPY" | sk_int_as tee "$SK_ABS" >/dev/null
  fi
}

# ---- example files ----

# sk_int_example_place SLUG DEST: writes the example of SLUG below DEST (sk_example_install) and records every
# directory it made and every file it wrote, so that --uninstall removes them (when still as written).
sk_int_example_place() {
  sk_ep_dest=$2
  case $sk_ep_dest in
    /*) ;;
    *) sk_ep_dest=$PWD/$sk_ep_dest ;;
  esac
  sk_ensure_dir .
  # the directories that are not there yet, outermost first
  sk_ep_new=
  sk_ep_d=$sk_ep_dest
  while [ ! -e "$sk_ep_d" ] && [ "$sk_ep_d" != / ]; do
    sk_ep_new="$sk_ep_d${sk_ep_new:+$UI_NL$sk_ep_new}"
    sk_ep_d=${sk_ep_d%/*}
    [ -n "$sk_ep_d" ] || sk_ep_d=/
  done
  if [ "$SK_DRY" != 1 ] && [ -n "$sk_ep_new" ]; then
    (umask 022 && mkdir -p "$sk_ep_dest") 2>/dev/null ||
      sk_fail 1 SK-E215 "Cannot create the example directory $sk_ep_dest." "Check the permissions of ${sk_ep_dest%/*}, or use --home DIR."
    while IFS= read -r sk_ep_n; do
      # the install home and its examples/ are shared with the other integrations: not ours to remove
      case $sk_ep_n in
        "$SK_HOME" | "$SK_HOME/examples") continue ;;
      esac
      sk_created "$sk_ep_n"
    done <<EP_DIRS
$sk_ep_new
EP_DIRS
  fi
  sk_example_install "$1" "$sk_ep_dest"
  if [ "$SK_DRY" != 1 ]; then
    sk_ep_list=$("sk_example_list_$1")
    while IFS='|' read -r sk_ep_p sk_ep_m; do
      sk_ep_n=${sk_ep_p%/*}
      # the sub-directories first (verify.sh sits in the top directory, open/ and the like below it)
      if [ "$sk_ep_n" != "$sk_ep_p" ] && [ -d "$sk_ep_dest/$sk_ep_n" ]; then
        sk_created "$sk_ep_dest/$sk_ep_n"
      fi
      sk_created "$sk_ep_dest/$sk_ep_p"
    done <<EP_FILES
$sk_ep_list
EP_FILES
  fi
}

# sk_int_example_note FILE TEXT: writes the one-line file TEXT (for example site.url) next to the example and
# records it. A relative FILE is a mistake.
sk_int_example_note() {
  if [ "$SK_DRY" = 1 ]; then
    return 0
  fi
  printf '%s\n' "$2" >"$1" 2>/dev/null ||
    sk_fail 1 SK-E215 "Cannot write $1." "Check the permissions of ${1%/*}."
  chmod 644 "$1" 2>/dev/null || true
  sk_created "$1"
}

# ---- WP-CLI, as the owner of the WordPress site (WordPress and WooCommerce) ----
# The recipe sets WP_PATH, WP_URL, WP_ALLOW_ROOT, WP_HOST (the licensed host name, if the person gave one)
# and calls sk_wp_prepare in its detect phase; sk_wp then runs `wp` for that site.

# sk_wp ARGS...: WP-CLI for the site (--path, --url for a network site, --allow-root), run as the owner.
sk_wp() {
  if [ -n "${SK_WP_ROOT:-}" ]; then
    set -- "$@" "--path=$SK_WP_ROOT"
  fi
  if [ -n "${SK_WP_URL:-}" ]; then
    set -- "$@" "--url=$SK_WP_URL"
  fi
  if [ "${SK_WP_ALLOW_ROOT:-0}" = 1 ]; then
    set -- "$@" --allow-root
  fi
  sk_int_as "$SK_WP_BIN" "$@"
}

# sk_int_keep_package FETCH...: the manual way, for a platform whose tool is missing: downloads and verifies each
# package (FETCH are the functions that fetch them) and keeps it in <home>/packages; says where it is.
sk_int_keep_package() {
  sk_home
  if [ "$SK_DRY" = 1 ]; then
    ui_say "  [dry-run] the verified package would be kept in $SK_HOME/packages"
    return 0
  fi
  for sk_kp_f in "$@"; do
    "$sk_kp_f"
    sk_ensure_dir packages
    sk_keep "$SK_FILE" "$SK_HOME/packages"
    ui_say "  package (checked against its SHA-256): $SK_KEPT"
  done
}

# sk_wp_manual FETCH...: WP-CLI is not there. Keeps the verified package(s) in <home>/packages and says how to
# install them by hand; then stops with SK-E201.
sk_wp_manual() {
  ui_say ""
  ui_say "Without WP-CLI the plugin is installed by hand:"
  sk_int_keep_package "$@"
  ui_say "  1. In the WordPress administration: Plugins > Add New > Upload Plugin, choose the package, Install, Activate."
  ui_say "     (Or copy the unpacked folder into wp-content/plugins/ and activate it there.)"
  ui_say "  2. Settings > ShakeKee Protection: enter the API key id, credential id and private key from your dashboard"
  ui_say "     (API keys, then credentials), or in daemon mode the daemon address and its shared secret."
  ui_say "  3. Or install WP-CLI (https://wp-cli.org) and run this installer again: it does all of it."
  sk_fail 1 SK-E201 "WP-CLI is not installed or not on the PATH, so the plugin was not installed." "Install WP-CLI (https://wp-cli.org) and run this again, or follow the manual steps above."
}

# sk_wp_prepare FETCH...: the detect phase. Finds WP-CLI and the site, decides who runs it, picks the site of a
# network, and sets SK_WP_BIN, SK_WP_ROOT, SK_WP_URL (a network site only), SK_WP_SITEURL, SK_WP_HOST (the host
# name of the site, or --host). FETCH is for sk_wp_manual.
sk_wp_prepare() {
  SK_WP_ROOT=
  SK_WP_URL=${WP_URL:-}
  SK_WP_ALLOW_ROOT=${WP_ALLOW_ROOT:-0}
  SK_WP_NETWORK=0
  ui_step_start wpcli "Looking for WP-CLI"
  SK_WP_BIN=$(command -v wp 2>/dev/null) || SK_WP_BIN=
  if [ -z "$SK_WP_BIN" ]; then
    ui_step_fail "wp was not found on the PATH"
    sk_wp_manual "$@"
  fi
  ui_step_ok "$SK_WP_BIN"
  sk_wp_dir=${WP_PATH:-.}
  if [ ! -d "$sk_wp_dir" ]; then
    sk_fail 1 SK-E202 "$sk_wp_dir is not a directory." "Run it from the WordPress root or pass --path DIR."
  fi
  SK_WP_ROOT=$(cd "$sk_wp_dir" && pwd -P) || sk_fail 1 SK-E202 "Cannot enter $sk_wp_dir." "Check the permissions."
  if ! sk_int_nobar "$SK_WP_ROOT"; then
    sk_fail 1 SK-E202 "The path $SK_WP_ROOT holds a bar or a line break, which the installer cannot record." "Link the site into a directory with a plain name."
  fi
  sk_int_as_setup "$SK_WP_ROOT"
  if sk_is_root && [ "$SK_INT_AS" = none ] && [ "$SK_WP_ALLOW_ROOT" = 0 ]; then
    sk_fail 1 SK-E203 "WP-CLI refuses to run as root, and the site at $SK_WP_ROOT belongs to root." "Run this as the user that owns the site (for example: sudo -u www-data sh), or add --allow-root."
  fi
  sk_int_as_note
  ui_step_start site "Reading the WordPress site at $SK_WP_ROOT"
  if ! sk_wp core is-installed >/dev/null 2>&1; then
    ui_step_fail "WP-CLI cannot read a WordPress here${SK_INT_OWNER:+ (run as $SK_INT_OWNER)}"
    sk_fail 1 SK-E202 "No WordPress site was found at $SK_WP_ROOT, or WP-CLI cannot read it." "Run it from the WordPress root or pass --path DIR. WP-CLI must be able to load wp-config.php and reach the database."
  fi
  if sk_wp core is-installed --network >/dev/null 2>&1; then
    SK_WP_NETWORK=1
    ui_step_ok "a WordPress network"
    if [ -z "$SK_WP_URL" ]; then
      sk_wp_pick_site
    fi
    ui_step_start url "Reading the address of the site"
  fi
  SK_WP_SITEURL=$(sk_wp option get home 2>/dev/null </dev/null) || SK_WP_SITEURL=
  if [ -z "$SK_WP_SITEURL" ]; then
    ui_step_fail "the address of the site (option home) cannot be read"
    sk_fail 1 SK-E202 "WP-CLI could not read the address of the site at $SK_WP_ROOT." "Check that WordPress is installed and its database is reachable; for a network name the site with --url."
  fi
  if [ -n "${WP_HOST:-}" ]; then
    SK_WP_HOST=$WP_HOST
  else
    sk_int_host_of "$SK_WP_SITEURL"
    SK_WP_HOST=$SK_INT_HOST
  fi
  if ! sk_int_host_ok "$SK_WP_HOST"; then
    ui_step_fail "the host name $SK_WP_HOST is not usable"
    sk_fail 1 SK-E202 "The site address $SK_WP_SITEURL has a host name the installer cannot use ($SK_WP_HOST)." "Give the licensed name with --host NAME."
  fi
  if [ "$SK_WP_NETWORK" = 1 ]; then
    ui_step_ok "$SK_WP_SITEURL (a site of a network)"
  else
    ui_step_ok "$SK_WP_SITEURL"
  fi
}

# sk_wp_pick_site: a WordPress network; SK_WP_URL is the site to act on.
sk_wp_pick_site() {
  sk_ps=$(sk_wp site list --field=url 2>/dev/null </dev/null) || sk_ps=
  sk_psn=0
  set --
  while IFS= read -r sk_psu; do
    [ -n "$sk_psu" ] || continue
    sk_psn=$((sk_psn + 1))
    set -- "$@" "$sk_psn|$sk_psu"
  done <<PS_SITES
$sk_ps
PS_SITES
  if [ "$sk_psn" = 0 ]; then
    sk_fail 1 SK-E204 "This is a WordPress network and WP-CLI lists no site." "Name the site with --url https://site.example.com."
  fi
  if [ "$sk_psn" = 1 ]; then
    SK_WP_URL=${1#*|}
    return 0
  fi
  if [ "$SK_YES" = 1 ] || [ "$UI_TTY" = 0 ] || [ "$UI_MODE" = json ]; then
    sk_fail 1 SK-E204 "This is a WordPress network with $sk_psn sites and the installer cannot ask which one." "Name the site with --url https://site.example.com (one run per site)."
  fi
  ui_menu_single "This WordPress is a network. Which site?" "$@"
  for sk_psu in "$@"; do
    if [ "${sk_psu%%|*}" = "$UI_REPLY" ]; then
      SK_WP_URL=${sk_psu#*|}
    fi
  done
}

# sk_wp_put NAME VALUE: sets the option NAME to VALUE (a secret: it goes in through the standard input; not
# loaded on every request). Returns the status of WP-CLI.
sk_wp_put() {
  sk_int_stash "$2"
  sk_int_run_in "$SK_INT_STASH" sk_wp option update "$1" --autoload=no
}

# sk_wp_set NAME VALUE: the same for a value that is no secret.
sk_wp_set() {
  sk_log_open
  sk_log "\$ wp option update $1 (value not logged)"
  sk_wp option update "$1" "$2" >>"$SK_LOG" 2>&1 </dev/null
}

# sk_wp_get NAME: SK_WP_V is the option NAME, empty when there is none.
sk_wp_get() {
  SK_WP_V=$(sk_wp option get "$1" 2>/dev/null </dev/null) || SK_WP_V=
}

# sk_wp_write_account: the account's key id, credential, private key and API key (the ones that are set) as
# options of the WordPress plugin, and the API and CDN addresses (sk_api_base, sk_cdn_url) when they are not the
# production ones; a daemon module that was on is switched off (it would keep the page gate for itself). Returns non-zero at the first option WP-CLI refuses.
sk_wp_write_account() {
  if [ -n "$SK_API_KEY_ID" ]; then sk_wp_put sk_api_key_id "$SK_API_KEY_ID" || return 1; fi
  if [ -n "$SK_CREDENTIAL_ID" ]; then sk_wp_put sk_credential_id "$SK_CREDENTIAL_ID" || return 1; fi
  if [ -n "$SK_PRIVATE_KEY_B64" ]; then sk_wp_put sk_machine_privkey_b64 "$SK_PRIVATE_KEY_B64" || return 1; fi
  if [ -n "$SK_API_KEY" ]; then sk_wp_put shakekee_api_key "$SK_API_KEY" || return 1; fi
  # the addresses of this ShakeKee when they are not the production ones (the key does not exist there)
  sk_account_bases
  if [ -n "$SK_BASE_API" ]; then sk_wp_set sk_api_base "$SK_BASE_API" || return 1; fi
  if [ -n "$SK_BASE_CDN" ]; then sk_wp_set sk_cdn_url "$SK_BASE_CDN" || return 1; fi
  sk_wp_get shakekee_daemon_enabled
  if [ "$SK_WP_V" = 1 ]; then sk_wp_set shakekee_daemon_enabled 0 || return 1; fi
  return 0
}

# sk_wp_revoke_ask: during --uninstall of an account-mode site: asks whether the API key and credential of
# SK_WP_HOST are revoked at ShakeKee too; the default is to keep them (--yes keeps them), and then the person
# is told where to revoke them (sk_remains).
sk_wp_revoke_ask() {
  ui_menu_single "Also revoke the ShakeKee API key and credential saved for $SK_WP_HOST?" "+keep|No, keep them|you can revoke them later in the dashboard" "revoke|Yes, revoke them now|needs a login code from your mail"
  if [ "$UI_REPLY" = revoke ]; then
    if (sk_account_revoke "$SK_WP_HOST"); then
      return 0
    fi
  fi
  sk_remains "Revoke the ShakeKee API key and credential of $SK_WP_HOST in the dashboard (API keys), or run --uninstall again in a terminal and answer yes. The saved copy is $SK_HOME/credentials/$SK_WP_HOST.json."
}

# sk_int_site_record PREFIX F5 F6: stores the site of this run under PREFIX.site.<key> as
# ROOT|URL|HOST|MODE|F5|F6 ("-" for an empty field); SK_WP_RECKEY is the key. The hooks find the sites again
# with sk_int_each. (The SK_WP_ROOT, SK_WP_URL and SK_WP_HOST names are shared by all the CMS recipes: the root
# directory of the site, the network site of a WordPress, the host name.)
sk_int_site_record() {
  sk_int_site_key "$SK_WP_ROOT|$SK_WP_URL"
  SK_WP_RECKEY=$1.site.$SK_SITEKEY
  if ! sk_int_nobar "$2$3"; then
    sk_fail 1 SK-E010 "A record of a site holds a bar." "This is a bug in the installer; please report it."
  fi
  sk_record "$SK_WP_RECKEY" "$SK_WP_ROOT|$SK_WP_URL|$SK_WP_HOST|${SK_INT_MODE:-}|${2:--}|${3:--}"
}

# sk_int_each PREFIX FUNCTION TOOL [PATH]: runs FUNCTION for every site recorded under PREFIX.site. (only the one
# at PATH when that is given), with SK_WP_ROOT, SK_WP_URL, SK_WP_HOST, SK_INT_MODE, SK_WP_F5, SK_WP_F6, SK_WP_RECKEY
# and SK_WP_N (1, 2, ...) set, the owner worked out (sk_int_as_setup) and SK_WP_BIN the path of TOOL (wp, php,
# composer): a site whose tool is missing is reported and counted as failed. FUNCTION returns 0 or not; the result
# is 1 when any did not.
sk_int_each() {
  sk_we_rc=0
  SK_WP_N=0
  sk_we_pfx=$1
  sk_we_fn=$2
  sk_we_tool=$3
  sk_we_only=${4:-}
  # the hooks write helper scripts into the temporary directory from inside command substitutions: make it here, in
  # this shell, so that the exit trap removes it
  sk_int_task
  sk_int_keys "$sk_we_pfx.site."
  sk_we_keys=$SK_KEYS
  while IFS= read -r sk_we_key; do
    [ -n "$sk_we_key" ] || continue
    sk_rec_get "$sk_we_key" || continue
    sk_we_rec=$SK_RV
    sk_int_field 1 "$sk_we_rec"; SK_WP_ROOT=$SK_FIELD
    sk_int_field 2 "$sk_we_rec"; SK_WP_URL=$SK_FIELD
    sk_int_field 3 "$sk_we_rec"; SK_WP_HOST=$SK_FIELD
    sk_int_field 4 "$sk_we_rec"; SK_INT_MODE=$SK_FIELD
    sk_int_field 5 "$sk_we_rec"; SK_WP_F5=$SK_FIELD
    sk_int_field 6 "$sk_we_rec"; SK_WP_F6=$SK_FIELD
    if [ -n "$sk_we_only" ]; then
      sk_we_want=$(cd "$sk_we_only" 2>/dev/null && pwd -P) || sk_we_want=$sk_we_only
      if [ "$sk_we_want" != "$SK_WP_ROOT" ]; then
        continue
      fi
    fi
    SK_WP_N=$((SK_WP_N + 1))
    SK_WP_RECKEY=$sk_we_key
    SK_WP_ALLOW_ROOT=${WP_ALLOW_ROOT:-0}
    SK_WP_BIN=$(command -v "$sk_we_tool" 2>/dev/null) || SK_WP_BIN=
    sk_int_as_setup "$SK_WP_ROOT"
    if [ -z "$SK_WP_BIN" ]; then
      ui_step_start "tool$SK_WP_N" "$SK_WP_ROOT ($SK_WP_HOST)"
      ui_step_fail "$sk_we_tool is not installed or not on the PATH${UI_NL}Fix: install it and run this again."
      sk_we_rc=1
      continue
    fi
    "$sk_we_fn" || sk_we_rc=1
  done <<SITE_KEYS
$sk_we_keys
SITE_KEYS
  if [ "$SK_WP_N" = 0 ]; then
    ui_say "  Nothing is recorded for ${sk_we_pfx}${sk_we_only:+ at $sk_we_only}."
  fi
  return "$sk_we_rc"
}
# ---- apps.sh: what the recipes of the application SDKs share (POSIX sh; defines functions only) ----
# laravel, django, nodejs, php, go, rust and csharp are ported to the phase contract with these functions. A recipe
# calls sk_app_init, parses its options (sk_app_option takes the shared ones), defines the app_* callbacks it needs,
# then sk_app_hooks and sk_phases. The phases map to the framework's like this: detect = tools and project,
# choose = host, mode (the daemon is installed here when it is wanted and missing), example, check = download and
# verify (also with --dry-run), plan, apply = the key (account mode), the package, the settings, the example,
# verify = the live checks, handover = whether it is ready, the lines the person adds, how to check.
#
# Callbacks (all optional but app_find, app_fetch, app_install, app_loaded, app_entry):
#   app_extra      more checks that need no project (versions, php zip, not root)
#   app_find       the project is here (sk_error SK-E301 when not)
#   app_choose     more questions (django asks for the Python)
#   app_fetch      the download of the slug's artifact
#   app_install    the package manager steps (sk_app_run ...)
#   app_uninstall  the package manager's removal (sk_app_try ...)
#   app_loaded     succeeds when the SDK can be loaded
#   app_config     more files the settings need (django's shakekee_settings.py)
#   app_env_extra  more KEY=value lines for .shakekee/shakekee.env
#   app_enter / app_save  restore / record the recipe's own options as facts
#   app_entry      prints what the person adds to the application
#
# Where things go (project = the folder of the application):
#   .shakekee/credentials.json   account mode: the plugin's saved credential (0600, folder 0700)
#   .shakekee/shakekee.env       daemon mode: SHAKEKEE_HOST and the daemon's secret (0600); account mode with only
#                                an API key: SHAKEKEE_API_KEY and SHAKEKEE_API_KEY_ID
#   shakekee-artifacts/          the verified download, kept for the package manager
#   <example folder>/            the example (open page, protected page, verify.sh)
# Facts in install.json: <slug>.path .host .mode, example.<slug> (the example folder) and the recipe's own (<slug>.dir ...).
#
# Errors (help pages: <origin>/help/SK-E3xx):
#   SK-E301  no project of this kind here           SK-E305  that mode is not possible here
#   SK-E302  a tool is missing or too old           SK-E306  the package manager failed
#   SK-E303  Composer must not run as root          SK-E307  a settings file cannot be written
#   SK-E304  the host name is missing or wrong      SK-E308  the package was installed but cannot be loaded

# sk_app_init SLUG LABEL MODES EXAMPLE-DIR NEEDS: MODES is "account", "daemon" or "account daemon"; NEEDS is lines
# "command|hint" of tools every run needs. The settings of the previous recipe are forgotten.
sk_app_init() {
  APP_SLUG=$1
  APP_LABEL=$2
  APP_MODES=$3
  APP_EXDIR=$4
  APP_NEEDS=$5
  APP_SECRET=SHAKEKEE_PREFLIGHT_SECRET
  APP_PATH=.
  APP_HOST=
  APP_FORCE=
  APP_EXAMPLE=1
  APP_ROOT=
  APP_MODE=
  SK_APP_PENDING=
  SK_APP_BAD=0
  APP_BASES=
  for sk_ai in app_extra app_find app_choose app_fetch app_install app_uninstall app_loaded app_config app_env_extra app_enter app_save app_entry; do
    unset -f "$sk_ai" 2>/dev/null || true
  done
}

# sk_app_option ARGS...: takes --path, --host, --daemon, --account, --no-example and --force. SK_APP_USED is the
# number of arguments after the option that it took (the recipe shifts by it).
sk_app_option() {
  SK_APP_USED=0
  case $1 in
    --path | --host)
      sk_value "$1" $#
      if [ "$1" = --path ]; then APP_PATH=$2; else APP_HOST=$2; fi
      SK_APP_USED=1
      ;;
    --daemon) APP_FORCE=daemon ;;
    --account) APP_FORCE=account ;;
    --no-example) APP_EXAMPLE=0 ;;
    --force) SK_FORCE=1 ;;
  esac
}

# sk_app_usage: the lines of --help that every application recipe has.
sk_app_usage() {
  printf '%s\n' \
    '  --path DIR      the project (default: the current directory)' \
    '  --host NAME     the host name of the site, as registered with ShakeKee (default: $SHAKEKEE_HOST, else asked)'
  if sk_app_has daemon; then
    printf '%s\n' '  --daemon        use the local ShakeKee daemon (installed first when it is missing)'
  fi
  if sk_app_has account; then
    printf '%s\n' '  --account       use a ShakeKee account key (asked for, or from SHAKEKEE_API_KEY_ID and friends)'
  fi
  printf '%s\n' \
    "  --no-example    do not add the example in $APP_EXDIR/" \
    '  --force         replace example files that differ from the example'
}

# sk_app_cmd [ARGS]: the one command a person types (the hub, with ARGS after -s --), for messages. There is one
# command for everything; the scripts of one integration are never named to the person.
sk_app_cmd() { printf 'curl -fsSL %s | sh%s' "$SK_ORIGIN" "${1:+ -s -- $1}"; } # get.shakekee.com

# sk_app_again: the command that runs this recipe again for this project (the hub installs by name).
sk_app_again() { sk_app_cmd "--$APP_SLUG $APP_ROOT"; }

sk_app_cb() {
  if command -v "$1" >/dev/null 2>&1; then
    "$1"
  fi
}

sk_app_has() {
  case " $APP_MODES " in *" $1 "*) return 0 ;; esac
  return 1
}

# sk_app_need: every tool of APP_NEEDS is there, else SK-E302.
sk_app_need() {
  while IFS='|' read -r sk_an_c sk_an_h; do
    if [ -n "$sk_an_c" ] && ! command -v "$sk_an_c" >/dev/null 2>&1; then
      sk_error SK-E302 "$sk_an_c is not installed or not on the PATH: $sk_an_h"
    fi
  done <<APP_NEEDS_LIST
$APP_NEEDS
APP_NEEDS_LIST
}

# sk_app_not_root: Composer refuses to be run as root, and so does this installer (COMPOSER_ALLOW_SUPERUSER=1 overrides).
sk_app_not_root() {
  if [ "$(id -u)" = 0 ] && [ "${COMPOSER_ALLOW_SUPERUSER:-}" != 1 ]; then
    sk_error SK-E303 "Composer should not be run as root. Run this as the user that owns the project, or set COMPOSER_ALLOW_SUPERUSER=1." "Switch to the user that owns the project (su, or sudo -u USER)."
  fi
}

# sk_app_root: the project folder, absolute; the recipe works in it from here on.
sk_app_root() {
  sk_home
  if [ ! -d "$APP_PATH" ]; then
    sk_error SK-E301 "$APP_PATH is not a folder." "Pass --path DIR with the folder of your $APP_LABEL project."
  fi
  APP_ROOT=$(cd "$APP_PATH" && pwd -P) || sk_error SK-E301 "Cannot enter $APP_PATH." "Check the permissions."
  cd "$APP_ROOT"
}

# ---- helpers that change things (all of them say what they would do with --dry-run) ----

# sk_app_run ID TEXT COMMAND...: a step; a failure is SK-E306.
sk_app_run() {
  sk_step_run "$@" || sk_error SK-E306 "Failed: $2." "The output of the command is in ${SK_LOG:-the log}; fix what it says and run this again."
}

# sk_app_try ID TEXT COMMAND...: the same for removals: a missing tool or a failure only warns.
sk_app_try() {
  if ! command -v "$3" >/dev/null 2>&1; then
    ui_warn "$3 is not installed, so \"$2\" was not done. Do it by hand."
  elif ! sk_step_run "$@"; then
    ui_warn "\"$2\" failed; do it by hand."
  fi
}

# sk_app_safety FILE: a copy of a manifest the package manager is about to change, kept under backups/ (not undone by
# --uninstall: the package manager's own removal is).
sk_app_safety() {
  if [ -f "$1" ] && [ ! -L "$1" ]; then
    sk_backup "$1"
    if [ -n "$SK_BACKUP_KEY" ]; then
      sk_unrecord "$SK_BACKUP_KEY"
    fi
  fi
}

# sk_app_mkdir DIR MODE: makes DIR when it is missing and records it as made by us.
sk_app_mkdir() {
  if [ -d "$1" ]; then
    return 0
  fi
  if [ "$SK_DRY" = 1 ]; then
    ui_say "[dry-run] create $1"
    return 0
  fi
  if ! (umask 022 && mkdir "$1" && chmod "$2" "$1") 2>/dev/null; then
    sk_error SK-E307 "Cannot create the folder $1." "Check the permissions of ${1%/*}."
  fi
  sk_created "$1"
}

# sk_app_put FILE MODE <<here-document: writes the text to FILE atomically with MODE, and records it as made by us. A file
# that is there and differs is copied to backups/ first. The text comes on the standard input (not as an argument, not
# through a pipe: this runs in the shell that keeps the record), so a secret in it is never on a command line.
sk_app_put() {
  if [ "$SK_DRY" = 1 ]; then
    ui_say "[dry-run] write $1"
    return 0
  fi
  if [ -L "$1" ]; then
    sk_error SK-E307 "$1 is a symbolic link." "Remove the link, or name the real folder."
  fi
  sk_ap_tmp=$1.shakekee-new.$$
  if ! (umask 077 && cat >"$sk_ap_tmp") 2>/dev/null || ! chmod "$2" "$sk_ap_tmp" 2>/dev/null; then
    rm -f "$sk_ap_tmp"
    sk_error SK-E307 "Cannot write $1." "Check the permissions of ${1%/*}."
  fi
  if [ -f "$1" ] && ! cmp -s "$sk_ap_tmp" "$1"; then
    sk_app_safety "$1"
  fi
  if ! mv -f "$sk_ap_tmp" "$1" 2>/dev/null; then
    rm -f "$sk_ap_tmp"
    sk_error SK-E307 "Cannot write $1." "Check the permissions of ${1%/*}."
  fi
  sk_created "$1"
}

# sk_app_keep FILE DIR OLD-GLOB: keeps the verified download in DIR for the package manager; older ones that match
# OLD-GLOB go. SK_KEPT is the kept file.
sk_app_keep() {
  SK_KEPT=$2/${1##*/}
  if [ "$SK_DRY" = 1 ]; then
    ui_say "[dry-run] keep ${1##*/} in $2"
    return 0
  fi
  sk_app_mkdir "$2" 755
  for sk_ak_old in "$2"/$3; do
    if [ -f "$sk_ak_old" ] && [ "$sk_ak_old" != "$SK_KEPT" ]; then
      rm -f "$sk_ak_old"
    fi
  done
  sk_keep "$1" "$2"
  sk_created "$SK_KEPT"
}

# sk_app_only_ours DIR GLOB: succeeds when every file in DIR matches GLOB (so nothing of another package is kept there).
sk_app_only_ours() {
  for sk_ao_f in "$1"/*; do
    if [ -e "$sk_ao_f" ]; then
      case ${sk_ao_f##*/} in
        $2) ;;
        *) return 1 ;;
      esac
    fi
  done
  return 0
}

# sk_app_track_begin PATH... / sk_app_track_end: what a command made below these paths is recorded as made by us
# (folders first, so that the undo removes the files before them).
sk_app_track_begin() {
  SK_APP_TR_ROOTS=$*
  SK_APP_TR_BEFORE=$UI_NL
  for sk_at_r in "$@"; do
    if [ -e "$sk_at_r" ]; then
      SK_APP_TR_BEFORE="$SK_APP_TR_BEFORE$(find "$sk_at_r" | sort)$UI_NL"
    fi
  done
}

sk_app_track_end() {
  if [ "$SK_DRY" = 1 ]; then
    return 0
  fi
  sk_at_new=
  for sk_at_r in $SK_APP_TR_ROOTS; do
    if [ -e "$sk_at_r" ]; then
      sk_at_all=$(find "$sk_at_r" | sort) || sk_at_all=
      while IFS= read -r sk_at_p; do
        case "$SK_APP_TR_BEFORE" in
          *"$UI_NL$sk_at_p$UI_NL"*) ;;
          *) sk_at_new="$sk_at_new$sk_at_p$UI_NL" ;;
        esac
      done <<APP_TRACK
$sk_at_all
APP_TRACK
    fi
  done
  for sk_at_kind in d f; do
    while IFS= read -r sk_at_p; do
      if [ -z "$sk_at_p" ] || [ -L "$sk_at_p" ]; then
        continue
      fi
      if { [ "$sk_at_kind" = d ] && [ -d "$sk_at_p" ]; } || { [ "$sk_at_kind" = f ] && [ -f "$sk_at_p" ]; }; then
        sk_created "$sk_at_p"
      fi
    done <<APP_TRACK
$sk_at_new
APP_TRACK
  done
}

# ---- the daemon ----

# sk_app_daemon_find: SK_APP_DAEMON is the shakekeed binary that is recorded or installed, else empty. SK_APP_DAEMON_OURS
# is 1 when it is the one the daemon recipe would install in this run (recorded here, in $SK_HOME/bin, or root's
# /usr/local/bin), 0 for one found on the PATH only: another user's or layout's, which is used as it is and never
# set up from here (that would install a second daemon next to it).
sk_app_daemon_find() {
  SK_APP_DAEMON=
  SK_APP_DAEMON_OURS=0
  if sk_rec_get daemon && [ -x "$SK_RV" ]; then
    SK_APP_DAEMON=$SK_RV
    SK_APP_DAEMON_OURS=1
  elif [ -x "$SK_HOME/bin/shakekeed" ]; then
    SK_APP_DAEMON=$SK_HOME/bin/shakekeed
    SK_APP_DAEMON_OURS=1
  elif command -v shakekeed >/dev/null 2>&1; then
    SK_APP_DAEMON=$(command -v shakekeed)
    if sk_is_root && [ "$SK_APP_DAEMON" = "${SK_DAEMON_PREFIX:-}/usr/local/bin/shakekeed" ]; then
      SK_APP_DAEMON_OURS=1
    fi
  fi
}

# sk_app_daemon_fixable: this run can set the daemon up: the script carries the daemon recipe (the server had a
# release), and the daemon is missing or ours.
sk_app_daemon_fixable() {
  command -v sk_recipe_daemon >/dev/null 2>&1 || return 1
  [ -z "$SK_APP_DAEMON" ] || [ "$SK_APP_DAEMON_OURS" = 1 ]
}

# sk_app_can_ask: a person can answer questions (a terminal, no --yes, not --json).
sk_app_can_ask() {
  [ "$SK_YES" != 1 ] && [ "$UI_TTY" != 0 ] && [ "$UI_MODE" != json ]
}

# sk_app_fix_daemon start|register: the Fix line for a daemon that is not ready, in the words of what this script can
# do: with the daemon recipe the installer does it when run again (the registration asks for an email address, so it
# needs a terminal); without it the daemon's own command is named.
sk_app_fix_daemon() {
  if sk_app_daemon_fixable; then
    case $1 in
      start) printf '%s' "Fix: run the installer again: $(sk_app_again)${UI_NL}It starts the daemon and writes the secret." ;;
      register) printf '%s' "Fix: run the installer again on a terminal: $(sk_app_again)${UI_NL}It asks for your email address and registers $APP_HOST with the daemon." ;;
    esac
  else
    case $1 in
      start) printf '%s' "Fix: start the daemon (systemctl --user start shakekeed, or sudo systemctl start shakekeed), then run $(sk_app_again) again to write the secret." ;;
      register) printf '%s' "Fix: ${SK_APP_DAEMON:-shakekeed} register --hostname $APP_HOST --email you@example.com" ;;
    esac
  fi
}

# sk_app_daemon_run ARGS...: shakekeed with the right home (the system layout is kept for root without --home).
sk_app_daemon_run() {
  if sk_is_root && [ -z "$SK_HOME_ARG" ] && [ -z "${SHAKEKEE_HOME:-}" ]; then
    "$SK_APP_DAEMON" "$@" </dev/null
  else
    SHAKEKEE_HOME=$SK_HOME "$SK_APP_DAEMON" "$@" </dev/null
  fi
}

# sk_app_daemon_facts: SK_APP_SECRET (printable and at least 16 characters, else empty; never printed or logged) and
# SK_APP_DSTATUS (the status JSON without white space).
sk_app_daemon_facts() {
  SK_APP_SECRET=
  SK_APP_DSTATUS=
  if [ -z "$SK_APP_DAEMON" ]; then
    return 0
  fi
  SK_APP_SECRET=$(sk_app_daemon_run print-config --preflight-secret 2>/dev/null) || SK_APP_SECRET=
  case $SK_APP_SECRET in
    *[!A-Za-z0-9._~+/=-]*) SK_APP_SECRET= ;;
  esac
  if [ ${#SK_APP_SECRET} -lt 16 ]; then
    SK_APP_SECRET=
  fi
  SK_APP_DSTATUS=$(sk_app_daemon_run status --json 2>/dev/null | tr -d ' \n\t') || SK_APP_DSTATUS=
}

# ---- detect and choose ----

sk_app_detect() {
  sk_app_need
  sk_app_root
  sk_app_cb app_extra
  sk_app_cb app_find
}

# sk_app_host: APP_HOST from --host, SHAKEKEE_HOST or a question.
sk_app_host() {
  if [ -z "$APP_HOST" ]; then
    APP_HOST=${SHAKEKEE_HOST:-}
  fi
  if [ -z "$APP_HOST" ]; then
    if [ "$SK_YES" = 1 ] || [ "$UI_TTY" = 0 ] || [ "$UI_MODE" = json ]; then
      sk_fail 2 SK-E304 "The host name of this site is needed and there is no terminal to ask for it." "Pass --host NAME, or set SHAKEKEE_HOST."
    fi
    ui_ask "Host name of this site, as registered with ShakeKee (for example example.com)"
    APP_HOST=$UI_REPLY
  fi
  APP_HOST=$(printf '%s' "$APP_HOST" | tr 'A-Z' 'a-z')
  case $APP_HOST in
    '' | -* | .* | *..* | *[!a-z0-9.-]*)
      sk_error SK-E304 "\"$APP_HOST\" is not a host name." "Use the name as registered with ShakeKee: letters, digits, dots and dashes (example.com)."
      ;;
  esac
}

# sk_app_mode: APP_MODE is daemon or account. The daemon is a way when it is installed, or when this script carries its
# installer (sk_recipe_daemon: the server had a release); a forced mode that is not possible is SK-E305. Without a
# flag, an SDK that has both ways is asked (the cursor starts on the daemon when it is installed, else on the account
# key; --yes takes where the cursor starts); an SDK with one way takes it. A daemon that is wanted and not ready for
# APP_HOST (not installed, never started, or the host not registered with it) is made ready now by the daemon recipe
# (sk_app_daemon_install: it installs what is missing, starts the daemon and registers the host, and is safe to run on
# a daemon that is there). SK_APP_PENDING says why the daemon's secret is still missing afterwards (a dry run; a daemon
# that could not be started; a script without the daemon recipe).
sk_app_mode() {
  sk_app_daemon_find
  sk_app_daemon_facts
  if [ -n "$APP_FORCE" ] && ! sk_app_has "$APP_FORCE"; then
    sk_error SK-E305 "$APP_LABEL works with $APP_MODES only; --$APP_FORCE does not apply." "Leave the option out."
  fi
  sk_am_daemon=0
  if sk_app_has daemon && { [ -n "$SK_APP_DAEMON" ] || command -v sk_recipe_daemon >/dev/null 2>&1; }; then
    sk_am_daemon=1
  fi
  # no daemon and no sk_recipe_daemon: the server that served this script has no daemon release, so it cannot
  # install one either; the fix names the production installer
  sk_am_fix='Install the daemon first: curl -fsSL https://get.shakekee.com | sh -s -- --daemon, then run this again'
  if [ "$APP_FORCE" = daemon ] && [ "$sk_am_daemon" = 0 ]; then
    sk_am_or=
    if sk_app_has account; then
      sk_am_or=', or leave --daemon out'
    fi
    sk_error SK-E305 "--daemon was given, the ShakeKee daemon (shakekeed) is not installed here, and this installer was served without the daemon's installer." "$sk_am_fix$sk_am_or."
  fi
  if [ -n "$APP_FORCE" ]; then
    APP_MODE=$APP_FORCE
  elif [ "$sk_am_daemon" = 0 ]; then
    if ! sk_app_has account; then
      sk_error SK-E305 "$APP_LABEL works through the ShakeKee daemon (shakekeed), which is not installed here, and this installer was served without the daemon's installer." "$sk_am_fix."
    fi
    if sk_app_has daemon; then
      ui_note "The ShakeKee daemon is not installed here and this installer cannot install it, so $APP_LABEL uses an account key."
    fi
    APP_MODE=account
  elif ! sk_app_has account; then
    APP_MODE=daemon
  else
    sk_app_mode_ask
  fi
  # what this run does about a daemon that is not ready (sk_app_daemon_fixable): installs a missing one; starts one of
  # ours that never ran; registers APP_HOST with it when a person can answer the recipe's questions (the registration
  # needs an email address; unattended, the daemon recipe would only skip it, run after run)
  if [ "$APP_MODE" = daemon ] && sk_app_daemon_fixable; then
    if [ -z "$SK_APP_DAEMON" ]; then
      sk_app_daemon_install "is not installed here yet; it is installed first"
    elif [ -z "$SK_APP_SECRET" ]; then
      sk_app_daemon_install "is installed here but was never started; it is set up and started first"
    elif ! sk_app_daemon_knows && sk_app_can_ask; then
      sk_app_daemon_install "does not know $APP_HOST yet; it is registered with it first"
    fi
  fi
  SK_APP_PENDING=
  if [ "$APP_MODE" = daemon ]; then
    if [ -z "$SK_APP_DAEMON" ]; then
      SK_APP_PENDING="the ShakeKee daemon is not installed here"
    elif [ -z "$SK_APP_SECRET" ]; then
      SK_APP_PENDING="the daemon has no usable secret yet (it makes one when it starts or registers a host)"
    elif ! sk_app_daemon_knows; then
      ui_warn "$APP_HOST is not registered with the daemon yet; the settings are written anyway.${UI_NL}$(sk_app_fix_daemon register)"
    fi
  fi
}

# sk_app_daemon_knows: the daemon's status names APP_HOST.
sk_app_daemon_knows() {
  case $SK_APP_DSTATUS in
    *'"'"$APP_HOST"'"'*) return 0 ;;
  esac
  return 1
}

# sk_app_mode_ask: the question for an SDK that has both ways. The daemon comes first; the cursor starts on it when
# it is installed here and ready, or when this run makes it ready (whoever installed the daemon means to use it);
# else on the account key (nothing is installed unasked). The hint says what this run does about the daemon.
sk_app_mode_ask() {
  sk_ma_can=0
  if sk_app_daemon_fixable; then
    sk_ma_can=1
  fi
  sk_ma_d=
  if [ -z "$SK_APP_DAEMON" ]; then
    sk_ma_hint="not installed yet: it is installed first, in this run"
  elif [ -z "$SK_APP_SECRET" ] && [ "$sk_ma_can" = 1 ]; then
    sk_ma_hint="installed here, never started: it is started first, in this run"
    sk_ma_d=+
  elif [ -z "$SK_APP_SECRET" ] && [ "$SK_APP_DAEMON_OURS" = 0 ]; then
    sk_ma_hint="found at $SK_APP_DAEMON; its secret cannot be read from here"
  elif [ -z "$SK_APP_SECRET" ]; then
    sk_ma_hint="installed here, not started yet"
  elif sk_app_daemon_knows; then
    sk_ma_hint="installed here, knows $APP_HOST"
    sk_ma_d=+
  elif [ "$sk_ma_can" = 1 ] && sk_app_can_ask; then
    sk_ma_hint="installed here: $APP_HOST is registered with it first, in this run"
    sk_ma_d=+
  else
    sk_ma_hint="installed here; $APP_HOST is not registered with it yet"
    sk_ma_d=+
  fi
  sk_ma_a=+
  if [ -n "$sk_ma_d" ]; then
    sk_ma_a=
  fi
  ui_menu_single "How should $APP_LABEL talk to ShakeKee for $APP_HOST?" \
    "${sk_ma_d}daemon|Through the ShakeKee daemon (shakekeed)|$sk_ma_hint" \
    "${sk_ma_a}account|With a ShakeKee account key|the application talks to ShakeKee itself; a code is mailed to you, or you paste a key"
  APP_MODE=$UI_REPLY
}

# sk_app_daemon_install WHY: the daemon recipe this script carries, for APP_HOST, in a subshell of its own (as the hub
# runs it: with set -e of its own, without the EXIT trap, without the script on its input). WHY is what the daemon
# lacks, said to the person. The recipe installs what is missing (a daemon that is there and current is left as it
# is), starts it and registers APP_HOST; it asks its own questions and shows its own plan. Declining that plan ends
# this installer too (exit 3, nothing was changed yet). A failure is SK-E305 when the daemon had to be installed; for
# a daemon that is there it is a warning, and the SDK is set up with the daemon as it is (what it still lacks is said
# in the hand-over). Afterwards the daemon's facts are read again (the recipe recorded it in install.json), and
# declining this installer's own plan says that the daemon stays (SK_STOP_NOTE).
sk_app_daemon_install() {
  ui_say "  The ShakeKee daemon $1."
  if [ -z "$SK_TASK" ]; then
    SK_TASK=$(mktemp -d "${TMPDIR:-/tmp}/shakekee-install.XXXXXX") || sk_fail 1 SK-E006 "Cannot create a temporary directory." "Check that ${TMPDIR:-/tmp} is writable."
  fi
  set +e
  (
    set -e
    trap - EXIT
    SK_SLUG=daemon
    sk_run_recipe daemon --hostname "$APP_HOST" --for "$APP_LABEL"
  ) </dev/null
  sk_di_rc=$?
  set -e
  case $sk_di_rc in
    0) ;;
    3 | 129 | 130 | 143) exit "$sk_di_rc" ;;
    *)
      if [ -n "$SK_APP_DAEMON" ]; then
        ui_warn "The daemon installer did not finish (exit status $sk_di_rc; the messages above say why). $APP_LABEL is set up with the daemon as it is; run $(sk_app_again) again once that is fixed."
      else
        sk_error SK-E305 "The ShakeKee daemon could not be installed (exit status $sk_di_rc), and $APP_LABEL was set up to use it." "The messages above say why. Fix that and run $(sk_app_again) again; nothing of $APP_LABEL was changed yet."
      fi
      ;;
  esac
  SK_STOP_NOTE="What was just done for the ShakeKee daemon stays; nothing of $APP_LABEL was changed."
  SK_REC=
  SK_REC_LOADED=0
  sk_app_daemon_find
  sk_app_daemon_facts
}

sk_app_choose() {
  sk_app_cb app_choose
  sk_app_host
  sk_app_mode
  if [ "$APP_MODE" = daemon ]; then
    ui_say "  $APP_LABEL will talk to the local ShakeKee daemon for $APP_HOST."
  else
    ui_say "  $APP_LABEL will use a ShakeKee account key for $APP_HOST."
  fi
  if [ "$APP_EXAMPLE" = 1 ]; then
    ui_menu_single "Add the example (an open page, a protected page and a check script) in $APP_EXDIR/?" "+yes|Yes, add it|nothing outside $APP_EXDIR/ is touched" "no|No, not now"
    if [ "$UI_REPLY" = no ]; then
      APP_EXAMPLE=0
    fi
  fi
}

sk_app_check() {
  sk_app_cb app_fetch
  SK_RECIPE_VERSION=${SK_FILE##*/}
}

sk_app_plan() {
  if [ "$APP_MODE" = account ]; then
    sk_plan_add "Get a ShakeKee key for $APP_HOST (a code is mailed to you, or paste a key you have)"
  fi
  sk_plan_add "Install ${SK_FILE##*/} (checked) into $APP_ROOT with ${APP_PKG:-its package manager}"
  sk_plan_add "Write the settings to $APP_ROOT/.shakekee/ (private files, mode 0600)"
  if [ "$APP_EXAMPLE" = 1 ]; then
    sk_plan_add "Add the example to $APP_ROOT/$APP_EXDIR/"
  fi
}

# ---- apply ----

sk_app_apply() {
  if [ "$APP_MODE" = account ]; then
    sk_account_credentials "$APP_HOST"
  fi
  sk_app_cb app_install
  sk_app_config
  sk_app_example
  sk_record "$APP_SLUG.path" "$APP_ROOT"
  sk_record "$APP_SLUG.host" "$APP_HOST"
  sk_record "$APP_SLUG.mode" "$APP_MODE"
  sk_app_cb app_save
}

# sk_app_config: .shakekee/ and what goes in it.
sk_app_config() {
  sk_ac_dir=$APP_ROOT/.shakekee
  sk_app_mkdir "$sk_ac_dir" 700
  sk_app_put "$sk_ac_dir/.gitignore" 644 <<'APP_FILE'
*
APP_FILE
  if [ "$APP_MODE" = account ]; then
    # the addresses of this ShakeKee when they are not the production ones (the key does not exist there)
    sk_ac_bases=
    if [ -z "$APP_BASES" ]; then
      sk_account_bases
      if [ -n "$SK_BASE_API" ]; then sk_ac_bases="SHAKEKEE_API_BASE=$SK_BASE_API"; fi
      if [ -n "$SK_BASE_CDN" ]; then sk_ac_bases="${sk_ac_bases:+$sk_ac_bases$UI_NL}SHAKEKEE_CDN_URL=$SK_BASE_CDN"; fi
    fi
    if [ -n "$SK_CREDENTIAL_ID" ] && [ -n "$SK_PRIVATE_KEY_B64" ] && [ -n "$SK_API_KEY_ID" ]; then
      sk_app_put "$sk_ac_dir/credentials.json" 600 <<APP_FILE
{
  "credential_id": "$SK_CREDENTIAL_ID",
  "api_key_id": "$SK_API_KEY_ID",
  "private_key_b64": "$SK_PRIVATE_KEY_B64"
}
APP_FILE
      if [ -n "$sk_ac_bases" ]; then
        sk_app_put "$sk_ac_dir/shakekee.env" 600 <<APP_FILE
# ShakeKee: the addresses of the ShakeKee this key belongs to (not the production ones). Put this file into the environment of the application.
$sk_ac_bases
APP_FILE
      fi
    else
      sk_app_put "$sk_ac_dir/shakekee.env" 600 <<APP_FILE
# ShakeKee: the API key; the plugin makes its own credential on first use. Keep this file private.
SHAKEKEE_API_KEY=$SK_API_KEY
SHAKEKEE_API_KEY_ID=$SK_API_KEY_ID${sk_ac_bases:+$UI_NL$sk_ac_bases}
APP_FILE
    fi
  else
    if [ -n "$SK_APP_SECRET" ]; then
      sk_ac_sec="$APP_SECRET=$SK_APP_SECRET"
    else
      sk_ac_sec="# $APP_SECRET= is missing: $SK_APP_PENDING. Run this installer again once it is there."
    fi
    sk_ac_more=$(sk_app_cb app_env_extra)
    sk_app_put "$sk_ac_dir/shakekee.env" 600 <<APP_FILE
# ShakeKee: generated by the $APP_LABEL installer of $SK_ORIGIN. Keep this file private.
SHAKEKEE_HOST=$APP_HOST
$sk_ac_sec${sk_ac_more:+$UI_NL$sk_ac_more}
APP_FILE
  fi
  sk_app_cb app_config
}

# sk_app_example: the example files, and a record of what was made.
sk_app_example() {
  if [ "$APP_EXAMPLE" != 1 ]; then
    return 0
  fi
  sk_ae_dest=$APP_ROOT/$APP_EXDIR
  sk_ae_list=$("sk_example_list_$APP_SLUG")
  sk_ae_dirs=$sk_ae_dest$UI_NL
  while IFS='|' read -r sk_ae_p sk_ae_m; do
    sk_ae_d=$sk_ae_dest
    sk_ae_r=$sk_ae_p
    while :; do
      case $sk_ae_r in
        */*)
          sk_ae_d=$sk_ae_d/${sk_ae_r%%/*}
          sk_ae_r=${sk_ae_r#*/}
          case "$sk_ae_dirs" in
            *"$sk_ae_d$UI_NL"*) ;;
            *) sk_ae_dirs=$sk_ae_dirs$sk_ae_d$UI_NL ;;
          esac
          ;;
        *) break ;;
      esac
    done
  done <<APP_EXAMPLE_LIST
$sk_ae_list
APP_EXAMPLE_LIST
  sk_ae_new=
  while IFS= read -r sk_ae_d; do
    if [ -n "$sk_ae_d" ] && [ ! -e "$sk_ae_d" ]; then
      sk_ae_new=$sk_ae_new$sk_ae_d$UI_NL
    fi
  done <<APP_EXAMPLE_LIST
$sk_ae_dirs
APP_EXAMPLE_LIST
  sk_example_install "$APP_SLUG" "$sk_ae_dest"
  if [ "$SK_DRY" = 1 ]; then
    return 0
  fi
  while IFS= read -r sk_ae_d; do
    if [ -n "$sk_ae_d" ] && [ -d "$sk_ae_d" ]; then
      sk_created "$sk_ae_d"
    fi
  done <<APP_EXAMPLE_LIST
$sk_ae_new
APP_EXAMPLE_LIST
  while IFS='|' read -r sk_ae_p sk_ae_m; do
    if [ -n "$sk_ae_p" ]; then
      sk_created "$sk_ae_dest/$sk_ae_p"
    fi
  done <<APP_EXAMPLE_LIST
$sk_ae_list
APP_EXAMPLE_LIST
}

# ---- verify (live) and --status / --doctor ----

# sk_app_checks: the checks of verify, --status and --doctor; SK_APP_BAD counts the failures. The ones that are only
# "not ready yet" are warnings.
sk_app_checks() {
  SK_APP_BAD=0
  if [ "$APP_MODE" = daemon ]; then
    sk_app_daemon_find
    sk_app_daemon_facts
  fi
  ui_step_start package "The $APP_LABEL package loads"
  if app_loaded >/dev/null 2>&1; then
    ui_step_ok
  else
    SK_APP_BAD=1
    ui_step_fail "the package is not installed in $APP_ROOT, or cannot be loaded.${UI_NL}Fix: run the installer again: $(sk_app_again)"
  fi
  ui_step_start config "The settings in $APP_ROOT/.shakekee/"
  sk_ck_f=$APP_ROOT/.shakekee/shakekee.env
  if [ "$APP_MODE" = account ] && [ ! -f "$sk_ck_f" ]; then
    sk_ck_f=$APP_ROOT/.shakekee/credentials.json
  fi
  if [ ! -f "$sk_ck_f" ]; then
    SK_APP_BAD=1
    ui_step_fail "${sk_ck_f##*/} is missing.${UI_NL}Fix: run the installer again."
  elif [ "$(ls -l "$sk_ck_f" | cut -c5-10)" != "------" ]; then
    SK_APP_BAD=1
    ui_step_fail "${sk_ck_f##*/} can be read by other users.${UI_NL}Fix: chmod 600 $sk_ck_f"
  elif [ "$APP_MODE" = daemon ] && ! grep -q "^$APP_SECRET=" "$sk_ck_f"; then
    ui_step_warn "${sk_ck_f##*/} has no secret yet.${UI_NL}$(sk_app_fix_daemon start)"
  else
    ui_step_ok
  fi
  if [ "$APP_MODE" = daemon ]; then
    ui_step_start daemon "The daemon knows $APP_HOST"
    case $SK_APP_DSTATUS in
      '') ui_step_warn "the daemon does not answer or is not installed.${UI_NL}$(sk_app_fix_daemon start)" ;;
      *'"'"$APP_HOST"'"'*) ui_step_ok ;;
      *) ui_step_warn "$APP_HOST is not registered with the daemon.${UI_NL}$(sk_app_fix_daemon register)" ;;
    esac
  fi
  sk_ck_ex=
  if sk_rec_get "example.$APP_SLUG"; then
    sk_ck_ex=$SK_RV
    ui_step_start example "The example in $sk_ck_ex"
    if [ -d "$sk_ck_ex" ]; then
      ui_step_ok "check it with: sh $sk_ck_ex/verify.sh"
    else
      ui_step_warn "the folder is gone (it was only an example)"
    fi
  fi
  return 0
}

sk_app_verify() {
  sk_app_checks
  if [ "$SK_APP_BAD" != 0 ]; then
    sk_error SK-E308 "The $APP_LABEL package was installed but the checks above failed." "Look at the lines marked with a cross; the output of the package manager is in ${SK_LOG:-the log}."
  fi
}

# sk_app_enter: for the hooks: the recorded project, host and mode. Fails with a message when there is no project.
sk_app_enter() {
  sk_home
  if ! sk_rec_get "$APP_SLUG.path"; then
    ui_warn "$APP_LABEL is not recorded as installed here."
    return 1
  fi
  APP_ROOT=$SK_RV
  APP_HOST=
  APP_MODE=
  if sk_rec_get "$APP_SLUG.host"; then APP_HOST=$SK_RV; fi
  if sk_rec_get "$APP_SLUG.mode"; then APP_MODE=$SK_RV; fi
  if [ ! -d "$APP_ROOT" ]; then
    ui_warn "The project folder $APP_ROOT is gone."
    return 1
  fi
  cd "$APP_ROOT"
  sk_app_cb app_enter
}

sk_app_status() {
  sk_app_enter || return 1
  sk_app_checks
  [ "$SK_APP_BAD" = 0 ]
}

sk_app_doctor() {
  sk_app_enter || return 1
  ui_step_start tools "The tools $APP_LABEL needs are there"
  sk_ad_miss=
  while IFS='|' read -r sk_ad_c sk_ad_h; do
    if [ -n "$sk_ad_c" ] && ! command -v "$sk_ad_c" >/dev/null 2>&1; then
      sk_ad_miss="$sk_ad_miss $sk_ad_c"
    fi
  done <<APP_NEEDS_LIST
$APP_NEEDS
APP_NEEDS_LIST
  if [ -z "$sk_ad_miss" ]; then
    ui_step_ok
  else
    SK_APP_BAD=1
    ui_step_fail "missing:$sk_ad_miss${UI_NL}Fix: install them, then run --update."
  fi
  sk_ad_bad=$SK_APP_BAD
  sk_app_checks
  [ "$sk_ad_bad" = 0 ] && [ "$SK_APP_BAD" = 0 ]
}

# ---- --update and --uninstall ----

# --update: the package again, from a new download; the settings and the example are not touched.
sk_app_update() {
  sk_app_enter || return 1
  sk_app_need
  sk_app_cb app_extra
  sk_app_cb app_fetch
  SK_RECIPE_VERSION=${SK_FILE##*/}
  sk_app_cb app_install
  sk_integration_add "$APP_SLUG" "$SK_RECIPE_VERSION"
  ui_say "The $APP_LABEL package is now $SK_RECIPE_VERSION. Your settings were not touched."
}

# --uninstall: the package manager removes the package; the files we made are removed by the framework (those you
# have not changed). The key at ShakeKee is deleted only when you say so.
sk_app_uninstall() {
  if sk_app_enter; then
    sk_app_cb app_uninstall
  fi
  if [ "$APP_MODE" = account ] && [ -n "$APP_HOST" ]; then
    ui_menu_single "Also delete the API key and credential made for $APP_HOST at ShakeKee?" "+keep|Keep them|they stay valid; delete them in the dashboard later" "revoke|Delete them|signs in once more"
    sk_au_gone=0
    if [ "$UI_REPLY" = revoke ]; then
      # in a subshell: a refusal ends it with an error, and that must not end the uninstall
      if (sk_account_revoke "$APP_HOST"); then
        sk_au_gone=1
      else
        ui_warn "The key was not deleted; delete it in the ShakeKee dashboard."
      fi
      SK_REC=
      SK_REC_LOADED=0
    fi
    if [ "$sk_au_gone" = 0 ]; then
      sk_remains "The ShakeKee API key and credential made for $APP_HOST are still valid. Delete them in the dashboard if you no longer use them."
    fi
  fi
  sk_app_forget
}

# sk_app_forget: the facts of this integration go.
sk_app_forget() {
  for sk_af_k in path host mode dir features project feed python; do
    sk_unrecord "$APP_SLUG.$sk_af_k"
  done
  sk_unrecord "example.$APP_SLUG"
}

# sk_app_handover: what the person does next: the result, whether it is ready to use, the lines to add (app_entry),
# the settings, the web server (daemon mode), how to check it. Headed groups, one command per line.
sk_app_handover() {
  if [ "$SK_DRY" = 1 ]; then
    ui_note "Dry run: nothing was changed."
    return 0
  fi
  ui_title "$APP_LABEL is set up for $APP_HOST"
  sk_app_verdict
  app_entry
  if [ "$APP_MODE" = daemon ]; then
    ui_heading "Load the settings into the environment of the application"
    ui_copy_block "set -a; . $APP_ROOT/.shakekee/shakekee.env; set +a"
    ui_note "Or EnvironmentFile=$APP_ROOT/.shakekee/shakekee.env in a systemd unit. The file is private (mode 0600); it holds the host and the daemon's secret."
    ui_heading "Let your web server pass two paths to the daemon"
    ui_say "    /.shakekee/  and  /.well-known/shakekee/   to   127.0.0.1:7879"
    ui_note "The daemon installer shows the lines for nginx, Apache and Caddy and can add them to your configuration: $(sk_app_cmd --daemon) (safe to run again)."
  fi
  if [ "$APP_MODE" = account ]; then
    ui_heading "Where the application finds its key"
    if [ -f "$APP_ROOT/.shakekee/credentials.json" ]; then
      ui_note "The application finds its key in $APP_ROOT/.shakekee/credentials.json by itself; the user that runs the application must be able to read it."
      if [ -f "$APP_ROOT/.shakekee/shakekee.env" ]; then
        ui_note "This ShakeKee is not the production one: put $APP_ROOT/.shakekee/shakekee.env into the environment of the application (Laravel: append it to .env), or the key is looked for at api.shakekee.com."
      fi
    else
      ui_note "Only an API key was given, so the plugin makes its own credential the first time it runs. Put $APP_ROOT/.shakekee/shakekee.env into the environment of the application (Laravel: append it to .env) and let the application write in $APP_ROOT/.shakekee/."
    fi
  fi
  ui_heading "Check it"
  if [ "$APP_EXAMPLE" = 1 ]; then
    ui_say "    With the application running   sh $APP_ROOT/$APP_EXDIR/verify.sh"
  fi
  ui_say "    Any time later                 $(sk_app_cmd --status)"
  ui_note "--update and --uninstall work the same way; they act on everything ShakeKee installed here."
}

# sk_app_verdict: the first line under the hand-over's title: is it ready to use? One step line (ui_step_start, then
# ui_step_ok, or ui_step_warn with the fix in its detail, one command per line), so that it looks like the checks the
# person just watched. SK_APP_PENDING (daemon mode) says why the secret is missing; sk_app_daemon_knows says whether
# the daemon knows APP_HOST; in account mode the key is in $APP_ROOT/.shakekee/.
sk_app_verdict() {
  ui_step_start ready "Ready to use"
  if [ "$APP_MODE" = account ]; then
    ui_step_ok "the key is in $APP_ROOT/.shakekee/"
  elif [ -n "$SK_APP_PENDING" ]; then
    ui_step_warn "Not ready yet: $SK_APP_PENDING.${UI_NL}$(sk_app_fix_daemon start)${UI_NL}Then check with: $(sk_app_cmd --status)"
  elif sk_app_daemon_knows; then
    ui_step_ok "the daemon knows $APP_HOST"
  else
    ui_step_warn "Not ready yet: the daemon runs, but $APP_HOST is not registered with it.${UI_NL}$(sk_app_fix_daemon register)"
  fi
}

# sk_app_hooks: defines the phase functions and hooks the framework runs (sk_phases is the recipe's last statement).
sk_app_hooks() {
  recipe_detect() { sk_app_detect; }
  recipe_choose() { sk_app_choose; }
  recipe_check() { sk_app_check; }
  recipe_plan() { sk_app_plan; }
  recipe_apply() { sk_app_apply; }
  recipe_verify() { sk_app_verify; }
  recipe_handover() { sk_app_handover; }
  recipe_status() { sk_app_status; }
  recipe_update() { sk_app_update; }
  recipe_uninstall() { sk_app_uninstall; }
  recipe_doctor() { sk_app_doctor; }
}
# ---- examples.sh: writes the example files that are embedded in the script (POSIX sh; defines functions only) ----
# For each integration that has examples (assets/examples/<slug>/ in the get service) the served script carries two
# generated functions, sk_example_list_<slug> (lines "path|mode") and sk_example_cat_<slug> PATH (the file's text).
# This function writes them; the generated ones and this one are all function definitions, so a truncated
# download still runs nothing.

# sk_example_install SLUG DEST: writes the example files of SLUG below DEST.
#   directories 0755, files 0644, scripts (first line #!) 0755; DEST is created.
#   A file that is already there and has the same text is left alone. A file that differs is never overwritten
#   unless SK_FORCE=1 (the --force of the hub); then it is replaced without a backup. Nothing is written when one
#   differs and there is no force. Links are never followed: a link in the way is an error. With --dry-run it
#   only says what it would write.
sk_example_install() {
  sk_ex_slug=$1
  sk_ex_dest=$2
  case $sk_ex_slug in
    '' | *[!a-z0-9]*) sk_ex_list= ;;
    *) sk_ex_list=$(sk_example_list_"$sk_ex_slug" 2>/dev/null) || sk_ex_list= ;;
  esac
  if [ -z "$sk_ex_list" ]; then
    sk_fail 1 SK-E101 "There is no example for ${sk_ex_slug:-this integration}." "Examples exist for the integrations that list one on their help page."
  fi
  case $sk_ex_dest in
    '') sk_fail 1 SK-E103 "No directory was given for the example." "Name the directory the example files go into." ;;
    /*) ;;
    *) sk_ex_dest=$PWD/$sk_ex_dest ;;
  esac
  # look first, write later: every file that is there must be the same text, or force is on
  sk_ex_diff=
  sk_ex_todo=
  sk_ex_same=0
  while IFS='|' read -r sk_ex_path sk_ex_mode; do
    sk_ex_target=$sk_ex_dest/$sk_ex_path
    sk_example_no_links "$sk_ex_dest" "$sk_ex_path"
    if [ -e "$sk_ex_target" ]; then
      if [ -f "$sk_ex_target" ] && sk_example_cat_"$sk_ex_slug" "$sk_ex_path" | cmp -s - "$sk_ex_target"; then
        sk_ex_same=$((sk_ex_same + 1))
        continue
      fi
      sk_ex_diff="${sk_ex_diff:+$sk_ex_diff, }$sk_ex_path"
    fi
    sk_ex_todo="${sk_ex_todo:+$sk_ex_todo$UI_NL}$sk_ex_path|$sk_ex_mode"
  done <<EXAMPLE_LIST
$sk_ex_list
EXAMPLE_LIST
  if [ -n "$sk_ex_diff" ] && [ "${SK_FORCE:-0}" != 1 ]; then
    sk_fail 1 SK-E102 "These example files exist in $sk_ex_dest and differ from the example: $sk_ex_diff. Nothing was written." "Run again with --force to replace them, or choose another directory."
  fi
  if [ -z "$sk_ex_todo" ]; then
    say "The example in $sk_ex_dest is already up to date."
    return 0
  fi
  sk_ex_n=0
  while IFS='|' read -r sk_ex_path sk_ex_mode; do
    sk_ex_n=$((sk_ex_n + 1))
    if [ "$SK_DRY" = 1 ]; then
      say "[dry-run] write $sk_ex_dest/$sk_ex_path"
      continue
    fi
    sk_ex_target=$sk_ex_dest/$sk_ex_path
    sk_ex_tmp=$sk_ex_target.shakekee-new.$$
    if ! (umask 022 && mkdir -p "${sk_ex_target%/*}") 2>/dev/null ||
      ! (umask 022 && sk_example_cat_"$sk_ex_slug" "$sk_ex_path" >"$sk_ex_tmp") 2>/dev/null ||
      ! chmod "$sk_ex_mode" "$sk_ex_tmp" 2>/dev/null ||
      ! mv -f "$sk_ex_tmp" "$sk_ex_target" 2>/dev/null; then
      rm -f "$sk_ex_tmp" 2>/dev/null || true
      sk_fail 1 SK-E103 "Cannot write the example file $sk_ex_target." "Check the permissions of $sk_ex_dest, or choose another directory."
    fi
  done <<EXAMPLE_LIST
$sk_ex_todo
EXAMPLE_LIST
  if [ "$SK_DRY" != 1 ]; then
    if [ "$sk_ex_same" -gt 0 ]; then
      say "Example written to $sk_ex_dest ($sk_ex_n files, $sk_ex_same unchanged)."
    else
      say "Example written to $sk_ex_dest ($sk_ex_n files)."
    fi
    sk_record "example.$sk_ex_slug" "$sk_ex_dest"
  fi
}

# sk_example_no_links DEST PATH: fails when DEST, or a part of PATH below it that exists, is a symbolic link.
sk_example_no_links() {
  sk_exl=$1
  if [ -L "$sk_exl" ]; then
    sk_fail 1 SK-E103 "$sk_exl is a symbolic link; the example is not written through links." "Name the real directory."
  fi
  sk_exr=$2
  while [ -n "$sk_exr" ]; do
    case $sk_exr in
      */*)
        sk_exl=$sk_exl/${sk_exr%%/*}
        sk_exr=${sk_exr#*/}
        ;;
      *)
        sk_exl=$sk_exl/$sk_exr
        sk_exr=
        ;;
    esac
    if [ -L "$sk_exl" ]; then
      sk_fail 1 SK-E103 "$sk_exl is a symbolic link; the example is not written through links." "Remove the link or choose another directory."
    fi
  done
}
sk_example_list_cloudflare() {
  printf '%s\n' 'open/index.html|644' 'protected/index.html|644' 'verify.sh|755'
}
sk_example_cat_cloudflare() {
  case $1 in
    'open/index.html')
cat <<'SK_EXAMPLE_END_OF_FILE'
<!doctype html>
<html lang="en">
<head><meta charset="utf-8"><title>ShakeKee example: open page</title></head>
<body>
<h1>ShakeKee example: this page is open</h1>
<p>Anybody gets this page. The Worker lets it through because the path is not one of SHAKEKEE_PROTECT.</p>
</body>
</html>
SK_EXAMPLE_END_OF_FILE
      ;;
    'protected/index.html')
cat <<'SK_EXAMPLE_END_OF_FILE'
<!doctype html>
<html lang="en">
<head><meta charset="utf-8"><title>ShakeKee example: protected page</title></head>
<body>
<h1>ShakeKee example: you passed the check</h1>
<p>You see this page only after the ShakeKee Worker has let you through. Visitors who have not shaken their phone get the wall instead.</p>
</body>
</html>
SK_EXAMPLE_END_OF_FILE
      ;;
    'verify.sh')
cat <<'SK_EXAMPLE_END_OF_FILE'
#!/bin/sh
# Proves the example: the open page is served to anybody, the protected page asks for the check first.
# Usage: sh verify.sh [URL]
#   URL  where the site is served with the Worker in front (default http://127.0.0.1:8787, where `wrangler dev` listens)
# Put the open/ and protected/ directories of this example on the site first (SHAKEKEE_PROTECT is /protected in wrangler.toml).
# A Worker that cannot sign in to ShakeKee lets every request through, so a protected page that answers 200 means: not deployed,
# the route does not match, or the credential does not work (wrangler tail says which).
set -eu
base=http://127.0.0.1:8787
case ${1:-} in
  '') ;;
  -*) echo "usage: sh verify.sh [URL]" >&2; exit 2 ;;
  *) base=${1%/} ;;
esac
command -v curl >/dev/null 2>&1 || { echo "curl is not installed" >&2; exit 2; }
case ${LC_ALL:-${LC_CTYPE:-${LANG:-}}} in
  *UTF-8* | *utf-8* | *UTF8* | *utf8*) ok='✔' bad='✖' ;;
  *) ok='ok  ' bad='FAIL' ;;
esac

tmp=$(mktemp "${TMPDIR:-/tmp}/shakekee-verify.XXXXXX")
trap 'rm -f "$tmp"' EXIT INT TERM
status() {
  curl -s -o "$tmp" -w '%{http_code}' --max-time 15 "$1" || true
}

failed=0
open=$(status "$base/open/")
if [ "$open" = 200 ] && grep -q 'this page is open' "$tmp"; then
  echo "$ok open page       $base/open/ answers 200"
else
  echo "$bad open page       $base/open/ answers ${open:-nothing}, expected 200 with the example page"
  failed=1
fi
protected=$(status "$base/protected/")
case $protected in
  403 | 429)
    if grep -qi shakekee "$tmp"; then
      echo "$ok protected page  $base/protected/ answers $protected with the ShakeKee wall: the visitor is asked for the check"
    else
      echo "$bad protected page  $base/protected/ answers $protected, but not with the ShakeKee wall: something else refuses the request"
      failed=1
    fi
    ;;
  200)
    echo "$bad protected page  $base/protected/ answers 200 without a check: the Worker is not in front of it, or it cannot sign in and lets requests pass"
    failed=1
    ;;
  *)
    echo "$bad protected page  $base/protected/ answers ${protected:-nothing}, expected 403 with the wall"
    failed=1
    ;;
esac
exit "$failed"
SK_EXAMPLE_END_OF_FILE
      ;;
  esac
}
sk_example_list_csharp() {
  printf '%s\n' 'Program.cs.txt|644' 'ShakeKeeExample.csproj|644' 'nuget.config|644' 'verify.sh|755'
}
sk_example_cat_csharp() {
  case $1 in
    'Program.cs.txt')
cat <<'SK_EXAMPLE_END_OF_FILE'
// ShakeKee example: a small self-hosted program (System.Net.HttpListener; .NET Framework or Mono). It is a .txt file so that
// a project around this folder does not compile it; ShakeKeeExample.csproj does. sh verify.sh --serve 8099 builds and runs it.
//   set -a; . ../.shakekee/shakekee.env; set +a; mono ShakeKeeExample.exe http://127.0.0.1:8080/
// /shakekee-example/open is served to anybody. /shakekee-example/protected goes through the ShakeKee daemon first:
// a visitor who has not passed the check gets the wall (status 403) instead of the page.
using System;
using System.Net;
using System.Text;
using System.Threading;
using ShakeKee;
using ShakeKee.Listener;

static class Program
{
    static void Main(string[] args)
    {
        ThreadPool.SetMinThreads(32, 32);
        var options = GuardOptions.FromEnvironment(); // SHAKEKEE_HOST, SHAKEKEE_SECRET, ...
        options.ProxyPublic = true;                   // no web server in front: this program serves /.shakekee/ itself
        var shakekee = new Middleware(options);

        var listener = new HttpListener();
        listener.Prefixes.Add(args.Length > 0 ? args[0] : "http://127.0.0.1:8080/");
        listener.Start();
        Console.WriteLine("listening on " + string.Join(", ", listener.Prefixes));
        while (true)
        {
            var context = listener.GetContext();
            ThreadPool.QueueUserWorkItem(_ => Serve(context, shakekee));
        }
    }

    static void Serve(HttpListenerContext context, Middleware shakekee)
    {
        try
        {
            var path = context.Request.Url.AbsolutePath;
            // Ask for the protected page and for the wall's own files; the open page never meets ShakeKee.
            bool guarded = path.StartsWith("/shakekee-example/protected") || path.StartsWith("/.shakekee/") || path.StartsWith("/.well-known/shakekee/");
            if (guarded && shakekee.Handle(context)) return; // true: the wall, a block or a /.shakekee/ answer was sent

            string text;
            if (path == "/shakekee-example/open") text = "ShakeKee example: this page is open.\n";
            else if (path.StartsWith("/shakekee-example/protected")) text = "ShakeKee example: you passed the check and see the protected page.\n";
            else { context.Response.StatusCode = 404; context.Response.Close(); return; }
            var body = Encoding.UTF8.GetBytes(text);
            context.Response.ContentType = "text/plain; charset=utf-8";
            context.Response.ContentLength64 = body.Length;
            context.Response.OutputStream.Write(body, 0, body.Length);
            context.Response.Close();
        }
        catch (Exception e)
        {
            Console.Error.WriteLine(e);
            try { context.Response.Abort(); } catch (Exception) { }
        }
    }
}
SK_EXAMPLE_END_OF_FILE
      ;;
    'ShakeKeeExample.csproj')
cat <<'SK_EXAMPLE_END_OF_FILE'
<Project Sdk="Microsoft.NET.Sdk">

  <!-- net461 runs on .NET Framework and on Mono, like the ShakeKee package itself. -->
  <PropertyGroup>
    <OutputType>Exe</OutputType>
    <TargetFramework>net461</TargetFramework>
    <LangVersion>7.3</LangVersion>
    <AssemblyName>ShakeKeeExample</AssemblyName>
    <!-- The program is Program.cs.txt, so that a project around this folder does not compile it too. -->
    <EnableDefaultCompileItems>false</EnableDefaultCompileItems>
  </PropertyGroup>

  <ItemGroup>
    <Compile Include="Program.cs.txt" />
  </ItemGroup>

  <ItemGroup>
    <!-- from the local feed that the installer made (see nuget.config) -->
    <PackageReference Include="ShakeKee" Version="*" />
    <!-- reference assemblies, so that net461 builds without Windows or Mono -->
    <PackageReference Include="Microsoft.NETFramework.ReferenceAssemblies" Version="1.0.3" PrivateAssets="all" />
  </ItemGroup>

</Project>
SK_EXAMPLE_END_OF_FILE
      ;;
    'nuget.config')
cat <<'SK_EXAMPLE_END_OF_FILE'
<?xml version="1.0" encoding="utf-8"?>
<configuration>
  <packageSources>
    <!-- the local feed of the installer: ../shakekee-packages (the nuget.org source stays for the other packages) -->
    <add key="shakekee-local" value="../shakekee-packages" />
  </packageSources>
</configuration>
SK_EXAMPLE_END_OF_FILE
      ;;
    'verify.sh')
cat <<'SK_EXAMPLE_END_OF_FILE'
#!/bin/sh
# Proves the ShakeKee example end to end: the open page is served to anybody, the protected page asks for the check first.
# Usage: sh verify.sh [--serve PORT | URL]
#   URL           where the running program serves the example (default http://127.0.0.1:8080)
#   --serve PORT  build this folder's program (dotnet build) and run it with mono on 127.0.0.1:PORT for the length of the check
# Exit status: 0 both pages behave, 1 one does not, 2 the check could not run.
set -eu
here=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
base=http://127.0.0.1:8080
open=/shakekee-example/open
protected=/shakekee-example/protected
server=
case ${1:-} in
  --serve)
    port=${2:?--serve needs a port}
    command -v dotnet >/dev/null 2>&1 || { echo "dotnet is not installed" >&2; exit 2; }
    command -v mono >/dev/null 2>&1 || { echo "mono is not installed (the example targets .NET Framework 4.6.1)" >&2; exit 2; }
    work=$(mktemp -d "${TMPDIR:-/tmp}/shakekee-example.XXXXXX")
    trap 'kill "$server" 2>/dev/null || true; rm -rf "$work"' EXIT INT TERM
    # obj/ goes to the temporary folder too: a project around this one would compile what it finds here
    dotnet build --nologo --verbosity quiet -o "$work" -p:BaseIntermediateOutputPath="$work/obj/" -p:MSBuildProjectExtensionsPath="$work/obj/" "$here/ShakeKeeExample.csproj" >/dev/null || { echo "the example does not build" >&2; exit 2; }
    if [ -f "$here/../.shakekee/shakekee.env" ]; then set -a; . "$here/../.shakekee/shakekee.env"; set +a; fi
    mono "$work/ShakeKeeExample.exe" "http://127.0.0.1:$port/" >/dev/null 2>&1 &
    server=$!
    base=http://127.0.0.1:$port
    sleep 2
    ;;
  '') ;;
  -*) echo "usage: sh verify.sh [--serve PORT | URL]" >&2; exit 2 ;;
  *) base=${1%/} ;;
esac
command -v curl >/dev/null 2>&1 || { echo "curl is not installed" >&2; exit 2; }
case ${LC_ALL:-${LC_CTYPE:-${LANG:-}}} in
  *UTF-8* | *utf-8* | *UTF8* | *utf8*) good=✔ bad=✖ ;;
  *) good=ok bad=FAIL ;;
esac
status() { c=$(curl -s -o /dev/null -w '%{http_code}' --max-time 10 "$1" || true); if [ "$c" != 000 ]; then printf '%s' "$c"; fi; }
failed=0
code=$(status "$base$open")
if [ "$code" = 200 ]; then
  echo "$good open page       $base$open answers 200"
else
  echo "$bad open page       $base$open answers ${code:-nothing}, expected 200 (is the program running?)"
  failed=1
fi
code=$(status "$base$protected")
case $code in
  403 | 429) echo "$good protected page  $base$protected answers $code: the visitor is asked for the check" ;;
  503) echo "$bad protected page  $base$protected answers 503: the daemon cannot be used (is it running? are SHAKEKEE_HOST and SHAKEKEE_SECRET set?)"; failed=1 ;;
  200) echo "$bad protected page  $base$protected answers 200 without a check: it is not protected (does the daemon's protect list for the host include this path?)"; failed=1 ;;
  *) echo "$bad protected page  $base$protected answers ${code:-nothing}, expected 403"; failed=1 ;;
esac
exit "$failed"
SK_EXAMPLE_END_OF_FILE
      ;;
  esac
}
sk_example_list_daemon() {
  printf '%s\n' 'verify.sh|755'
}
sk_example_cat_daemon() {
  case $1 in
    'verify.sh')
cat <<'SK_EXAMPLE_END_OF_FILE'
#!/bin/sh
# Proves the daemon end to end: it runs and answers, your web server passes /.shakekee/ to it, and a visitor
# who has not passed the check is asked for it (the daemon answers a preflight call with "challenge").
# Usage: sh verify.sh [--home DIR] [--daemon-bin PATH] [--host NAME] [--base URL] [--port PORT]
#   --home DIR         the ShakeKee folder (default: $SHAKEKEE_HOME, else ~/.shakekee, else the system install)
#   --daemon-bin PATH  the shakekeed program (default: DIR/bin/shakekeed, else shakekeed from the PATH)
#   --host NAME        the registered host name to test (default: the first one the daemon reports)
#   --base URL         where the public side is reached (default: https://HOST)
#   --port PORT        the loopback port of the daemon's preflight (default 7878)
# The shared secret is read from the file the daemon keeps it in; it is never printed or put on a command line.
set -eu
home=${SHAKEKEE_HOME:-}
bin=
host=
base=
port=7878
while [ $# -gt 0 ]; do
  case $1 in
    --home) home=${2:?--home needs a directory}; shift ;;
    --daemon-bin) bin=${2:?--daemon-bin needs a path}; shift ;;
    --host) host=${2:?--host needs a name}; shift ;;
    --base) base=${2:?--base needs a URL}; shift ;;
    --port) port=${2:?--port needs a number}; shift ;;
    *) echo "usage: sh verify.sh [--home DIR] [--daemon-bin PATH] [--host NAME] [--base URL] [--port PORT]" >&2; exit 2 ;;
  esac
  shift
done
command -v curl >/dev/null 2>&1 || { echo "curl is not installed" >&2; exit 2; }
if [ -z "$home" ] && [ -d "${HOME:-/nonexistent}/.shakekee" ]; then
  home=$HOME/.shakekee
fi
if [ -z "$bin" ]; then
  if [ -n "$home" ] && [ -x "$home/bin/shakekeed" ]; then
    bin=$home/bin/shakekeed
  else
    bin=$(command -v shakekeed 2>/dev/null || true)
  fi
fi
[ -n "$bin" ] && [ -x "$bin" ] || { echo "FAIL  daemon          the shakekeed program was not found (use --daemon-bin)"; exit 1; }
state=/var/lib/shakekeed
set -- status --json
if [ -n "$home" ] && [ -d "$home/state" ]; then
  state=$home/state
  set -- --home "$home" status --json
fi

failed=0
status=$("$bin" "$@" 2>/dev/null </dev/null) || status=
case $status in
  *'"reachable":true'*) echo "ok    daemon          answers on 127.0.0.1:$port" ;;
  *) echo "FAIL  daemon          does not answer on 127.0.0.1:$port (is it running? try: $bin $* )"; failed=1 ;;
esac
if [ -z "$host" ]; then
  host=$(printf '%s' "$status" | sed -n 's/.*"hostnames":\["\([^"]*\)".*/\1/p' | head -n 1)
fi
if [ -z "$host" ]; then
  echo "FAIL  registration    no registered host name was found (register one: $bin register --hostname example.com)"
  exit 1
fi
[ -n "$base" ] || base=https://$host

code=$(curl -s -o /dev/null -w '%{http_code}' --max-time 10 "$base/.shakekee/api/v1/pubkey" || true)
if [ "$code" = 200 ]; then
  echo "ok    web server      $base/.shakekee/ reaches the daemon"
else
  echo "FAIL  web server      $base/.shakekee/api/v1/pubkey answers ${code:-nothing}, expected 200 (the web server must pass /.shakekee/ to the daemon)"
  failed=1
fi

secret=$state/preflight.secret
if [ ! -r "$secret" ]; then
  echo "FAIL  preflight       cannot read the shared secret $secret (run this as the user of the daemon)"
  exit 1
fi
work=$(mktemp -d "${TMPDIR:-/tmp}/shakekee-verify.XXXXXX")
trap 'rm -rf "$work"' EXIT INT TERM HUP
(umask 077 && printf 'X-SK-Secret: %s\n' "$(cat "$secret")" >"$work/secret.hdr")
answer=$(curl -s -o /dev/null -D - --max-time 10 -X POST -H "@$work/secret.hdr" \
  -H "X-SK-Host: $host" -H "X-SK-Path: /" -H "X-SK-Method: GET" -H "X-SK-IP: 203.0.113.7" \
  "http://127.0.0.1:$port/v1/preflight" || true)
action=$(printf '%s' "$answer" | tr -d '\r' | sed -n 's/^[Xx]-[Ss][Kk]-[Aa]ction: *//p' | head -n 1)
reason=$(printf '%s' "$answer" | tr -d '\r' | sed -n 's/^[Xx]-[Ss][Kk]-[Rr]eason: *//p' | head -n 1)
case $action in
  challenge) echo "ok    protection      a visitor without the check is challenged on $host" ;;
  bypass)
    echo "FAIL  protection      the daemon lets visitors through on $host (${reason:-no reason given}); a host that is not registered, a path outside the protect rules or an ended licence do that"
    failed=1
    ;;
  *)
    echo "FAIL  protection      the daemon answered '${action:-nothing}' to the preflight call (wrong secret? $secret)"
    failed=1
    ;;
esac
exit "$failed"
SK_EXAMPLE_END_OF_FILE
      ;;
  esac
}
sk_example_list_django() {
  printf '%s\n' '__init__.py|644' 'urls.py|644' 'verify.sh|755' 'views.py|644'
}
sk_example_cat_django() {
  case $1 in
    '__init__.py')
cat <<'SK_EXAMPLE_END_OF_FILE'
# ShakeKee example app: include it in urls.py with
#     path('shakekee-example/', include('shakekee_example.urls')),
SK_EXAMPLE_END_OF_FILE
      ;;
    'urls.py')
cat <<'SK_EXAMPLE_END_OF_FILE'
from django.urls import path

from . import views

urlpatterns = [
    path('open/', views.open_page),
    path('protected/', views.protected_page),
]
SK_EXAMPLE_END_OF_FILE
      ;;
    'verify.sh')
cat <<'SK_EXAMPLE_END_OF_FILE'
#!/bin/sh
# Proves the ShakeKee example end to end: the open page is served to anybody, the protected page asks for the check first.
# Usage: sh verify.sh [--serve PORT | URL]
#   URL           where the running application serves the example (default http://127.0.0.1:8000)
#   --serve PORT  start Django's development server on 127.0.0.1:PORT for the length of the check
#                 (SHAKEKEE_PYTHON names the Python of your virtualenv, default python3)
# Exit status: 0 both pages behave, 1 one does not, 2 the check could not run.
set -eu
here=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
base=http://127.0.0.1:8000
open=/shakekee-example/open/
protected=/shakekee-example/protected/
server=
case ${1:-} in
  --serve)
    port=${2:?--serve needs a port}
    py=${SHAKEKEE_PYTHON:-python3}
    command -v "$py" >/dev/null 2>&1 || { echo "$py is not installed" >&2; exit 2; }
    if [ -f "$here/../.shakekee/shakekee.env" ]; then set -a; . "$here/../.shakekee/shakekee.env"; set +a; fi
    (cd "$here/.." && exec "$py" manage.py runserver "127.0.0.1:$port" --noreload) >/dev/null 2>&1 &
    server=$!
    trap 'kill "$server" 2>/dev/null || true' EXIT INT TERM
    base=http://127.0.0.1:$port
    sleep 3
    ;;
  '') ;;
  -*) echo "usage: sh verify.sh [--serve PORT | URL]" >&2; exit 2 ;;
  *) base=${1%/} ;;
esac
command -v curl >/dev/null 2>&1 || { echo "curl is not installed" >&2; exit 2; }
case ${LC_ALL:-${LC_CTYPE:-${LANG:-}}} in
  *UTF-8* | *utf-8* | *UTF8* | *utf8*) good=✔ bad=✖ ;;
  *) good=ok bad=FAIL ;;
esac
status() { c=$(curl -s -o /dev/null -w '%{http_code}' --max-time 10 "$1" || true); if [ "$c" != 000 ]; then printf '%s' "$c"; fi; }
failed=0
code=$(status "$base$open")
if [ "$code" = 200 ]; then
  echo "$good open page       $base$open answers 200"
else
  echo "$bad open page       $base$open answers ${code:-nothing}, expected 200 (is the application running, and the example included in urls.py?)"
  failed=1
fi
code=$(status "$base$protected")
case $code in
  403 | 429) echo "$good protected page  $base$protected answers $code: the visitor is asked for the check" ;;
  503) echo "$bad protected page  $base$protected answers 503: ShakeKee cannot be used (key or daemon: see .shakekee/ and the log)"; failed=1 ;;
  200) echo "$bad protected page  $base$protected answers 200 without a check: it is not protected (is shakekee_settings.apply(globals()) in settings.py? no key yet, or ShakeKee unreachable and failing open)"; failed=1 ;;
  *) echo "$bad protected page  $base$protected answers ${code:-nothing}, expected 403"; failed=1 ;;
esac
exit "$failed"
SK_EXAMPLE_END_OF_FILE
      ;;
    'views.py')
cat <<'SK_EXAMPLE_END_OF_FILE'
from django.http import HttpResponse


def open_page(request):
    """Served to anybody: ShakeKee is not involved."""
    return HttpResponse('ShakeKee example: this page is open.\n', content_type='text/plain; charset=utf-8')


def protected_page(request):
    """/shakekee-example/protected is in SHAKEKEE_PROTECT_PATHS (shakekee_settings.py): the middleware answers a visitor
    who has not passed the check with the wall (status 403) before this view runs."""
    return HttpResponse('ShakeKee example: you passed the check and see the protected page.\n', content_type='text/plain; charset=utf-8')
SK_EXAMPLE_END_OF_FILE
      ;;
  esac
}
sk_example_list_drupal() {
  printf '%s\n' 'verify.sh|755'
}
sk_example_cat_drupal() {
  case $1 in
    'verify.sh')
cat <<'SK_EXAMPLE_END_OF_FILE'
#!/bin/sh
# Proves the ShakeKee module on this Drupal site: a page the daemon protects answers with the verification wall
# (status 403) to a visitor who has not passed the check. The daemon's protect list decides which pages those are
# (the whole site by default).
# Usage: sh verify.sh [--open URL] [PROTECTED-URL]
#   Default: the address in the file "urls" next to this script (written by the installer).
#   --open URL   a page the protect list leaves out: it must answer 200
# Exit status: 0 all checks passed, 1 a check failed, 2 the check could not be run (no curl, no address, no answer).
set -eu
here=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
url=
open=
while [ $# -gt 0 ]; do
  case $1 in
    --open) open=${2:?--open needs an address}; shift ;;
    -*) echo "usage: sh verify.sh [--open URL] [PROTECTED-URL]" >&2; exit 2 ;;
    *) url=$1 ;;
  esac
  shift
done
if [ -z "$url" ] && [ -f "$here/urls" ]; then
  url=$(sed -n 's/^protected //p' "$here/urls" | head -n 1)
fi
if [ -z "$url" ]; then
  echo "✖ no address: give a protected page (sh verify.sh https://example.com/)" >&2
  exit 2
fi
command -v curl >/dev/null 2>&1 || { echo "✖ curl is not installed" >&2; exit 2; }

# probe URL: code is the status of a GET (000 when nothing answers), action the daemon's verdict header, if any
probe() {
  out=$(curl -s -L --max-redirs 3 --max-time 15 -o /dev/null -D - -w 'STATUS %{http_code}\n' "$1" 2>/dev/null) || out=
  code=$(printf '%s\n' "$out" | sed -n 's/^STATUS //p' | tail -n 1)
  action=$(printf '%s\n' "$out" | tr -d '\r' | sed -n 's/^[Xx]-[Ss][Kk]-[Aa]ction: *//p' | tail -n 1)
  code=${code:-000}
}

failed=0
probe "$url"
if [ "$code" = 000 ]; then
  echo "✖ the site did not answer: $url" >&2
  exit 2
fi
case $code in
  403 | 429)
    echo "✔ protected page  $url answers $code: the visitor is asked for the check${action:+ (verdict: $action)}"
    ;;
  503)
    echo "✖ protected page  $url answers 503: ShakeKee cannot be used (is the daemon running, is the secret right? with fail closed every request is refused)"
    failed=1
    ;;
  200)
    echo "✖ protected page  $url answers 200 without a check: the page is not protected (module not enabled, host name or secret wrong so it fails open, daemon not running, or the page is not in the daemon's protect list)"
    failed=1
    ;;
  *)
    echo "✖ protected page  $url answers $code, expected 403"
    failed=1
    ;;
esac
if [ -n "$open" ]; then
  probe "$open"
  if [ "$code" = 200 ]; then
    echo "✔ open page       $open answers 200"
  else
    echo "✖ open page       $open answers $code, expected 200"
    failed=1
  fi
fi
exit "$failed"
SK_EXAMPLE_END_OF_FILE
      ;;
  esac
}
sk_example_list_go() {
  printf '%s\n' 'main.go|644' 'verify.sh|755'
}
sk_example_cat_go() {
  case $1 in
    'main.go')
cat <<'SK_EXAMPLE_END_OF_FILE'
// ShakeKee example: a small program of its own, in your module.
//
//	set -a; . .shakekee/shakekee.env; set +a; go run ./shakekee-example     (LISTEN, default 127.0.0.1:8080)
//
// /shakekee-example/open is served to anybody. /shakekee-example/protected goes through the ShakeKee daemon
// first: a visitor who has not passed the check gets the wall (status 403) instead of the page.
package main

import (
	"fmt"
	"log"
	"net/http"
	"os"
	"time"

	"github.com/shakekee/shakekee-go"
)

func env(name, def string) string {
	if v := os.Getenv(name); v != "" {
		return v
	}
	return def
}

func main() {
	g, err := shakekee.New(shakekee.Config{
		DaemonURL:  env("SHAKEKEE_PREFLIGHT_URL", "http://127.0.0.1:7878"),
		PublicURL:  env("SHAKEKEE_PUBLIC_URL", "http://127.0.0.1:7879"),
		Secret:     os.Getenv("SHAKEKEE_PREFLIGHT_SECRET"),
		Host:       os.Getenv("SHAKEKEE_HOST"),
		FailClosed: true, // while the daemon cannot be used the protected page answers 503, not the page
	})
	if err != nil {
		log.Fatal(err)
	}

	protected := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		fmt.Fprintln(w, "ShakeKee example: you passed the check and see the protected page.")
	})
	mux := http.NewServeMux()
	// The wall's own files and status stream, on this site's address, outside the guard.
	mux.Handle("/.shakekee/", g.Proxy())
	mux.Handle("/.well-known/shakekee/", g.Proxy())
	mux.HandleFunc("/shakekee-example/open", func(w http.ResponseWriter, r *http.Request) {
		fmt.Fprintln(w, "ShakeKee example: this page is open.")
	})
	mux.Handle("/shakekee-example/protected", g.Middleware(protected))

	srv := &http.Server{
		Addr:              env("LISTEN", "127.0.0.1:8080"),
		Handler:           mux,
		ReadHeaderTimeout: 10 * time.Second,
	}
	log.Fatal(srv.ListenAndServe())
}
SK_EXAMPLE_END_OF_FILE
      ;;
    'verify.sh')
cat <<'SK_EXAMPLE_END_OF_FILE'
#!/bin/sh
# Proves the ShakeKee example end to end: the open page is served to anybody, the protected page asks for the check first.
# Usage: sh verify.sh [--serve PORT | URL]
#   URL           where the running program serves the example (default http://127.0.0.1:8080)
#   --serve PORT  build this folder's program and run it on 127.0.0.1:PORT for the length of the check
# Exit status: 0 both pages behave, 1 one does not, 2 the check could not run.
set -eu
here=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
base=http://127.0.0.1:8080
open=/shakekee-example/open
protected=/shakekee-example/protected
server=
case ${1:-} in
  --serve)
    port=${2:?--serve needs a port}
    command -v go >/dev/null 2>&1 || { echo "go is not installed" >&2; exit 2; }
    work=$(mktemp -d "${TMPDIR:-/tmp}/shakekee-example.XXXXXX")
    trap 'kill "$server" 2>/dev/null || true; rm -rf "$work"' EXIT INT TERM
    (cd "$here/.." && go build -o "$work/example" "./${here##*/}") || { echo "the example does not build" >&2; exit 2; }
    if [ -f "$here/../.shakekee/shakekee.env" ]; then set -a; . "$here/../.shakekee/shakekee.env"; set +a; fi
    LISTEN=127.0.0.1:$port "$work/example" >/dev/null 2>&1 &
    server=$!
    base=http://127.0.0.1:$port
    sleep 1
    ;;
  '') ;;
  -*) echo "usage: sh verify.sh [--serve PORT | URL]" >&2; exit 2 ;;
  *) base=${1%/} ;;
esac
command -v curl >/dev/null 2>&1 || { echo "curl is not installed" >&2; exit 2; }
case ${LC_ALL:-${LC_CTYPE:-${LANG:-}}} in
  *UTF-8* | *utf-8* | *UTF8* | *utf8*) good=✔ bad=✖ ;;
  *) good=ok bad=FAIL ;;
esac
status() { c=$(curl -s -o /dev/null -w '%{http_code}' --max-time 10 "$1" || true); if [ "$c" != 000 ]; then printf '%s' "$c"; fi; }
failed=0
code=$(status "$base$open")
if [ "$code" = 200 ]; then
  echo "$good open page       $base$open answers 200"
else
  echo "$bad open page       $base$open answers ${code:-nothing}, expected 200 (is the program running?)"
  failed=1
fi
code=$(status "$base$protected")
case $code in
  403 | 429) echo "$good protected page  $base$protected answers $code: the visitor is asked for the check" ;;
  503) echo "$bad protected page  $base$protected answers 503: the daemon cannot be used (is it running? are SHAKEKEE_HOST and SHAKEKEE_PREFLIGHT_SECRET set?)"; failed=1 ;;
  200) echo "$bad protected page  $base$protected answers 200 without a check: it is not protected (does the daemon's protect list for the host include this path?)"; failed=1 ;;
  *) echo "$bad protected page  $base$protected answers ${code:-nothing}, expected 403"; failed=1 ;;
esac
exit "$failed"
SK_EXAMPLE_END_OF_FILE
      ;;
  esac
}
sk_example_list_joomla() {
  printf '%s\n' 'verify.sh|755'
}
sk_example_cat_joomla() {
  case $1 in
    'verify.sh')
cat <<'SK_EXAMPLE_END_OF_FILE'
#!/bin/sh
# Proves the ShakeKee plugin on this Joomla site: the login form carries the ShakeKee widget (the plugin adds a
# script to the page that draws it), so the plugin is enabled and loaded. A page you named under "Protected URL
# paths" can be checked as well: it should answer with the wall (403) to a visitor who has not passed the check.
# Usage: sh verify.sh [--protected URL] [LOGIN-URL | SITE-URL]
#   Default: the address in the file "urls" next to this script (written by the installer).
# Exit status: 0 all checks passed, 1 a check failed, 2 the check could not be run (no curl, no address, no answer).
set -eu
here=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
url=
protected=
while [ $# -gt 0 ]; do
  case $1 in
    --protected) protected=${2:?--protected needs an address}; shift ;;
    -*) echo "usage: sh verify.sh [--protected URL] [LOGIN-URL | SITE-URL]" >&2; exit 2 ;;
    *) url=$1 ;;
  esac
  shift
done
if [ -z "$url" ] && [ -f "$here/urls" ]; then
  url=$(sed -n 's/^login //p' "$here/urls" | head -n 1)
fi
if [ -z "$url" ]; then
  echo "✖ no address: give the login page or the site address (sh verify.sh https://example.com)" >&2
  exit 2
fi
case $url in
  *com_users*) ;;
  *) url="${url%/}/index.php?option=com_users&view=login" ;;
esac
command -v curl >/dev/null 2>&1 || { echo "✖ curl is not installed" >&2; exit 2; }

page=$(mktemp "${TMPDIR:-/tmp}/shakekee-verify.XXXXXX")
trap 'rm -f "$page"' EXIT INT TERM
code=$(curl -s -L --max-redirs 3 --max-time 20 -o "$page" -w '%{http_code}' "$url" 2>/dev/null) || code=000
if [ "$code" = 000 ]; then
  echo "✖ the site did not answer: $url" >&2
  exit 2
fi
failed=0
if [ "$code" != 200 ]; then
  echo "✖ login page      $url answers $code, expected 200"
  failed=1
elif grep -q 'skw_challenge_hex' "$page"; then
  echo "✔ login page      $url carries the ShakeKee widget"
else
  echo "✖ login page      $url has no ShakeKee widget: is the plugin enabled (System > Manage > Plugins), \"Enable on Login\" on, and the page not cached?"
  failed=1
fi
if [ -n "$protected" ]; then
  pcode=$(curl -s -L --max-redirs 3 --max-time 20 -o /dev/null -w '%{http_code}' "$protected" 2>/dev/null) || pcode=000
  case $pcode in
    403 | 429) echo "✔ protected page  $protected answers $pcode: the visitor is asked for the check" ;;
    200) echo "✖ protected page  $protected answers 200 without a check: add its path under \"Protected URL paths\" in the plugin's settings"; failed=1 ;;
    *) echo "✖ protected page  $protected answers $pcode, expected 403"; failed=1 ;;
  esac
fi
exit "$failed"
SK_EXAMPLE_END_OF_FILE
      ;;
  esac
}
sk_example_list_laravel() {
  printf '%s\n' 'routes.php|644' 'verify.sh|755'
}
sk_example_cat_laravel() {
  case $1 in
    'routes.php')
cat <<'SK_EXAMPLE_END_OF_FILE'
<?php
// ShakeKee example routes. Load them with one line in routes/web.php:
//     require base_path('shakekee-example/routes.php');
// /shakekee-example/open is served to anybody. /shakekee-example/protected goes through the 'shakekee' middleware:
// a visitor who has not passed the check gets the wall (status 403) instead of the page.
use Illuminate\Support\Facades\Route;

Route::get('/shakekee-example/open', fn () => response("ShakeKee example: this page is open.\n", 200, ['Content-Type' => 'text/plain; charset=utf-8']));

Route::middleware('shakekee')->get('/shakekee-example/protected', fn () => response("ShakeKee example: you passed the check and see the protected page.\n", 200, ['Content-Type' => 'text/plain; charset=utf-8']));
SK_EXAMPLE_END_OF_FILE
      ;;
    'verify.sh')
cat <<'SK_EXAMPLE_END_OF_FILE'
#!/bin/sh
# Proves the ShakeKee example end to end: the open page is served to anybody, the protected page asks for the check first.
# Usage: sh verify.sh [--serve PORT | URL]
#   URL           where the running application serves the example (default http://127.0.0.1:8000)
#   --serve PORT  start PHP's built-in server for the project on 127.0.0.1:PORT, for the length of the check
# Exit status: 0 both pages behave, 1 one does not, 2 the check could not run.
set -eu
here=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
base=http://127.0.0.1:8000
open=/shakekee-example/open
protected=/shakekee-example/protected
server=
case ${1:-} in
  --serve)
    port=${2:?--serve needs a port}
    command -v php >/dev/null 2>&1 || { echo "php is not installed" >&2; exit 2; }
    if [ -f "$here/../.shakekee/shakekee.env" ]; then set -a; . "$here/../.shakekee/shakekee.env"; set +a; fi
    (cd "$here/.." && exec php -S "127.0.0.1:$port" -t public vendor/laravel/framework/src/Illuminate/Foundation/resources/server.php) >/dev/null 2>&1 &
    server=$!
    trap 'kill "$server" 2>/dev/null || true' EXIT INT TERM
    base=http://127.0.0.1:$port
    sleep 2
    ;;
  '') ;;
  -*) echo "usage: sh verify.sh [--serve PORT | URL]" >&2; exit 2 ;;
  *) base=${1%/} ;;
esac
command -v curl >/dev/null 2>&1 || { echo "curl is not installed" >&2; exit 2; }
case ${LC_ALL:-${LC_CTYPE:-${LANG:-}}} in
  *UTF-8* | *utf-8* | *UTF8* | *utf8*) good=✔ bad=✖ ;;
  *) good=ok bad=FAIL ;;
esac
status() { c=$(curl -s -o /dev/null -w '%{http_code}' --max-time 10 "$1" || true); if [ "$c" != 000 ]; then printf '%s' "$c"; fi; }
failed=0
code=$(status "$base$open")
if [ "$code" = 200 ]; then
  echo "$good open page       $base$open answers 200"
else
  echo "$bad open page       $base$open answers ${code:-nothing}, expected 200 (is the application running, and the example loaded?)"
  failed=1
fi
code=$(status "$base$protected")
case $code in
  403 | 429) echo "$good protected page  $base$protected answers $code: the visitor is asked for the check" ;;
  503) echo "$bad protected page  $base$protected answers 503: ShakeKee cannot be used (key or daemon: see .shakekee/ and the log)"; failed=1 ;;
  200) echo "$bad protected page  $base$protected answers 200 without a check: it is not protected (no key yet, or ShakeKee unreachable and failing open)"; failed=1 ;;
  *) echo "$bad protected page  $base$protected answers ${code:-nothing}, expected 403"; failed=1 ;;
esac
exit "$failed"
SK_EXAMPLE_END_OF_FILE
      ;;
  esac
}
sk_example_list_nodejs() {
  printf '%s\n' 'server.js|644' 'verify.sh|755'
}
sk_example_cat_nodejs() {
  case $1 in
    'server.js')
cat <<'SK_EXAMPLE_END_OF_FILE'
// ShakeKee example: a small Express server of its own (needs express in the project).
//   node shakekee-example/server.js            (PORT, default 3000; run it from the project folder)
// /shakekee-example/open is served to anybody; /shakekee-example/protected asks for the check first (status 403).
// With SHAKEKEE_PREFLIGHT_SECRET in the environment (.shakekee/shakekee.env, daemon mode) the local daemon decides;
// .shakekee/shakekee.env may also name the API and CDN of a ShakeKee that is not the production one (SHAKEKEE_API_BASE, SHAKEKEE_CDN_URL);
// otherwise the saved key in .shakekee/credentials.json is used (account mode).
const fs = require('fs');
const path = require('path');
const express = require('express');

const env = path.join(__dirname, '..', '.shakekee', 'shakekee.env');
if (fs.existsSync(env)) {
  for (const line of fs.readFileSync(env, 'utf8').split('\n')) {
    const m = /^([A-Z_]+)=(.*)$/.exec(line);
    if (m && process.env[m[1]] === undefined) process.env[m[1]] = m[2];
  }
}
process.env.SHAKEKEE_STATE_DIR = process.env.SHAKEKEE_STATE_DIR || path.join(__dirname, '..', '.shakekee');

const app = express();
const protect = '/shakekee-example/protected';
if (process.env.SHAKEKEE_PREFLIGHT_SECRET) {
  const { shakekeed } = require('shakekee-nodejs/shakekeed');
  app.use(protect, shakekeed({
    url: process.env.SHAKEKEE_PREFLIGHT_URL,
    secret: process.env.SHAKEKEE_PREFLIGHT_SECRET,
    host: process.env.SHAKEKEE_HOST,
    onDaemonError: 'closed',
  }));
} else {
  const shakekee = require('shakekee-nodejs');
  const config = shakekee.loadConfig();
  app.use(shakekee.middleware({ config, protectPaths: [protect] }));
  app.use(shakekee.routes(config));
}

app.get('/shakekee-example/open', (req, res) => res.type('text/plain').send('ShakeKee example: this page is open.\n'));
app.get(protect, (req, res) => res.type('text/plain').send('ShakeKee example: you passed the check and see the protected page.\n'));

app.listen(Number(process.env.PORT) || 3000, process.env.HOST || '127.0.0.1', () => console.log('listening'));
SK_EXAMPLE_END_OF_FILE
      ;;
    'verify.sh')
cat <<'SK_EXAMPLE_END_OF_FILE'
#!/bin/sh
# Proves the ShakeKee example end to end: the open page is served to anybody, the protected page asks for the check first.
# Usage: sh verify.sh [--serve PORT | URL]
#   URL           where the running application serves the example (default http://127.0.0.1:3000)
#   --serve PORT  start server.js of this folder on 127.0.0.1:PORT for the length of the check
# Exit status: 0 both pages behave, 1 one does not, 2 the check could not run.
set -eu
here=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
base=http://127.0.0.1:3000
open=/shakekee-example/open
protected=/shakekee-example/protected
server=
case ${1:-} in
  --serve)
    port=${2:?--serve needs a port}
    command -v node >/dev/null 2>&1 || { echo "node is not installed" >&2; exit 2; }
    (cd "$here/.." && exec env PORT="$port" node "$here/server.js") >/dev/null 2>&1 &
    server=$!
    trap 'kill "$server" 2>/dev/null || true' EXIT INT TERM
    base=http://127.0.0.1:$port
    sleep 2
    ;;
  '') ;;
  -*) echo "usage: sh verify.sh [--serve PORT | URL]" >&2; exit 2 ;;
  *) base=${1%/} ;;
esac
command -v curl >/dev/null 2>&1 || { echo "curl is not installed" >&2; exit 2; }
case ${LC_ALL:-${LC_CTYPE:-${LANG:-}}} in
  *UTF-8* | *utf-8* | *UTF8* | *utf8*) good=✔ bad=✖ ;;
  *) good=ok bad=FAIL ;;
esac
status() { c=$(curl -s -o /dev/null -w '%{http_code}' --max-time 10 "$1" || true); if [ "$c" != 000 ]; then printf '%s' "$c"; fi; }
failed=0
code=$(status "$base$open")
if [ "$code" = 200 ]; then
  echo "$good open page       $base$open answers 200"
else
  echo "$bad open page       $base$open answers ${code:-nothing}, expected 200 (is the application running?)"
  failed=1
fi
code=$(status "$base$protected")
case $code in
  403 | 429) echo "$good protected page  $base$protected answers $code: the visitor is asked for the check" ;;
  503) echo "$bad protected page  $base$protected answers 503: ShakeKee cannot be used (key or daemon: see .shakekee/ and the log)"; failed=1 ;;
  200) echo "$bad protected page  $base$protected answers 200 without a check: it is not protected (no key yet, or ShakeKee unreachable and failing open; in daemon mode the daemon's protect list must include the path)"; failed=1 ;;
  *) echo "$bad protected page  $base$protected answers ${code:-nothing}, expected 403"; failed=1 ;;
esac
exit "$failed"
SK_EXAMPLE_END_OF_FILE
      ;;
  esac
}
sk_example_list_php() {
  printf '%s\n' 'open/index.php|644' 'protected/index.php|644' 'verify.sh|755'
}
sk_example_cat_php() {
  case $1 in
    'open/index.php')
cat <<'SK_EXAMPLE_END_OF_FILE'
<?php
// An open page: ShakeKee is not involved, anybody gets it. verify.sh expects the answer 200.
header('Content-Type: text/plain; charset=utf-8');
echo "ShakeKee example: this page is open.\n";
SK_EXAMPLE_END_OF_FILE
      ;;
    'protected/index.php')
cat <<'SK_EXAMPLE_END_OF_FILE'
<?php
// A protected page: ShakeKee decides before anything below the protect() call runs. A visitor who has not
// passed the check gets the wall (status 403) instead of this page. The settings come from the environment:
//   SHAKEKEE_HOST    the licensed host name
//   SHAKEKEE_SECRET  the shared secret of the local daemon (at least 16 bytes)
// SHAKEKEE_PHP may name the package's autoload.php or src/ShakeKee.php when it is somewhere unusual.
$candidates = [
    getenv('SHAKEKEE_PHP') ?: '',
    __DIR__ . '/../../vendor/autoload.php',
    __DIR__ . '/../vendor/autoload.php',
    getcwd() . '/vendor/autoload.php',
];
foreach ($candidates as $file) {
    if ($file !== '' && is_file($file)) {
        require $file;
        break;
    }
}
if (!class_exists('ShakeKee\\Guard')) {
    http_response_code(500);
    header('Content-Type: text/plain; charset=utf-8');
    echo "ShakeKee for PHP was not found. Install it (get.shakekee.com/php) or set SHAKEKEE_PHP.\n";
    exit;
}
// The example fails closed: while the daemon cannot be used you see a 503 instead of an unprotected page.
if (getenv('SHAKEKEE_FAIL_MODE') === false) {
    putenv('SHAKEKEE_FAIL_MODE=closed');
}
ShakeKee\Guard::protect();

header('Content-Type: text/plain; charset=utf-8');
echo "ShakeKee example: you passed the check and see the protected page.\n";
SK_EXAMPLE_END_OF_FILE
      ;;
    'verify.sh')
cat <<'SK_EXAMPLE_END_OF_FILE'
#!/bin/sh
# Proves the example: the open page is served to anybody, the protected page asks for the check first.
# Usage: sh verify.sh [--serve PORT | URL]
#   URL           where the example directory is served (default http://127.0.0.1:8080)
#   --serve PORT  start PHP's built-in web server for this directory on 127.0.0.1:PORT, for the length of the check
# The protected page needs SHAKEKEE_HOST and SHAKEKEE_SECRET in the environment and a running daemon.
set -eu
here=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
base=http://127.0.0.1:8080
server=
case ${1:-} in
  --serve)
    port=${2:?--serve needs a port}
    command -v php >/dev/null 2>&1 || { echo "php is not installed" >&2; exit 2; }
    # the settings the installer wrote (SHAKEKEE_HOST, SHAKEKEE_SECRET); what is already in the environment wins
    if [ -f "$here/../.shakekee/shakekee.env" ]; then
      while IFS='=' read -r key value; do
        case $key in
          SHAKEKEE_[A-Z_]*) if [ -z "$(printenv "$key")" ]; then export "$key=$value"; fi ;;
        esac
      done <"$here/../.shakekee/shakekee.env"
    fi
    php -S "127.0.0.1:$port" -t "$here" >/dev/null 2>&1 &
    server=$!
    trap 'kill "$server" 2>/dev/null || true' EXIT INT TERM
    base=http://127.0.0.1:$port
    sleep 1
    ;;
  '') ;;
  -*) echo "usage: sh verify.sh [--serve PORT | URL]" >&2; exit 2 ;;
  *) base=${1%/} ;;
esac
command -v curl >/dev/null 2>&1 || { echo "curl is not installed" >&2; exit 2; }

status() {
  curl -s -o /dev/null -w '%{http_code}' --max-time 10 "$1" || true
}

failed=0
open=$(status "$base/open/")
if [ "$open" = 200 ]; then
  echo "ok    open page       $base/open/ answers 200"
else
  echo "FAIL  open page       $base/open/ answers ${open:-nothing}, expected 200"
  failed=1
fi
protected=$(status "$base/protected/")
case $protected in
  403 | 429)
    echo "ok    protected page  $base/protected/ answers $protected: the visitor is asked for the check"
    ;;
  503)
    echo "FAIL  protected page  $base/protected/ answers 503: the daemon cannot be used (is it running? are SHAKEKEE_HOST and SHAKEKEE_SECRET set?)"
    failed=1
    ;;
  200)
    echo "FAIL  protected page  $base/protected/ answers 200 without a check: the page is not protected"
    failed=1
    ;;
  *)
    echo "FAIL  protected page  $base/protected/ answers ${protected:-nothing}, expected 403"
    failed=1
    ;;
esac
exit "$failed"
SK_EXAMPLE_END_OF_FILE
      ;;
  esac
}
sk_example_list_prestashop() {
  printf '%s\n' 'verify.sh|755'
}
sk_example_cat_prestashop() {
  case $1 in
    'verify.sh')
cat <<'SK_EXAMPLE_END_OF_FILE'
#!/bin/sh
# Proves the ShakeKee module on this PrestaShop shop: a front office page the daemon protects answers with the
# verification wall (status 403) to a visitor who has not passed the check. The daemon's protect list decides which
# pages those are (the whole site by default).
# Usage: sh verify.sh [--open URL] [PROTECTED-URL]
#   Default: the address in the file "urls" next to this script (written by the installer).
#   --open URL   a page the protect list leaves out: it must answer 200
# Exit status: 0 all checks passed, 1 a check failed, 2 the check could not be run (no curl, no address, no answer).
set -eu
here=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
url=
open=
while [ $# -gt 0 ]; do
  case $1 in
    --open) open=${2:?--open needs an address}; shift ;;
    -*) echo "usage: sh verify.sh [--open URL] [PROTECTED-URL]" >&2; exit 2 ;;
    *) url=$1 ;;
  esac
  shift
done
if [ -z "$url" ] && [ -f "$here/urls" ]; then
  url=$(sed -n 's/^protected //p' "$here/urls" | head -n 1)
fi
if [ -z "$url" ]; then
  echo "✖ no address: give a protected page (sh verify.sh https://shop.example.com/)" >&2
  exit 2
fi
command -v curl >/dev/null 2>&1 || { echo "✖ curl is not installed" >&2; exit 2; }

# probe URL: code is the status of a GET (000 when nothing answers), action the daemon's verdict header, if any
probe() {
  out=$(curl -s -L --max-redirs 3 --max-time 15 -o /dev/null -D - -w 'STATUS %{http_code}\n' "$1" 2>/dev/null) || out=
  code=$(printf '%s\n' "$out" | sed -n 's/^STATUS //p' | tail -n 1)
  action=$(printf '%s\n' "$out" | tr -d '\r' | sed -n 's/^[Xx]-[Ss][Kk]-[Aa]ction: *//p' | tail -n 1)
  code=${code:-000}
}

failed=0
probe "$url"
if [ "$code" = 000 ]; then
  echo "✖ the shop did not answer: $url" >&2
  exit 2
fi
case $code in
  403 | 429)
    echo "✔ protected page  $url answers $code: the visitor is asked for the check${action:+ (verdict: $action)}"
    ;;
  503)
    echo "✖ protected page  $url answers 503: ShakeKee cannot be used (is the daemon running, is the secret right? with fail closed every request is refused)"
    failed=1
    ;;
  200)
    echo "✖ protected page  $url answers 200 without a check: the page is not protected (module not enabled, \"Ask the daemon\" off, host name or secret wrong so it fails open, daemon not running, or the page is not in the daemon's protect list)"
    failed=1
    ;;
  *)
    echo "✖ protected page  $url answers $code, expected 403"
    failed=1
    ;;
esac
if [ -n "$open" ]; then
  probe "$open"
  if [ "$code" = 200 ]; then
    echo "✔ open page       $open answers 200"
  else
    echo "✖ open page       $open answers $code, expected 200"
    failed=1
  fi
fi
exit "$failed"
SK_EXAMPLE_END_OF_FILE
      ;;
  esac
}
sk_example_list_rust() {
  printf '%s\n' 'Cargo.toml|644' 'src/main.rs|644' 'verify.sh|755'
}
sk_example_cat_rust() {
  case $1 in
    'Cargo.toml')
cat <<'SK_EXAMPLE_END_OF_FILE'
# ShakeKee example: a crate of its own. It uses the ShakeKee crate that the installer put in vendor/shakekee.
[package]
name = "shakekee-example"
version = "0.1.0"
edition = "2021"
publish = false

# its own workspace, so that a workspace around your project does not take it in
[workspace]

[dependencies]
shakekee = { path = "../vendor/shakekee", features = ["axum"] }
axum = { version = "0.8", features = ["http1", "tokio"] }
tokio = { version = "1", features = ["macros", "net", "rt-multi-thread"] }
SK_EXAMPLE_END_OF_FILE
      ;;
    'src/main.rs')
cat <<'SK_EXAMPLE_END_OF_FILE'
//! ShakeKee example: an axum server.
//!
//! ```text
//! set -a; . ../.shakekee/shakekee.env; set +a; cargo run     (LISTEN, default 127.0.0.1:8080)
//! ```
//!
//! /shakekee-example/open is served to anybody. /shakekee-example/protected goes through the ShakeKee daemon
//! first: a visitor who has not passed the check gets the wall (status 403) instead of the page.

use std::net::SocketAddr;

use axum::middleware::from_fn_with_state;
use axum::routing::get;
use axum::Router;
use shakekee::{Config, Preflight, Proxy};

fn env(name: &str, default: &str) -> String {
    std::env::var(name).unwrap_or_else(|_| default.to_string())
}

#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
    let config = Config::new(env("SHAKEKEE_PREFLIGHT_SECRET", ""), env("SHAKEKEE_HOST", ""))
        .fail_closed(true); // while the daemon cannot be used the protected page answers 503

    let app = Router::new()
        .route(
            "/shakekee-example/protected",
            get(|| async { "ShakeKee example: you passed the check and see the protected page.\n" }),
        )
        // routes added before the layer are asked about at the daemon; what comes after it is not
        .layer(from_fn_with_state(Preflight::new(&config)?, shakekee::axum::guard))
        .route(
            "/shakekee-example/open",
            get(|| async { "ShakeKee example: this page is open.\n" }),
        )
        .merge(shakekee::axum::proxy_routes(Proxy::new(&config)?));

    let listener = tokio::net::TcpListener::bind(env("LISTEN", "127.0.0.1:8080")).await?;
    // ConnectInfo is where the guard reads the visitor's address from
    axum::serve(listener, app.into_make_service_with_connect_info::<SocketAddr>()).await?;
    Ok(())
}
SK_EXAMPLE_END_OF_FILE
      ;;
    'verify.sh')
cat <<'SK_EXAMPLE_END_OF_FILE'
#!/bin/sh
# Proves the ShakeKee example end to end: the open page is served to anybody, the protected page asks for the check first.
# Usage: sh verify.sh [--serve PORT | URL]
#   URL           where the running program serves the example (default http://127.0.0.1:8080)
#   --serve PORT  build this folder's crate and run it on 127.0.0.1:PORT for the length of the check
# Exit status: 0 both pages behave, 1 one does not, 2 the check could not run.
set -eu
here=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
base=http://127.0.0.1:8080
open=/shakekee-example/open
protected=/shakekee-example/protected
server=
case ${1:-} in
  --serve)
    port=${2:?--serve needs a port}
    command -v cargo >/dev/null 2>&1 || { echo "cargo is not installed" >&2; exit 2; }
    cargo build --quiet --manifest-path "$here/Cargo.toml" || { echo "the example does not build" >&2; exit 2; }
    trap 'kill "$server" 2>/dev/null || true' EXIT INT TERM
    if [ -f "$here/../.shakekee/shakekee.env" ]; then set -a; . "$here/../.shakekee/shakekee.env"; set +a; fi
    LISTEN=127.0.0.1:$port "$here/target/debug/shakekee-example" >/dev/null 2>&1 &
    server=$!
    base=http://127.0.0.1:$port
    sleep 1
    ;;
  '') ;;
  -*) echo "usage: sh verify.sh [--serve PORT | URL]" >&2; exit 2 ;;
  *) base=${1%/} ;;
esac
command -v curl >/dev/null 2>&1 || { echo "curl is not installed" >&2; exit 2; }
case ${LC_ALL:-${LC_CTYPE:-${LANG:-}}} in
  *UTF-8* | *utf-8* | *UTF8* | *utf8*) good=✔ bad=✖ ;;
  *) good=ok bad=FAIL ;;
esac
status() { c=$(curl -s -o /dev/null -w '%{http_code}' --max-time 10 "$1" || true); if [ "$c" != 000 ]; then printf '%s' "$c"; fi; }
failed=0
code=$(status "$base$open")
if [ "$code" = 200 ]; then
  echo "$good open page       $base$open answers 200"
else
  echo "$bad open page       $base$open answers ${code:-nothing}, expected 200 (is the program running?)"
  failed=1
fi
code=$(status "$base$protected")
case $code in
  403 | 429) echo "$good protected page  $base$protected answers $code: the visitor is asked for the check" ;;
  503) echo "$bad protected page  $base$protected answers 503: the daemon cannot be used (is it running? are SHAKEKEE_HOST and SHAKEKEE_PREFLIGHT_SECRET set?)"; failed=1 ;;
  200) echo "$bad protected page  $base$protected answers 200 without a check: it is not protected (does the daemon's protect list for the host include this path?)"; failed=1 ;;
  *) echo "$bad protected page  $base$protected answers ${code:-nothing}, expected 403"; failed=1 ;;
esac
exit "$failed"
SK_EXAMPLE_END_OF_FILE
      ;;
  esac
}
sk_example_list_shopify() {
  printf '%s\n' 'open/shakekee-example-open.liquid|644' 'protected/shakekee-example-protected.liquid|644' 'verify.sh|755'
}
sk_example_cat_shopify() {
  case $1 in
    'open/shakekee-example-open.liquid')
cat <<'SK_EXAMPLE_END_OF_FILE'
{% comment %}
  ShakeKee example, the open form: a contact form with no check, for comparison.
  Copy this file to the sections/ folder of your theme, then add the section "ShakeKee example (open)" in the theme editor.
{% endcomment %}
<section class="shakekee-example shakekee-example--open">
  <h2>{{ section.settings.heading }}</h2>
  {% form 'contact' %}
    {{ form.errors | default_errors }}
    <p><input type="email" name="contact[email]" required placeholder="you@example.com"></p>
    <p><textarea name="contact[body]" required></textarea></p>
    <p><button type="submit">Send</button></p>
  {% endform %}
</section>

{% schema %}
{
  "name": "ShakeKee example (open)",
  "settings": [
    { "type": "text", "id": "heading", "label": "Heading", "default": "Contact us (open)" }
  ],
  "presets": [{ "name": "ShakeKee example (open)" }]
}
{% endschema %}
SK_EXAMPLE_END_OF_FILE
      ;;
    'protected/shakekee-example-protected.liquid')
cat <<'SK_EXAMPLE_END_OF_FILE'
{% comment %}
  ShakeKee example, the protected form: the same contact form, with room for the ShakeKee Widget app block.
  Copy this file to the sections/ folder of your theme, add the section "ShakeKee example (protected)" in the theme editor,
  then use "Add block" inside it and choose "ShakeKee Widget". The form cannot be sent before the visitor has shaken their phone.
{% endcomment %}
<section class="shakekee-example shakekee-example--protected">
  <h2>{{ section.settings.heading }}</h2>
  {% form 'contact' %}
    {{ form.errors | default_errors }}
    <p><input type="email" name="contact[email]" required placeholder="you@example.com"></p>
    <p><textarea name="contact[body]" required></textarea></p>
    {% for block in section.blocks %}
      {% render block %}
    {% endfor %}
    <p><button type="submit">Send</button></p>
  {% endform %}
</section>

{% schema %}
{
  "name": "ShakeKee example (protected)",
  "settings": [
    { "type": "text", "id": "heading", "label": "Heading", "default": "Contact us (protected)" }
  ],
  "blocks": [{ "type": "@app" }],
  "presets": [{ "name": "ShakeKee example (protected)" }]
}
{% endschema %}
SK_EXAMPLE_END_OF_FILE
      ;;
    'verify.sh')
cat <<'SK_EXAMPLE_END_OF_FILE'
#!/bin/sh
# Proves the app behind the example: it answers on its App Proxy route, only to Shopify's signed requests, and its ShakeKee
# credential works.
# Usage: sh verify.sh APP_URL [--app DIR] [--env FILE]
#   APP_URL    where the app runs (the application_url of shopify.app.toml)
#   --app DIR  the app's folder with shakekee.server.js and node_modules (default: ../app, next to this example)
#   --env FILE the app's environment file (default: ../.env; without it the settings must be in the environment)
# The credential check signs in to ShakeKee with the app's own code (Node.js 20 or newer, npm install done in the app folder).
set -eu
here=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
app=$here/../app
envfile=$here/../.env
base=
while [ $# -gt 0 ]; do
  case $1 in
    --app) app=${2:?--app needs a folder}; shift ;;
    --env) envfile=${2:?--env needs a file}; shift ;;
    -*) echo "usage: sh verify.sh APP_URL [--app DIR] [--env FILE]" >&2; exit 2 ;;
    *) base=${1%/} ;;
  esac
  shift
done
[ -n "$base" ] || { echo "usage: sh verify.sh APP_URL [--app DIR] [--env FILE]" >&2; exit 2; }
command -v curl >/dev/null 2>&1 || { echo "curl is not installed" >&2; exit 2; }
case ${LC_ALL:-${LC_CTYPE:-${LANG:-}}} in
  *UTF-8* | *utf-8* | *UTF8* | *utf8*) ok='✔' bad='✖' skip='–' ;;
  *) ok='ok  ' bad='FAIL' skip='skip' ;;
esac

failed=0
code=$(curl -s -o /dev/null -w '%{http_code}' --max-time 15 -X POST "$base/apps/shakekee/challenge" || true)
case $code in
  401)
    echo "$ok app proxy       $base/apps/shakekee/challenge refuses a request that Shopify did not sign (401)"
    ;;
  404)
    echo "$bad app proxy       $base/apps/shakekee/challenge answers 404: the ShakeKee routes are not part of the running app"
    failed=1
    ;;
  200 | 201)
    echo "$bad app proxy       $base/apps/shakekee/challenge answers $code to an unsigned request: the proxy route is open to everybody"
    failed=1
    ;;
  *)
    echo "$bad app proxy       $base/apps/shakekee/challenge answers ${code:-nothing}: is the app running at that address?"
    failed=1
    ;;
esac

server=$app/shakekee.server.js
if ! command -v node >/dev/null 2>&1; then
  echo "$skip credential      node is not installed, so the credential was not tried"
elif [ ! -f "$server" ] || [ ! -d "$app/node_modules/shakekee-nodejs" ]; then
  echo "$skip credential      no installed app in $app (run npm install there), so the credential was not tried"
else
  js='import { pathToFileURL } from "node:url";
const m = await import(pathToFileURL(process.argv[1]).href);
const s = await m.credentialStatus();
if (s.ok) { console.log("credential " + s.credentialId + " is signed in"); process.exit(0); }
console.log(s.configured ? (s.message || "the credential was not accepted") : "missing settings: " + s.missing.join(", "));
process.exit(1);'
  if [ -f "$envfile" ]; then
    result=$(node --env-file="$envfile" --input-type=module -e "$js" "$server" 2>&1) && rc=0 || rc=$?
  else
    result=$(node --input-type=module -e "$js" "$server" 2>&1) && rc=0 || rc=$?
  fi
  if [ "$rc" = 0 ]; then
    echo "$ok credential      $result"
  else
    echo "$bad credential      $result"
    failed=1
  fi
fi
exit "$failed"
SK_EXAMPLE_END_OF_FILE
      ;;
  esac
}
sk_example_list_woocommerce() {
  printf '%s\n' 'verify.sh|755'
}
sk_example_cat_woocommerce() {
  case $1 in
    'verify.sh')
cat <<'SK_EXAMPLE_END_OF_FILE'
#!/bin/sh
# Proves the ShakeKee add-on on this WooCommerce shop: the login form of the shop carries the ShakeKee widget (the
# place where a visitor is asked to shake the phone), so the add-on is active and has what it needs to draw it.
# Usage: sh verify.sh [LOGIN-URL | SITE-URL]
#   Default: the address in the file "urls" next to this script (written by the installer), else SITE-URL/my-account/.
# Exit status: 0 the widget is there, 1 it is not, 2 the check could not be run (no curl, no address, no answer).
set -eu
here=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
url=${1:-}
if [ -z "$url" ] && [ -f "$here/urls" ]; then
  url=$(sed -n 's/^login //p' "$here/urls" | head -n 1)
fi
if [ -z "$url" ]; then
  echo "✖ no address: give the login page or the site address (sh verify.sh https://shop.example.com/my-account/)" >&2
  exit 2
fi
case $url in
  */my-account/ | */my-account | *\?*) ;;
  *) url=${url%/}/my-account/ ;;
esac
command -v curl >/dev/null 2>&1 || { echo "✖ curl is not installed" >&2; exit 2; }

page=$(mktemp "${TMPDIR:-/tmp}/shakekee-verify.XXXXXX")
trap 'rm -f "$page"' EXIT INT TERM
code=$(curl -s -L --max-redirs 3 --max-time 20 -o "$page" -w '%{http_code}' "$url" 2>/dev/null) || code=000
if [ "$code" = 000 ]; then
  echo "✖ the shop did not answer: $url" >&2
  exit 2
fi
if [ "$code" != 200 ]; then
  echo "✖ login page      $url answers $code, expected 200"
  exit 1
fi
if grep -q 'id="shakekee-root"' "$page"; then
  echo "✔ login page      $url carries the ShakeKee widget"
  exit 0
fi
echo "✖ login page      $url has no ShakeKee widget: is the add-on active, is \"Enable on Login\" on (Settings > ShakeKee), and is the login form cached by a page cache?"
exit 1
SK_EXAMPLE_END_OF_FILE
      ;;
  esac
}
sk_example_list_wordpress() {
  printf '%s\n' 'page-open.html|644' 'page-protected.html|644' 'verify.sh|755'
}
sk_example_cat_wordpress() {
  case $1 in
    'page-open.html')
cat <<'SK_EXAMPLE_END_OF_FILE'
<p>ShakeKee example: this page is open. Anybody gets it, ShakeKee is not involved.</p>
SK_EXAMPLE_END_OF_FILE
      ;;
    'page-protected.html')
cat <<'SK_EXAMPLE_END_OF_FILE'
<p>ShakeKee example: you passed the check and see the protected page.</p>
SK_EXAMPLE_END_OF_FILE
      ;;
    'verify.sh')
cat <<'SK_EXAMPLE_END_OF_FILE'
#!/bin/sh
# Proves the ShakeKee example on this WordPress site: the open page is served to anybody, the protected page
# answers with the verification wall (status 403) until the visitor has passed the check.
# Usage: sh verify.sh [--open URL] [--protected URL] [SITE-URL]
#   The two addresses are in the file "urls" next to this script (written by the installer); SITE-URL alone
#   checks SITE-URL/shakekee-example-open/ and SITE-URL/shakekee-example/ (pretty permalinks).
# Exit status: 0 all checks passed, 1 a check failed, 2 the check could not be run (no curl, no address, or
# the site did not answer at all).
set -eu
here=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
open=
protected=
while [ $# -gt 0 ]; do
  case $1 in
    --open) open=${2:?--open needs an address}; shift ;;
    --protected) protected=${2:?--protected needs an address}; shift ;;
    -*) echo "usage: sh verify.sh [--open URL] [--protected URL] [SITE-URL]" >&2; exit 2 ;;
    *) open=${1%/}/shakekee-example-open/; protected=${1%/}/shakekee-example/ ;;
  esac
  shift
done
if [ -z "$open$protected" ] && [ -f "$here/urls" ]; then
  open=$(sed -n 's/^open //p' "$here/urls" | head -n 1)
  protected=$(sed -n 's/^protected //p' "$here/urls" | head -n 1)
fi
if [ -z "$open" ] || [ -z "$protected" ]; then
  echo "✖ no address: give the site address (sh verify.sh https://example.com) or --open and --protected" >&2
  exit 2
fi
command -v curl >/dev/null 2>&1 || { echo "✖ curl is not installed" >&2; exit 2; }

# probe URL: code is the status of a GET (000 when nothing answers), action the daemon's verdict header, if any
probe() {
  out=$(curl -s -L --max-redirs 3 --max-time 15 -o /dev/null -D - -w 'STATUS %{http_code}\n' "$1" 2>/dev/null) || out=
  code=$(printf '%s\n' "$out" | sed -n 's/^STATUS //p' | tail -n 1)
  action=$(printf '%s\n' "$out" | tr -d '\r' | sed -n 's/^[Xx]-[Ss][Kk]-[Aa]ction: *//p' | tail -n 1)
  code=${code:-000}
}

failed=0
probe "$open"
open_code=$code
open_action=$action
probe "$protected"
if [ "$open_code" = 000 ] && [ "$code" = 000 ]; then
  echo "✖ the site did not answer: $open" >&2
  exit 2
fi
if [ "$open_code" = 200 ]; then
  echo "✔ open page       $open answers 200"
elif [ "$open_code" = 403 ] && [ -n "$open_action" ]; then
  echo "✖ open page       $open answers 403: the daemon protects the whole site; leave the open page out of its protect list or ignore this line"
  failed=1
else
  echo "✖ open page       $open answers $open_code, expected 200"
  failed=1
fi
case $code in
  403 | 429)
    echo "✔ protected page  $protected answers $code: the visitor is asked for the check${action:+ (verdict: $action)}"
    ;;
  503)
    echo "✖ protected page  $protected answers 503: ShakeKee cannot be used (is the daemon running, is the secret right?)"
    failed=1
    ;;
  200)
    echo "✖ protected page  $protected answers 200 without a check: the page is not protected (daemon not running or secret wrong; in account mode: is the credential valid?)"
    failed=1
    ;;
  *)
    echo "✖ protected page  $protected answers $code, expected 403"
    failed=1
    ;;
esac
exit "$failed"
SK_EXAMPLE_END_OF_FILE
      ;;
  esac
}
sk_recipe_wordpress() {
# ---- wordpress: the plugin, installed and configured with WP-CLI as the owner of the site ----
# Phase contract (docs/installer-framework.md): detect, choose, check, plan, apply, verify, handover, and the hooks
# for --status, --update, --doctor and --uninstall. Shared parts: integrate.sh (WP-CLI, mode, private files) and
# account.sh. What it writes and how it is undone: docs/installer-cms.md.
WP_PATH=
WP_ALLOW_ROOT=0
WP_URL=
WP_HOST=
WP_MODE=
WP_EXAMPLE=ask

usage() {
  cat <<'USAGE'
Usage: curl -fsSL https://get.shakekee.com/wordpress | sh -s -- [options]
  --path DIR      the WordPress root (default: the current directory)
  --url URL       the site of a WordPress network to act on
  --host NAME     the licensed host name (default: the host of the site address)
  --daemon        use the ShakeKee daemon next to the web server (the host must be registered with it)
  --account       use an API key and credential of your ShakeKee account (no daemon)
  --example       create the example pages /shakekee-example-open/ and /shakekee-example/ without asking
  --no-example    do not create them
  --allow-root    pass --allow-root to WP-CLI (when the site belongs to root)
  --force         replace example files that differ from the example
  --dry-run       download and verify, change nothing
WP-CLI must be installed. It runs as the user that owns the site (through sudo or runuser when that is not you).
Without --daemon or --account the daemon is used when this host is registered with it, else the account.
Secrets are given in the environment when there is no terminal: SHAKEKEE_API_KEY_ID with SHAKEKEE_API_KEY, or
SHAKEKEE_CREDENTIAL_ID with SHAKEKEE_PRIVATE_KEY_B64. Safe to run again.
USAGE
  sk_usage_common
}

while [ $# -gt 0 ]; do
  case $1 in
    --path) sk_value "$1" $#; WP_PATH=$2; shift ;;
    --url) sk_value "$1" $#; WP_URL=$2; shift ;;
    --host) sk_value "$1" $#; WP_HOST=$2; shift ;;
    --daemon) WP_MODE=daemon ;;
    --account) WP_MODE=account ;;
    --example) WP_EXAMPLE=yes ;;
    --no-example) WP_EXAMPLE=no ;;
    --allow-root) WP_ALLOW_ROOT=1 ;;
    --force) SK_FORCE=1 ;;
    --dry-run) SK_DRY=1 ;;
    --help | -h) usage; exit 0 ;;
    *) die "unknown option $1 (see --help)" ;;
  esac
  shift
done

recipe_detect() {
  sk_wp_prepare sk_fetch_wordpress
}

recipe_choose() {
  sk_int_choose wordpress "$SK_WP_HOST" both "$WP_MODE"
  SK_WP_EXAMPLE=$WP_EXAMPLE
  if [ "$SK_WP_EXAMPLE" = ask ]; then
    ui_menu_single "Create two example pages to try it on (/shakekee-example-open/ and /shakekee-example/)?" "+yes|Yes, create them|--uninstall removes them again" "no|No|only the plugin is set up"
    SK_WP_EXAMPLE=$UI_REPLY
  fi
}

recipe_check() {
  sk_fetch_wordpress
  SK_WP_PKG=$SK_FILE
}

recipe_plan() {
  sk_plan_add "Install and activate the plugin shakekee-protection in $SK_WP_ROOT (WP-CLI$SK_INT_ASTXT)"
  if [ "$SK_INT_MODE" = daemon ]; then
    sk_plan_add "Daemon mode: switch on the daemon module and give the plugin the daemon address, the host name $SK_WP_HOST and the shared secret (stored as plugin settings)"
  else
    sk_plan_add "Account mode: get an API key and a credential for $SK_WP_HOST and store them as plugin settings"
  fi
  if [ "$SK_WP_EXAMPLE" = yes ]; then
    sk_plan_add "Create two example pages with WP-CLI and write the example to $SK_HOME/examples/wordpress"
  fi
  sk_plan_add "Remember what was done in $SK_HOME/install.json"
}

# the settings of the mode; returns non-zero at the first one WP-CLI refuses
wp_write_daemon() {
  sk_wp_set shakekee_daemon_url "$SK_INT_URL" || return 1
  sk_wp_set shakekee_daemon_host "$SK_WP_HOST" || return 1
  sk_wp_put shakekee_daemon_secret "$SK_INT_SECRET" || return 1
  sk_wp_set shakekee_daemon_enabled 1
}

# wp_page SLUG TITLE FILE: creates or updates the page; SK_WP_PAGE is its id
wp_page() {
  wpr_old=$(sk_wp post list --post_type=page --post_status=any --name="$1" --field=ID 2>/dev/null </dev/null | head -n 1) || wpr_old=
  case $wpr_old in
    '' | *[!0-9]*)
      SK_WP_PAGE=$(sk_wp post create - --post_type=page --post_status=publish "--post_title=$2" "--post_name=$1" --porcelain <"$3" 2>>"$SK_LOG") || return 1
      ;;
    *)
      sk_wp post update "$wpr_old" - "--post_title=$2" --post_status=publish <"$3" >>"$SK_LOG" 2>&1 || return 1
      SK_WP_PAGE=$wpr_old
      ;;
  esac
  case $SK_WP_PAGE in '' | *[!0-9]*) return 1 ;; esac
}

wp_example() {
  SK_WP_EXDIR=$SK_HOME/examples/wordpress/$(printf '%s' "$SK_WP_HOST$SK_WP_URL" | tr -c 'A-Za-z0-9.-' '_')
  sk_int_example_place wordpress "$SK_WP_EXDIR"
  if [ "$SK_DRY" = 1 ]; then
    ui_say "[dry-run] create the pages shakekee-example-open and shakekee-example"
    return 0
  fi
  sk_log_open
  ui_step_start pages "Creating the example pages"
  if ! wp_page shakekee-example-open "ShakeKee example (open)" "$SK_WP_EXDIR/page-open.html"; then
    ui_step_fail "WP-CLI could not create the page"
    sk_fail 1 SK-E215 "The example pages could not be created." "Run again with --no-example, or check that the user of WP-CLI may create pages."
  fi
  wpr_open=$SK_WP_PAGE
  if ! wp_page shakekee-example "ShakeKee example (protected)" "$SK_WP_EXDIR/page-protected.html"; then
    ui_step_fail "WP-CLI could not create the page"
    sk_fail 1 SK-E215 "The example pages could not be created." "Run again with --no-example, or check that the user of WP-CLI may create pages."
  fi
  wpr_prot=$SK_WP_PAGE
  if [ "$SK_INT_MODE" = account ]; then
    sk_wp post meta update "$wpr_prot" _shakekee_required 1 >>"$SK_LOG" 2>&1 </dev/null || true
  fi
  SK_WP_EXIDS=$wpr_open,$wpr_prot
  ui_step_ok "ids $wpr_open and $wpr_prot"
  # the address of a page: the pretty one when the site has permalinks, else the plain one
  sk_wp_get permalink_structure
  if [ -n "$SK_WP_V" ]; then
    SK_WP_OPENURL=${SK_WP_SITEURL%/}/shakekee-example-open/
    SK_WP_PROTURL=${SK_WP_SITEURL%/}/shakekee-example/
  else
    SK_WP_OPENURL=${SK_WP_SITEURL%/}/?page_id=$wpr_open
    SK_WP_PROTURL=${SK_WP_SITEURL%/}/?page_id=$wpr_prot
  fi
  sk_int_example_note "$SK_WP_EXDIR/urls" "open $SK_WP_OPENURL${UI_NL}protected $SK_WP_PROTURL"
}

recipe_apply() {
  sk_step_run plugin "Installing and activating the plugin shakekee-protection" sk_wp plugin install "$SK_WP_PKG" --force --activate ||
    sk_fail 1 SK-E205 "WP-CLI could not install or activate the plugin." "The output is in $SK_LOG. Check that the user of WP-CLI may write to wp-content/plugins and that the site loads."
  SK_WP_EXIDS=
  if [ "$SK_INT_MODE" = daemon ]; then
    sk_int_daemon_secret
    sk_int_daemon_url
    sk_step_run daemonmod "Activating the daemon module" sk_wp plugin activate shakekee-protection/shakekee-daemon ||
      sk_fail 1 SK-E205 "WP-CLI could not activate the daemon module of the plugin." "The output is in $SK_LOG. The module is the file shakekee-daemon.php in the plugin's folder."
    wpr_write=wp_write_daemon
    wpr_what="daemon address, host name $SK_WP_HOST and the shared secret"
  else
    sk_account_credentials "$SK_WP_HOST"
    wpr_write=sk_wp_write_account
    wpr_what="API key id, credential and private key for $SK_WP_HOST"
  fi
  if [ "$SK_DRY" = 1 ]; then
    ui_say "[dry-run] write the plugin's settings: $wpr_what"
  else
    sk_log_open
    ui_step_start settings "Writing the plugin's settings"
    if $wpr_write; then
      ui_step_ok "$wpr_what"
    else
      ui_step_fail "WP-CLI could not write an option; the output is in $SK_LOG"
      sk_fail 1 SK-E209 "The plugin's settings could not be written." "The output is in $SK_LOG. Check that WP-CLI can write to the database of the site."
    fi
  fi
  SK_WP_EXDIR=
  SK_WP_OPENURL=
  SK_WP_PROTURL=
  if [ "$SK_WP_EXAMPLE" = yes ]; then
    wp_example
  fi
  SK_RECIPE_VERSION=$(sk_wp plugin get shakekee-protection --field=version 2>/dev/null </dev/null) || SK_RECIPE_VERSION=installed
  SK_RECIPE_VERSION=${SK_RECIPE_VERSION:-installed}
  sk_int_site_record wordpress "${SK_WP_EXIDS:--}" "${SK_WP_EXDIR:--}"
}

recipe_verify() {
  wpr_bad=0
  ui_step_start active "The plugin is active"
  if sk_wp plugin is-active shakekee-protection >/dev/null 2>&1; then
    ui_step_ok
  else
    ui_step_fail "WP-CLI says shakekee-protection is not active"
    wpr_bad=1
  fi
  if [ "$SK_INT_MODE" = daemon ]; then
    ui_step_start daemon "The plugin can use the daemon"
    sk_int_daemon_probe
    case $SK_INT_PROBE in
      ok) ui_step_ok "$SK_INT_URL accepts the shared secret" ;;
      secret) ui_step_fail "the daemon answers 401: it does not accept the shared secret${UI_NL}Fix: run this installer again; it writes the daemon's secret into the plugin."; wpr_bad=1 ;;
      down) ui_step_warn "nothing answers at $SK_INT_URL (is the daemon running?)" ;;
      *) ui_step_warn "the daemon's answer was not understood" ;;
    esac
  else
    ui_step_start creds "The plugin has its credentials"
    sk_wp_get sk_api_key_id
    wpr_k=$SK_WP_V
    sk_wp_get sk_credential_id
    wpr_c=$SK_WP_V
    if [ -n "$wpr_k" ] && { [ -n "$wpr_c" ] || [ -n "$SK_API_KEY" ]; }; then
      ui_step_ok "key id $wpr_k${wpr_c:+, credential $wpr_c}"
    else
      ui_step_fail "the plugin's settings lack the API key id and the credential"
      wpr_bad=1
    fi
  fi
  if [ "$SK_WP_EXAMPLE" = yes ] && [ -f "$SK_WP_EXDIR/verify.sh" ]; then
    ui_step_start example "The example pages answer as they should"
    wpr_v=$(sh "$SK_WP_EXDIR/verify.sh" 2>&1 </dev/null) && wpr_vc=0 || wpr_vc=$?
    case $wpr_vc in
      0) ui_step_ok ;;
      2) ui_step_warn "the site could not be reached from here:${UI_NL}$wpr_v" ;;
      *) ui_step_fail "$wpr_v${UI_NL}Fix: see $SK_ORIGIN/help/SK-E214"; wpr_bad=1 ;;
    esac
  fi
  if [ "$wpr_bad" = 1 ]; then
    sk_fail 1 SK-E214 "The plugin is installed but a live check failed." "Read the failed line above, fix it and run the installer again. $SK_HOME/examples/wordpress has verify.sh for later."
  fi
}

recipe_handover() {
  ui_title "WordPress is set up for $SK_WP_HOST"
  ui_say "  Mode:      $SK_INT_MODE"
  ui_say "  Settings:  Settings > ShakeKee Protection in the WordPress administration"
  if [ "$SK_WP_EXAMPLE" = yes ] && [ "$SK_DRY" != 1 ]; then
    ui_say "  Try it:    ${SK_WP_PROTURL:-}"
    ui_say "  Check it:  sh $SK_WP_EXDIR/verify.sh"
  fi
  if [ "$SK_INT_MODE" = daemon ]; then
    ui_note "The web server must pass /.shakekee/ and /.well-known/shakekee/ to the daemon (its public listener); the wall needs them. Run this installer's nginx, apache or caddy step, or see edge/shakekeed/integrations."
  else
    ui_note "Protect a page: edit it and tick the ShakeKee box, or use the [shakekee_protected] shortcode. The raw API key stays in $SK_HOME/credentials/ and in the plugin's settings."
  fi
}

# ---- the sites this installer set up, for the hooks (sk_wp_each runs these for each recorded site) ----

wp_status_one() {
  SK_WP_EXIDS=$SK_WP_F5
  ui_step_start "site$SK_WP_N" "WordPress at $SK_WP_ROOT ($SK_WP_HOST, $SK_INT_MODE mode)"
  if ! sk_wp core is-installed >/dev/null 2>&1; then
    ui_step_fail "WP-CLI cannot read the site${SK_INT_OWNER:+ (run as $SK_INT_OWNER)}"
    return 1
  fi
  if ! sk_wp plugin is-active shakekee-protection >/dev/null 2>&1; then
    ui_step_fail "the plugin shakekee-protection is not active${UI_NL}Fix: activate it in the WordPress administration, or run the installer again."
    return 1
  fi
  if [ "$SK_INT_MODE" = daemon ]; then
    sk_int_daemon_find
    sk_int_daemon_url
    SK_INT_SECRET=$(sk_int_daemon print-config --preflight-secret 2>/dev/null </dev/null) || SK_INT_SECRET=
    sk_int_daemon_probe
    case $SK_INT_PROBE in
      ok) ui_step_ok "plugin active; the daemon accepts the shared secret" ;;
      secret)
        ui_step_fail "the daemon does not accept the shared secret${UI_NL}Fix: run the installer again; it writes the daemon's secret into the plugin."
        return 1
        ;;
      *) ui_step_warn "plugin active; the daemon at $SK_INT_URL did not answer" ;;
    esac
  else
    sk_wp_get sk_api_key_id
    if [ -z "$SK_WP_V" ]; then
      ui_step_fail "the plugin has no API key id${UI_NL}Fix: run the installer again."
      return 1
    fi
    ui_step_ok "plugin active; key id $SK_WP_V"
  fi
  return 0
}

recipe_status() {
  sk_int_each wordpress wp_status_one wp "$WP_PATH"
}

wp_doctor_one() {
  wp_status_one || return 1
  if [ "$SK_INT_MODE" = account ]; then
    ui_step_start "reason$SK_WP_N" "The plugin reports no sign-in problem"
    wpr_t=$(sk_wp transient get sk_last_auth_error 2>/dev/null </dev/null) || wpr_t=
    if [ -z "$wpr_t" ]; then
      ui_step_ok
    else
      ui_step_warn "the last reason it could not work: $wpr_t${UI_NL}Fix: see Settings > ShakeKee Protection; run the installer again if the credential was revoked."
    fi
  fi
  return 0
}

recipe_doctor() {
  sk_int_each wordpress wp_doctor_one wp "$WP_PATH"
}

wp_update_one() {
  sk_fetch_wordpress
  sk_step_run plugin "Updating the plugin at $SK_WP_ROOT" sk_wp plugin install "$SK_FILE" --force --activate || return 1
  SK_RECIPE_VERSION=$(sk_wp plugin get shakekee-protection --field=version 2>/dev/null </dev/null) || SK_RECIPE_VERSION=installed
  sk_integration_add wordpress "${SK_RECIPE_VERSION:-installed}"
  return 0
}

recipe_update() {
  sk_int_each wordpress wp_update_one wp "$WP_PATH"
}

# the options the installer wrote: the plugin's uninstall.php removes them too, but a network keeps the plugin
wp_uninstall_one() {
  SK_WP_EXIDS=$SK_WP_F5
  ui_step_start "site$SK_WP_N" "Removing ShakeKee from $SK_WP_ROOT ($SK_WP_HOST)"
  if ! sk_wp core is-installed >/dev/null 2>&1; then
    ui_step_fail "WP-CLI cannot read the site${SK_INT_OWNER:+ (run as $SK_INT_OWNER)}"
    return 1
  fi
  ui_step_ok
  if [ "$SK_DRY" = 1 ]; then
    ui_say "[dry-run] delete the example pages, deactivate and remove the plugin, remove its settings"
    return 0
  fi
  sk_log_open
  case $SK_WP_EXIDS in
    '' | -) ;;
    *)
      for wpr_id in $(printf '%s' "$SK_WP_EXIDS" | tr ',' ' '); do
        case $wpr_id in '' | *[!0-9]*) continue ;; esac
        sk_step_run "page$wpr_id" "Deleting the example page $wpr_id" sk_wp post delete "$wpr_id" --force || true
      done
      ;;
  esac
  if [ -n "$SK_WP_URL" ]; then
    sk_step_run deactivate "Deactivating the plugin for $SK_WP_URL" sk_wp plugin deactivate shakekee-protection || true
    for wpr_o in shakekee_api_key sk_api_key_id sk_credential_id sk_machine_privkey_b64 sk_api_base sk_cdn_url shakekee_daemon_enabled shakekee_daemon_url shakekee_daemon_host shakekee_daemon_secret; do
      sk_wp option delete "$wpr_o" >>"$SK_LOG" 2>&1 </dev/null || true
    done
  else
    sk_step_run remove "Deactivating and deleting the plugin" sk_wp plugin uninstall shakekee-protection --deactivate || return 1
  fi
  if [ "$SK_INT_MODE" = account ]; then
    sk_wp_revoke_ask
  fi
  sk_unrecord "$SK_WP_RECKEY"
  return 0
}

recipe_uninstall() {
  sk_int_each wordpress wp_uninstall_one wp "$WP_PATH"
}

sk_phases
}
sk_recipe_woocommerce() {
# ---- woocommerce: the WordPress plugin first (if missing), then the WooCommerce add-on, configured with WP-CLI ----
# The add-on has no credentials of its own: it signs in with the ones of the WordPress plugin (shakekee-protection),
# so this recipe works with an API key and credential of the account (no daemon mode). Shared parts: integrate.sh and
# account.sh; docs/installer-cms.md says what is written and how it is undone.
WP_PATH=
WP_ALLOW_ROOT=0
WP_URL=
WP_HOST=
WP_MODE=
WP_EXAMPLE=ask

usage() {
  cat <<'USAGE'
Usage: curl -fsSL https://get.shakekee.com/woocommerce | sh -s -- [options]
  --path DIR      the WordPress root (default: the current directory)
  --url URL       the site of a WordPress network to act on
  --host NAME     the licensed host name (default: the host of the site address)
  --account       use an API key and credential of your ShakeKee account (the only mode of this add-on)
  --no-example    do not write the example (verify.sh)
  --allow-root    pass --allow-root to WP-CLI (when the site belongs to root)
  --force         replace example files that differ from the example
  --dry-run       download and verify, change nothing
WooCommerce must be active, WP-CLI installed. Installs the ShakeKee Protection plugin too when it is not there, and
uses the credentials it already has. WP-CLI runs as the user that owns the site. Safe to run again.
USAGE
  sk_usage_common
}

while [ $# -gt 0 ]; do
  case $1 in
    --path) sk_value "$1" $#; WP_PATH=$2; shift ;;
    --url) sk_value "$1" $#; WP_URL=$2; shift ;;
    --host) sk_value "$1" $#; WP_HOST=$2; shift ;;
    --daemon) WP_MODE=daemon ;;
    --account) WP_MODE=account ;;
    --example) WP_EXAMPLE=yes ;;
    --no-example) WP_EXAMPLE=no ;;
    --allow-root) WP_ALLOW_ROOT=1 ;;
    --force) SK_FORCE=1 ;;
    --dry-run) SK_DRY=1 ;;
    --help | -h) usage; exit 0 ;;
    *) die "unknown option $1 (see --help)" ;;
  esac
  shift
done

recipe_detect() {
  sk_wp_prepare sk_fetch_wordpress sk_fetch_woocommerce
  ui_step_start woo "WooCommerce is active"
  if sk_wp plugin is-active woocommerce >/dev/null 2>&1; then
    ui_step_ok
  else
    ui_step_fail "WP-CLI says woocommerce is not active"
    sk_fail 1 SK-E206 "WooCommerce is not active on $SK_WP_ROOT." "Install and activate WooCommerce first, then run this again."
  fi
}

recipe_choose() {
  sk_int_choose woocommerce "$SK_WP_HOST" account "$WP_MODE"
  # the add-on uses the credentials of the WordPress plugin: take them when they are there
  SK_WC_HAVE_BASE=0
  SK_WC_HAVE_CREDS=0
  if sk_wp plugin is-installed shakekee-protection >/dev/null 2>&1; then
    SK_WC_HAVE_BASE=1
    sk_wp_get sk_api_key_id
    wcr_k=$SK_WP_V
    sk_wp_get sk_credential_id
    wcr_c=$SK_WP_V
    if [ -n "$wcr_k" ] && [ -n "$wcr_c" ]; then
      SK_WC_HAVE_CREDS=1
    fi
  fi
  SK_WP_EXAMPLE=$WP_EXAMPLE
  if [ "$SK_WP_EXAMPLE" = ask ]; then
    SK_WP_EXAMPLE=yes
  fi
}

recipe_check() {
  sk_fetch_wordpress
  SK_WC_BASE=$SK_FILE
  sk_fetch_woocommerce
  SK_WC_ADDON=$SK_FILE
}

recipe_plan() {
  if [ "$SK_WC_HAVE_BASE" = 1 ]; then
    sk_plan_add "Keep the plugin shakekee-protection that is already there; make sure it is active"
  else
    sk_plan_add "Install and activate the plugin shakekee-protection in $SK_WP_ROOT (WP-CLI$SK_INT_ASTXT)"
  fi
  sk_plan_add "Install and activate the plugin shakekee-woocommerce"
  if [ "$SK_WC_HAVE_CREDS" = 1 ]; then
    sk_plan_add "Use the API key and credential the WordPress plugin already has"
  else
    sk_plan_add "Get an API key and a credential for $SK_WP_HOST and store them as settings of the WordPress plugin"
  fi
  if [ "$SK_WP_EXAMPLE" = yes ]; then
    sk_plan_add "Write verify.sh to $SK_HOME/examples/woocommerce"
  fi
  sk_plan_add "Remember what was done in $SK_HOME/install.json"
}

recipe_apply() {
  # a run before this one may have added the WordPress plugin: it stays ours
  SK_WC_OURS=0
  sk_int_site_key "$SK_WP_ROOT|$SK_WP_URL"
  if sk_rec_get "woocommerce.site.$SK_SITEKEY"; then
    sk_int_field 5 "$SK_RV"
    if [ "$SK_FIELD" = 1 ]; then
      SK_WC_OURS=1
    fi
  fi
  if [ "$SK_WC_HAVE_BASE" = 1 ]; then
    say "ShakeKee Protection is already installed; leaving it as it is."
    sk_wp plugin is-active shakekee-protection >/dev/null 2>&1 ||
      sk_step_run base "Activating the plugin shakekee-protection" sk_wp plugin activate shakekee-protection ||
      sk_fail 1 SK-E205 "WP-CLI could not activate the plugin shakekee-protection." "The output is in $SK_LOG."
  else
    SK_WC_OURS=1
    sk_step_run base "Installing and activating the plugin shakekee-protection" sk_wp plugin install "$SK_WC_BASE" --activate ||
      sk_fail 1 SK-E205 "WP-CLI could not install the plugin shakekee-protection." "The output is in $SK_LOG. Check that the user of WP-CLI may write to wp-content/plugins."
  fi
  sk_step_run addon "Installing and activating the plugin shakekee-woocommerce" sk_wp plugin install "$SK_WC_ADDON" --force --activate ||
    sk_fail 1 SK-E205 "WP-CLI could not install the plugin shakekee-woocommerce." "The output is in $SK_LOG."
  if [ "$SK_WC_HAVE_CREDS" = 1 ]; then
    say "Using the API key and credential that ShakeKee Protection already has."
  else
    sk_account_credentials "$SK_WP_HOST"
    if [ "$SK_DRY" = 1 ]; then
      ui_say "[dry-run] write the settings of the WordPress plugin: API key id, credential and private key for $SK_WP_HOST"
    else
      sk_log_open
      ui_step_start settings "Writing the settings of the WordPress plugin"
      if sk_wp_write_account; then
        ui_step_ok "API key id, credential and private key for $SK_WP_HOST"
      else
        ui_step_fail "WP-CLI could not write an option; the output is in $SK_LOG"
        sk_fail 1 SK-E209 "The plugin's settings could not be written." "The output is in $SK_LOG. Check that WP-CLI can write to the database of the site."
      fi
    fi
  fi
  SK_WP_EXDIR=
  if [ "$SK_WP_EXAMPLE" = yes ]; then
    SK_WP_EXDIR=$SK_HOME/examples/woocommerce/$(printf '%s' "$SK_WP_HOST$SK_WP_URL" | tr -c 'A-Za-z0-9.-' '_')
    sk_int_example_place woocommerce "$SK_WP_EXDIR"
    sk_int_example_note "$SK_WP_EXDIR/urls" "login ${SK_WP_SITEURL%/}/my-account/"
  fi
  SK_RECIPE_VERSION=$(sk_wp plugin get shakekee-woocommerce --field=version 2>/dev/null </dev/null) || SK_RECIPE_VERSION=installed
  SK_RECIPE_VERSION=${SK_RECIPE_VERSION:-installed}
  sk_int_site_record woocommerce "$SK_WC_OURS" "${SK_WP_EXDIR:--}"
}

recipe_verify() {
  wcr_bad=0
  ui_step_start active "Both plugins are active"
  if sk_wp plugin is-active shakekee-woocommerce >/dev/null 2>&1 && sk_wp plugin is-active shakekee-protection >/dev/null 2>&1; then
    ui_step_ok
  else
    ui_step_fail "WP-CLI says shakekee-protection or shakekee-woocommerce is not active"
    wcr_bad=1
  fi
  ui_step_start creds "The WordPress plugin has the credentials the add-on needs"
  sk_wp_get sk_api_key_id
  wcr_k=$SK_WP_V
  sk_wp_get sk_credential_id
  wcr_c=$SK_WP_V
  if [ -n "$wcr_k" ] && { [ -n "$wcr_c" ] || [ -n "${SK_API_KEY:-}" ]; }; then
    ui_step_ok "key id $wcr_k${wcr_c:+, credential $wcr_c}"
  else
    ui_step_fail "the settings of ShakeKee Protection lack the API key id and the credential"
    wcr_bad=1
  fi
  if [ "$SK_WP_EXAMPLE" = yes ] && [ -f "$SK_WP_EXDIR/verify.sh" ]; then
    ui_step_start example "The login form carries the ShakeKee widget"
    wcr_v=$(sh "$SK_WP_EXDIR/verify.sh" 2>&1 </dev/null) && wcr_vc=0 || wcr_vc=$?
    case $wcr_vc in
      0) ui_step_ok ;;
      2) ui_step_warn "the site could not be reached from here:${UI_NL}$wcr_v" ;;
      *) ui_step_fail "$wcr_v${UI_NL}Fix: see $SK_ORIGIN/help/SK-E214"; wcr_bad=1 ;;
    esac
  fi
  if [ "$wcr_bad" = 1 ]; then
    sk_fail 1 SK-E214 "The plugins are installed but a live check failed." "Read the failed line above, fix it and run the installer again."
  fi
}

recipe_handover() {
  ui_title "WooCommerce is set up for $SK_WP_HOST"
  ui_say "  The widget is on the checkout, login and registration forms (Settings > ShakeKee switches each one)."
  if [ "$SK_WP_EXAMPLE" = yes ] && [ "$SK_DRY" != 1 ]; then
    ui_say "  Check it:  sh $SK_WP_EXDIR/verify.sh"
  fi
  ui_note "Only the classic checkout ([woocommerce_checkout]) is covered; the block checkout is not. The API key stays in $SK_HOME/credentials/ and in the settings of ShakeKee Protection."
}

# ---- the hooks ----

wc_status_one() {
  ui_step_start "site$SK_WP_N" "WooCommerce at $SK_WP_ROOT ($SK_WP_HOST)"
  if ! sk_wp core is-installed >/dev/null 2>&1; then
    ui_step_fail "WP-CLI cannot read the site${SK_INT_OWNER:+ (run as $SK_INT_OWNER)}"
    return 1
  fi
  if ! sk_wp plugin is-active shakekee-woocommerce >/dev/null 2>&1; then
    ui_step_fail "the plugin shakekee-woocommerce is not active${UI_NL}Fix: activate it in the WordPress administration, or run the installer again."
    return 1
  fi
  if ! sk_wp plugin is-active shakekee-protection >/dev/null 2>&1; then
    ui_step_fail "the plugin shakekee-protection, which holds the credentials, is not active${UI_NL}Fix: activate it, or run the installer again."
    return 1
  fi
  sk_wp_get sk_api_key_id
  if [ -z "$SK_WP_V" ]; then
    ui_step_fail "ShakeKee Protection has no API key id${UI_NL}Fix: run the installer again."
    return 1
  fi
  ui_step_ok "both plugins active; key id $SK_WP_V"
  return 0
}

recipe_status() {
  sk_int_each woocommerce wc_status_one wp "$WP_PATH"
}

wc_doctor_one() {
  wc_status_one || return 1
  ui_step_start "reason$SK_WP_N" "The plugin reports no sign-in problem"
  wcr_t=$(sk_wp transient get sk_last_auth_error 2>/dev/null </dev/null) || wcr_t=
  if [ -z "$wcr_t" ]; then
    ui_step_ok
  else
    ui_step_warn "the last reason it could not work: $wcr_t${UI_NL}Fix: see Settings > ShakeKee; run the installer again if the credential was revoked."
  fi
  return 0
}

recipe_doctor() {
  sk_int_each woocommerce wc_doctor_one wp "$WP_PATH"
}

wc_update_one() {
  sk_fetch_woocommerce
  sk_step_run addon "Updating the plugin at $SK_WP_ROOT" sk_wp plugin install "$SK_FILE" --force --activate || return 1
  SK_RECIPE_VERSION=$(sk_wp plugin get shakekee-woocommerce --field=version 2>/dev/null </dev/null) || SK_RECIPE_VERSION=installed
  sk_integration_add woocommerce "${SK_RECIPE_VERSION:-installed}"
  return 0
}

recipe_update() {
  sk_int_each woocommerce wc_update_one wp "$WP_PATH"
}

wc_uninstall_one() {
  ui_step_start "site$SK_WP_N" "Removing ShakeKee for WooCommerce from $SK_WP_ROOT ($SK_WP_HOST)"
  if ! sk_wp core is-installed >/dev/null 2>&1; then
    ui_step_fail "WP-CLI cannot read the site${SK_INT_OWNER:+ (run as $SK_INT_OWNER)}"
    return 1
  fi
  ui_step_ok
  if [ "$SK_DRY" = 1 ]; then
    ui_say "[dry-run] deactivate and delete the plugin shakekee-woocommerce; remove shakekee-protection too if this installer added it"
    return 0
  fi
  sk_log_open
  if [ -n "$SK_WP_URL" ]; then
    sk_step_run addon "Deactivating the add-on for $SK_WP_URL" sk_wp plugin deactivate shakekee-woocommerce || true
  else
    sk_step_run addon "Deactivating and deleting the plugin shakekee-woocommerce" sk_wp plugin uninstall shakekee-woocommerce --deactivate || return 1
  fi
  # the WordPress plugin is removed only when this installer added it and the WordPress installer does not hold it
  if [ "$SK_WP_F5" = 1 ] && ! sk_rec_get integration.wordpress; then
    if [ -n "$SK_WP_URL" ]; then
      sk_step_run base "Deactivating the plugin shakekee-protection for $SK_WP_URL" sk_wp plugin deactivate shakekee-protection || true
    else
      sk_step_run base "Deactivating and deleting the plugin shakekee-protection (this installer added it)" sk_wp plugin uninstall shakekee-protection --deactivate || return 1
    fi
    sk_wp_revoke_ask
  else
    ui_note "The plugin shakekee-protection and its credentials stay: they were there before, or the WordPress installer holds them."
  fi
  sk_unrecord "$SK_WP_RECKEY"
  return 0
}

recipe_uninstall() {
  sk_int_each woocommerce wc_uninstall_one wp "$WP_PATH"
}

sk_phases
}
sk_recipe_joomla() {
# ---- joomla: installs the system plugin with the Joomla CLI, enables it and gives it the account's credential ----
# The plugin works with an API key and credential of the account (no daemon mode). The settings are plugin parameters;
# the installer writes them through Joomla's own database layer, with the secrets on the standard input. Outside a
# Joomla root it saves the verified package for the administrator's installer. Shared parts: integrate.sh and
# account.sh; docs/installer-cms.md says what is written and how it is undone.
JM_PATH=
JM_ENABLE=1
JM_HOST=
JM_MODE=
JM_EXAMPLE=yes

usage() {
  cat <<'USAGE'
Usage: curl -fsSL https://get.shakekee.com/joomla | sh -s -- [options]
  --path DIR      the Joomla root (default: the current directory)
  --host NAME     the licensed host name of the site (or SHAKEKEE_HOST; Joomla does not know its own address)
  --account       use an API key and credential of your ShakeKee account (the only mode of this plugin)
  --no-enable     install the plugin, leave it disabled
  --no-example    do not write the example (verify.sh)
  --force         replace example files that differ from the example
  --dry-run       download and verify, change nothing
Run it as the user that owns the site, or as another user: the Joomla CLI then runs as the owner (sudo or runuser).
Joomla 4.3+ or 5, PHP with sodium and curl. Outside a Joomla root it saves the verified package as
plg_system_shakekee.zip for the administrator's installer. Safe to run again.
USAGE
  sk_usage_common
}

while [ $# -gt 0 ]; do
  case $1 in
    --path) sk_value "$1" $#; JM_PATH=$2; shift ;;
    --host) sk_value "$1" $#; JM_HOST=$2; shift ;;
    --daemon) JM_MODE=daemon ;;
    --account) JM_MODE=account ;;
    --no-enable) JM_ENABLE=0 ;;
    --example) JM_EXAMPLE=yes ;;
    --no-example) JM_EXAMPLE=no ;;
    --force) SK_FORCE=1 ;;
    --dry-run) SK_DRY=1 ;;
    --help | -h) usage; exit 0 ;;
    *) die "unknown option $1 (see --help)" ;;
  esac
  shift
done

# jm_script: writes the helper that changes the plugin through Joomla's database layer (what the administrator's
# "enable" button and the plugin's form do), after the same bootstrap as cli/joomla.php. Anybody may read it: it
# holds no secret. Secrets reach it on the standard input.
jm_script() {
  sk_int_task
  SK_JM_SCRIPT=$SK_TASK/shakekee-joomla.php
  cat >"$SK_JM_SCRIPT" <<'PHP'
<?php
// php shakekee-joomla.php ROOT enable|id|state|params
//   enable           switch the plugin System - ShakeKee on
//   id               prints its extension id
//   state            prints what is set (no secret): installed, enabled, key_id, credential_id, has_private_key, has_api_key
//   params           reads lines name=value from the standard input into the plugin's parameters
const _JEXEC = 1;
$root = $argv[1] ?? '';
$action = $argv[2] ?? '';
if ($root === '' || !in_array($action, ['enable', 'id', 'state', 'params'], true)) {
    fwrite(STDERR, "usage: php shakekee-joomla.php ROOT enable|id|state|params\n");
    exit(2);
}
define('JPATH_BASE', $root);
require_once JPATH_BASE . '/includes/defines.php';
require_once JPATH_BASE . '/includes/framework.php';
$container = \Joomla\CMS\Factory::getContainer();
$container->alias('session', 'session.cli')->alias('JSession', 'session.cli')
    ->alias(\Joomla\CMS\Session\Session::class, 'session.cli')->alias(\Joomla\Session\Session::class, 'session.cli')
    ->alias(\Joomla\Session\SessionInterface::class, 'session.cli');
$db = $container->get('DatabaseDriver');
$ours = static function ($query) use ($db) {
    return $query->where($db->quoteName('type') . ' = ' . $db->quote('plugin'))
        ->where($db->quoteName('folder') . ' = ' . $db->quote('system'))
        ->where($db->quoteName('element') . ' = ' . $db->quote('shakekee'));
};
$select = $db->getQuery(true)->select($db->quoteName(['extension_id', 'enabled', 'params']))->from($db->quoteName('#__extensions'));
$row = $db->setQuery($ours($select))->loadObject();
if (!$row) {
    if ($action === 'state') {
        echo "installed=0\n";
        exit(0);
    }
    fwrite(STDERR, "The plugin System - ShakeKee is not installed.\n");
    exit(1);
}
$update = static function (array $set) use ($db, $ours) {
    $query = $db->getQuery(true)->update($db->quoteName('#__extensions'));
    foreach ($set as $column => $value) {
        $query->set($db->quoteName($column) . ' = ' . $db->quote($value));
    }
    $db->setQuery($ours($query))->execute();
};
$params = json_decode((string) $row->params, true);
if (!is_array($params)) {
    $params = [];
}
$names = ['api_key', 'api_key_id', 'credential_id', 'private_key_b64', 'auto_credential_id', 'api_base', 'cdn_url'];
switch ($action) {
    case 'enable':
        $update(['enabled' => '1']);
        break;
    case 'id':
        echo (int) $row->extension_id, "\n";
        break;
    case 'state':
        echo "installed=1\nenabled=", (int) $row->enabled, "\n";
        echo 'key_id=', $params['api_key_id'] ?? '', "\ncredential_id=", $params['credential_id'] ?? '', "\n";
        echo 'has_private_key=', ($params['private_key_b64'] ?? '') !== '' ? 1 : 0, "\n";
        echo 'has_api_key=', ($params['api_key'] ?? '') !== '' ? 1 : 0, "\n";
        break;
    case 'params':
        foreach (preg_split('/\r?\n/', (string) stream_get_contents(STDIN)) as $line) {
            $pos = strpos($line, '=');
            if ($pos === false) {
                continue;
            }
            $name = substr($line, 0, $pos);
            $value = substr($line, $pos + 1);
            if (in_array($name, $names, true) && $value !== '') {
                $params[$name] = $value;
            }
        }
        $update(['params' => json_encode($params, JSON_UNESCAPED_SLASHES)]);
        break;
}
PHP
  chmod 644 "$SK_JM_SCRIPT"
}

# jm_cli ARGS...: Joomla's own console as the owner of the site
jm_cli() {
  sk_int_as php "$SK_WP_ROOT/cli/joomla.php" "$@"
}

# jm_helper ACTION: the helper script, as the owner, in the site
jm_helper() {
  jm_script
  sk_int_as php "$SK_JM_SCRIPT" "$SK_WP_ROOT" "$@"
}

# jm_fallback: not a Joomla root (or no php): the verified package for the administrator's installer
jm_fallback() {
  sk_fetch_joomla
  jm_target=plg_system_shakekee.zip
  if [ -e "$jm_target" ] && ! cmp -s "$SK_FILE" "$jm_target"; then
    die "$jm_target already exists and is different; remove it or run this elsewhere"
  fi
  sk_do "save the verified package as $jm_target" cp "$SK_FILE" "$jm_target"
  if [ "$SK_DRY" = 0 ]; then
    sk_created "$jm_target"
  fi
  say "Not run in a Joomla root (no cli/joomla.php, or no php). Upload $jm_target in Administrator > System > Install > Extensions,"
  say "then enable System - ShakeKee under System > Manage > Plugins and open its settings: the API key id, the credential id and"
  say "the private key come from your dashboard (API keys, then credentials). Or run this installer in the Joomla root."
}

recipe_detect() {
  jm_dir=${JM_PATH:-.}
  if [ ! -f "$jm_dir/cli/joomla.php" ] || ! command -v php >/dev/null 2>&1; then
    jm_fallback
    exit 0
  fi
  SK_WP_ROOT=$(cd "$jm_dir" && pwd -P) || sk_fail 1 SK-E210 "Cannot enter $jm_dir." "Check the permissions."
  if ! sk_int_nobar "$SK_WP_ROOT"; then
    sk_fail 1 SK-E210 "The path $SK_WP_ROOT holds a bar or a line break, which the installer cannot record." "Link the site into a directory with a plain name."
  fi
  SK_WP_URL=
  ui_step_start joomla "Reading the Joomla site at $SK_WP_ROOT"
  jm_major=$(sed -n 's/^[[:space:]]*\(public \)\{0,1\}const MAJOR_VERSION = \([0-9][0-9]*\);.*/\2/p' "$SK_WP_ROOT/libraries/src/Version.php" 2>/dev/null | head -n 1) || jm_major=
  jm_minor=$(sed -n 's/^[[:space:]]*\(public \)\{0,1\}const MINOR_VERSION = \([0-9][0-9]*\);.*/\2/p' "$SK_WP_ROOT/libraries/src/Version.php" 2>/dev/null | head -n 1) || jm_minor=
  if [ -n "$jm_major" ] && [ "$jm_major" -lt 4 ]; then
    ui_step_fail "Joomla $jm_major.${jm_minor:-0} is too old"
    sk_fail 1 SK-E210 "Joomla $jm_major.${jm_minor:-0} has no command line extension installer; the plugin needs Joomla 4.3 or 5." "Upgrade Joomla, or install the package by hand: run this installer elsewhere to get plg_system_shakekee.zip."
  fi
  sk_int_as_setup "$SK_WP_ROOT"
  ui_step_ok "Joomla ${jm_major:-?}.${jm_minor:-?}${SK_INT_OWNER:+, owner $SK_INT_OWNER}"
  sk_int_as_note
  if sk_is_root && [ "$SK_INT_AS" = none ]; then
    ui_note "Running as root: the files the Joomla CLI creates will belong to root. Run it as the user of the web server if the site cannot write to them."
  fi
  # the host name: Joomla does not know its public address
  SK_WP_HOST=${JM_HOST:-${SHAKEKEE_HOST:-}}
  if [ -z "$SK_WP_HOST" ]; then
    jm_live=$(sed -n "s/^[[:space:]]*public \$live_site = '\\([^']*\\)'.*/\\1/p" "$SK_WP_ROOT/configuration.php" 2>/dev/null | head -n 1) || jm_live=
    if [ -n "$jm_live" ]; then
      sk_int_host_of "$jm_live"
      SK_WP_HOST=$SK_INT_HOST
    fi
  fi
}

recipe_choose() {
  if [ -z "$SK_WP_HOST" ]; then
    if [ "$SK_YES" = 1 ] || [ "$UI_TTY" = 0 ] || [ "$UI_MODE" = json ]; then
      sk_fail 2 SK-E001 "The host name of the Joomla site is needed and there is no terminal to ask for it." "Pass --host NAME (the name your site is registered under), or set SHAKEKEE_HOST."
    fi
    ui_ask "Which host name is this Joomla site for? (for example shop.example.com)"
    SK_WP_HOST=$UI_REPLY
  fi
  if ! sk_int_host_ok "$SK_WP_HOST"; then
    sk_fail 1 SK-E210 "The host name $SK_WP_HOST cannot be used (letters, digits, dots and dashes only)." "Pass the name of the site with --host NAME."
  fi
  sk_int_choose joomla "$SK_WP_HOST" account "$JM_MODE"
}

recipe_check() {
  sk_fetch_joomla
  SK_JM_PKG=$SK_FILE
  if [ "$SK_INT_AS" != none ]; then
    chmod 644 "$SK_JM_PKG" 2>/dev/null || true
  fi
  ui_step_start phpext "PHP has sodium and curl (the CLI's PHP; the web server may differ)"
  if php -r 'exit(extension_loaded("sodium") && extension_loaded("curl") ? 0 : 1);' >/dev/null 2>&1; then
    ui_step_ok
  else
    ui_step_warn "sodium or curl is missing in the PHP of the command line${UI_NL}Fix: install php-sodium and php-curl for the PHP that serves the site (SK-E213)."
  fi
}

recipe_plan() {
  sk_plan_add "Install the extension with the Joomla CLI in $SK_WP_ROOT$SK_INT_ASTXT"
  if [ "$JM_ENABLE" = 1 ]; then
    sk_plan_add "Enable the plugin System - ShakeKee"
  fi
  sk_plan_add "Get an API key and a credential for $SK_WP_HOST and store them in the plugin's parameters"
  if [ "$JM_EXAMPLE" = yes ]; then
    sk_plan_add "Write verify.sh to $SK_HOME/examples/joomla"
  fi
  sk_plan_add "Remember what was done in $SK_HOME/install.json"
}

recipe_apply() {
  sk_step_run ext "Installing the extension with the Joomla CLI" jm_cli extension:install "--path=$SK_JM_PKG" ||
    sk_fail 1 SK-E205 "The Joomla CLI could not install the extension." "The output is in $SK_LOG. Check that the user running it may write to the site's plugins directory."
  if [ "$JM_ENABLE" = 1 ]; then
    # Joomla installs plugins disabled and its CLI has no command to enable one
    sk_step_run enable "Enabling the plugin System - ShakeKee" jm_helper enable ||
      warn "could not enable the plugin; enable it under System > Manage > Plugins"
  fi
  sk_account_credentials "$SK_WP_HOST"
  if [ "$SK_DRY" = 1 ]; then
    ui_say "[dry-run] write the plugin's parameters: API key id, credential, private key and API key for $SK_WP_HOST"
  else
    jm_values="api_key_id=$SK_API_KEY_ID${UI_NL}credential_id=$SK_CREDENTIAL_ID${UI_NL}private_key_b64=$SK_PRIVATE_KEY_B64${UI_NL}api_key=$SK_API_KEY${UI_NL}"
    # with the raw API key too the plugin may replace a credential that was revoked (it marks it as its own)
    if [ -n "$SK_API_KEY" ] && [ -n "$SK_CREDENTIAL_ID" ]; then
      jm_values="${jm_values}auto_credential_id=$SK_CREDENTIAL_ID${UI_NL}"
    fi
    # the addresses of this ShakeKee when they are not the production ones (the key does not exist there)
    sk_account_bases
    if [ -n "$SK_BASE_API" ]; then jm_values="${jm_values}api_base=$SK_BASE_API${UI_NL}"; fi
    if [ -n "$SK_BASE_CDN" ]; then jm_values="${jm_values}cdn_url=$SK_BASE_CDN${UI_NL}"; fi
    jm_script
    sk_int_stash "$jm_values"
    sk_int_step_in "$SK_INT_STASH" params "Writing the plugin's parameters" sk_int_as php "$SK_JM_SCRIPT" "$SK_WP_ROOT" params ||
      sk_fail 1 SK-E209 "The plugin's parameters could not be written." "The output is in $SK_LOG. The plugin must be installed (see the step above) and Joomla's database reachable."
  fi
  SK_WP_EXDIR=
  if [ "$JM_EXAMPLE" = yes ]; then
    SK_WP_EXDIR=$SK_HOME/examples/joomla/$SK_WP_HOST
    sk_int_example_place joomla "$SK_WP_EXDIR"
    sk_int_example_note "$SK_WP_EXDIR/urls" "login https://$SK_WP_HOST/index.php?option=com_users&view=login"
  fi
  SK_RECIPE_VERSION=installed
  sk_int_site_record joomla - "${SK_WP_EXDIR:--}"
}

recipe_verify() {
  jmr_bad=0
  ui_step_start state "The plugin is installed, enabled and has its credential"
  jmr_s=$(jm_helper state 2>&1 </dev/null) || jmr_s=
  case $jmr_s in
    *installed=1*) ;;
    *)
      ui_step_fail "the plugin System - ShakeKee does not appear in Joomla"
      jmr_bad=1
      ;;
  esac
  if [ "$jmr_bad" = 0 ]; then
    jmr_ok=1
    if [ "$JM_ENABLE" = 1 ]; then
      case $jmr_s in *enabled=1*) ;; *) jmr_ok=0; jmr_why="the plugin is not enabled (System > Manage > Plugins)" ;; esac
    fi
    case $jmr_s in
      *key_id=?*) ;;
      *) jmr_ok=0; jmr_why="the plugin has no API key id" ;;
    esac
    if [ "$jmr_ok" = 1 ]; then
      ui_step_ok
    else
      ui_step_fail "$jmr_why"
      jmr_bad=1
    fi
  fi
  if [ "$JM_EXAMPLE" = yes ] && [ -f "$SK_WP_EXDIR/verify.sh" ]; then
    ui_step_start example "The login form carries the ShakeKee widget"
    jmr_v=$(sh "$SK_WP_EXDIR/verify.sh" 2>&1 </dev/null) && jmr_vc=0 || jmr_vc=$?
    case $jmr_vc in
      0) ui_step_ok ;;
      2) ui_step_warn "the site could not be reached from here:${UI_NL}$jmr_v" ;;
      *) ui_step_fail "$jmr_v${UI_NL}Fix: see $SK_ORIGIN/help/SK-E214"; jmr_bad=1 ;;
    esac
  fi
  if [ "$jmr_bad" = 1 ]; then
    sk_fail 1 SK-E214 "The plugin is installed but a live check failed." "Read the failed line above, fix it and run the installer again."
  fi
}

recipe_handover() {
  ui_title "Joomla is set up for $SK_WP_HOST"
  ui_say "  Settings:  System > Manage > Plugins > System - ShakeKee (pages to protect, fail open or closed)"
  if [ "$JM_EXAMPLE" = yes ] && [ "$SK_DRY" != 1 ]; then
    ui_say "  Check it:  sh $SK_WP_EXDIR/verify.sh"
  fi
  ui_note "The widget is on the login and registration forms. Name the menu items, articles or URL paths to protect in the plugin's settings. The raw API key stays in $SK_HOME/credentials/ and in the plugin's parameters."
}

# ---- the hooks ----

jm_status_one() {
  ui_step_start "site$SK_WP_N" "Joomla at $SK_WP_ROOT ($SK_WP_HOST)"
  jms_s=$(jm_helper state 2>&1 </dev/null) || jms_s=
  case $jms_s in
    *installed=1*) ;;
    *)
      ui_step_fail "the plugin System - ShakeKee does not appear in Joomla (or the site cannot be read)${UI_NL}Fix: run the installer again."
      return 1
      ;;
  esac
  case $jms_s in
    *enabled=1*) ;;
    *)
      ui_step_fail "the plugin is installed but not enabled${UI_NL}Fix: enable it under System > Manage > Plugins."
      return 1
      ;;
  esac
  case $jms_s in
    *key_id=?*) ui_step_ok "plugin enabled; credential saved" ;;
    *)
      ui_step_fail "the plugin has no API key id${UI_NL}Fix: run the installer again."
      return 1
      ;;
  esac
  return 0
}

recipe_status() {
  sk_int_each joomla jm_status_one php "$JM_PATH"
}

jm_doctor_one() {
  jm_status_one || return 1
  ui_step_start "phpext$SK_WP_N" "PHP has sodium and curl"
  if sk_int_as php -r 'exit(extension_loaded("sodium") && extension_loaded("curl") ? 0 : 1);' >/dev/null 2>&1; then
    ui_step_ok
  else
    ui_step_warn "sodium or curl is missing in the PHP of the command line${UI_NL}Fix: install php-sodium and php-curl for the PHP that serves the site."
  fi
  return 0
}

recipe_doctor() {
  sk_int_each joomla jm_doctor_one php "$JM_PATH"
}

jm_update_one() {
  sk_fetch_joomla
  if [ "$SK_INT_AS" != none ]; then
    chmod 644 "$SK_FILE" 2>/dev/null || true
  fi
  sk_step_run ext "Updating the extension in $SK_WP_ROOT" jm_cli extension:install "--path=$SK_FILE" || return 1
  sk_integration_add joomla installed
  return 0
}

recipe_update() {
  sk_int_each joomla jm_update_one php "$JM_PATH"
}

jm_uninstall_one() {
  ui_step_start "site$SK_WP_N" "Removing ShakeKee from $SK_WP_ROOT ($SK_WP_HOST)"
  jmu_id=$(jm_helper id 2>/dev/null </dev/null) || jmu_id=
  case $jmu_id in
    '' | *[!0-9]*)
      ui_step_warn "the plugin is no longer in Joomla; nothing to remove there"
      jmu_id=
      ;;
    *) ui_step_ok "extension id $jmu_id" ;;
  esac
  if [ "$SK_DRY" = 1 ]; then
    ui_say "[dry-run] remove the extension with the Joomla CLI"
    return 0
  fi
  sk_log_open
  if [ -n "$jmu_id" ]; then
    sk_step_run remove "Removing the extension (its parameters go with it)" jm_cli extension:remove "$jmu_id" || return 1
  fi
  sk_wp_revoke_ask
  sk_unrecord "$SK_WP_RECKEY"
  return 0
}

recipe_uninstall() {
  sk_int_each joomla jm_uninstall_one php "$JM_PATH"
}

sk_phases
}
sk_recipe_drupal() {
# ---- drupal: Composer package from a local artifact repository, drush en, and the daemon settings ----
# The module works with the ShakeKee daemon next to the web server (no account mode). composer.json and
# composer.lock are backed up before Composer changes them and put back by --uninstall. Shared parts: integrate.sh;
# docs/installer-cms.md says what is written and how it is undone.
DR_PATH=
DR_ENABLE=1
DR_HOST=
DR_MODE=
DR_EXAMPLE=yes

usage() {
  cat <<'USAGE'
Usage: curl -fsSL https://get.shakekee.com/drupal | sh -s -- [options]
  --path DIR      the Drupal project (the directory with composer.json; default: the current directory)
  --host NAME     the licensed host name of the site (or SHAKEKEE_HOST)
  --daemon        use the ShakeKee daemon (the only mode of this module; the host must be registered with it)
  --no-enable     install the package, do not run drush en or write the settings
  --no-example    do not write the example (verify.sh)
  --force         replace example files that differ from the example
  --dry-run       download and verify, change nothing
Needs Composer. The verified package is kept in DIR/shakekee-artifacts/ (Composer records its path). With drush the
module is enabled and set up (address, host name and shared secret of the daemon); without drush the settings are
written to a file that you include from settings.php. Composer runs as the owner of the project. Safe to run again.
USAGE
  sk_usage_common
}

while [ $# -gt 0 ]; do
  case $1 in
    --path) sk_value "$1" $#; DR_PATH=$2; shift ;;
    --host) sk_value "$1" $#; DR_HOST=$2; shift ;;
    --daemon) DR_MODE=daemon ;;
    --account) DR_MODE=account ;;
    --no-enable) DR_ENABLE=0 ;;
    --example) DR_EXAMPLE=yes ;;
    --no-example) DR_EXAMPLE=no ;;
    --force) SK_FORCE=1 ;;
    --dry-run) SK_DRY=1 ;;
    --help | -h) usage; exit 0 ;;
    *) die "unknown option $1 (see --help)" ;;
  esac
  shift
done

# dr_composer ARGS...: Composer in the project, as its owner
dr_composer() {
  sk_int_as composer "--working-dir=$SK_WP_ROOT" --no-interaction "$@"
}

# dr_drush_find: SK_DR_DRUSH is the project's drush (vendor/bin/drush), else the one on the PATH, else empty
dr_drush_find() {
  SK_DR_DRUSH=
  if [ -x "$SK_WP_ROOT/vendor/bin/drush" ]; then
    SK_DR_DRUSH=$SK_WP_ROOT/vendor/bin/drush
  elif command -v drush >/dev/null 2>&1; then
    SK_DR_DRUSH=$(command -v drush)
  fi
}

# dr_drush ARGS...: drush in the project directory, as its owner
dr_drush() {
  (cd "$SK_WP_ROOT" && sk_int_as "$SK_DR_DRUSH" "$@")
}

recipe_detect() {
  ui_step_start composer "Looking for Composer and the Drupal project"
  SK_WP_BIN=$(command -v composer 2>/dev/null) || SK_WP_BIN=
  if [ -z "$SK_WP_BIN" ]; then
    ui_step_fail "composer was not found on the PATH"
    ui_say ""
    ui_say "Without Composer the module is installed by hand:"
    sk_int_keep_package sk_fetch_drupal
    ui_say "  1. Unpack the package into web/modules/custom/ of the project (the folder shakekee/ is at its top level)."
    ui_say "  2. Enable the module at Extend, then set the daemon address, the host name and the shared secret at"
    ui_say "     /admin/config/system/shakekee (or install Composer and drush and run this installer again)."
    sk_fail 1 SK-E211 "Composer is not installed or not on the PATH, so the module was not installed." "Install Composer (https://getcomposer.org) and run this again, or follow the manual steps above."
  fi
  if ! command -v php >/dev/null 2>&1 || ! php -r 'exit(class_exists("ZipArchive") ? 0 : 1);' >/dev/null 2>&1; then
    ui_step_fail "PHP or PHP's zip extension is missing"
    sk_fail 1 SK-E213 "PHP's zip extension is not enabled (Composer's artifact repository needs it)." "Install php-zip for the PHP of the command line and run this again."
  fi
  dr_dir=${DR_PATH:-.}
  if [ ! -f "$dr_dir/composer.json" ]; then
    ui_step_fail "no composer.json in $dr_dir"
    sk_fail 1 SK-E211 "There is no composer.json in $dr_dir." "Run it in the Drupal project (the directory above web/) or pass --path DIR."
  fi
  SK_WP_ROOT=$(cd "$dr_dir" && pwd -P) || sk_fail 1 SK-E211 "Cannot enter $dr_dir." "Check the permissions."
  if ! sk_int_nobar "$SK_WP_ROOT"; then
    sk_fail 1 SK-E211 "The path $SK_WP_ROOT holds a bar or a line break, which the installer cannot record." "Link the project into a directory with a plain name."
  fi
  if ! grep -q '"drupal/core' "$SK_WP_ROOT/composer.json"; then
    ui_step_fail "$SK_WP_ROOT/composer.json does not require drupal/core"
    sk_fail 1 SK-E211 "$SK_WP_ROOT/composer.json does not require drupal/core; this is not a Drupal project." "Pass the directory of the Drupal project with --path DIR."
  fi
  SK_WP_URL=
  sk_int_as_setup "$SK_WP_ROOT"
  if sk_is_root && [ "$SK_INT_AS" = none ] && [ "${COMPOSER_ALLOW_SUPERUSER:-}" != 1 ]; then
    ui_step_fail "the project belongs to root and Composer should not run as root"
    sk_fail 1 SK-E211 "Composer should not be run as root, and the project belongs to root." "Run this as the user that owns the project, or set COMPOSER_ALLOW_SUPERUSER=1."
  fi
  dr_drush_find
  ui_step_ok "$SK_WP_ROOT${SK_INT_OWNER:+, owner $SK_INT_OWNER}, drush: ${SK_DR_DRUSH:-none}"
  sk_int_as_note
  SK_WP_HOST=${DR_HOST:-${SHAKEKEE_HOST:-}}
}

recipe_choose() {
  if [ -z "$SK_WP_HOST" ]; then
    if [ "$SK_YES" = 1 ] || [ "$UI_TTY" = 0 ] || [ "$UI_MODE" = json ]; then
      sk_fail 2 SK-E001 "The host name of the Drupal site is needed and there is no terminal to ask for it." "Pass --host NAME (the name the daemon has registered), or set SHAKEKEE_HOST."
    fi
    ui_ask "Which host name is this Drupal site? (as registered with the ShakeKee daemon)"
    SK_WP_HOST=$UI_REPLY
  fi
  if ! sk_int_host_ok "$SK_WP_HOST"; then
    sk_fail 1 SK-E211 "The host name $SK_WP_HOST cannot be used (letters, digits, dots and dashes only)." "Pass the name of the site with --host NAME."
  fi
  sk_int_choose drupal "$SK_WP_HOST" daemon "$DR_MODE"
}

recipe_check() {
  sk_fetch_drupal
  SK_DR_PKG=$SK_FILE
  if [ "$SK_INT_AS" != none ]; then
    chmod 644 "$SK_DR_PKG" 2>/dev/null || true
  fi
}

recipe_plan() {
  sk_plan_add "Keep the verified package in $SK_WP_ROOT/shakekee-artifacts/ and add it to composer.json as an artifact repository (composer.json and composer.lock are backed up first)"
  sk_plan_add "composer require shakekee/drupal$SK_INT_ASTXT"
  if [ "$DR_ENABLE" = 1 ]; then
    if [ -n "$SK_DR_DRUSH" ]; then
      sk_plan_add "drush en shakekee, then set the daemon address, the host name $SK_WP_HOST and the shared secret"
    else
      sk_plan_add "Write the daemon settings to a file that settings.php includes (drush is not installed, so the module is enabled at Extend)"
    fi
  fi
  if [ "$DR_EXAMPLE" = yes ]; then
    sk_plan_add "Write verify.sh to $SK_HOME/examples/drupal"
  fi
  sk_plan_add "Remember what was done in $SK_HOME/install.json"
}

# dr_settings_file: no drush: the settings, in a file only this user reads, for settings.php to include
dr_settings_file() {
  SK_DR_SETFILE=$SK_HOME/drupal-settings-$SK_WP_HOST.php
  case $SK_INT_SECRET in
    '' | *[!A-Za-z0-9._~+/=-]*) sk_fail 1 SK-E216 "The daemon's shared secret has characters the settings file cannot hold." "Set SHAKEKEED_PREFLIGHT_SECRET to a secret of letters and digits and restart the daemon." ;;
  esac
  sk_ensure_dir .
  (umask 077 && {
    printf '%s\n' '<?php' '// Written by the ShakeKee installer. Include it from settings.php:' "//   include '$SK_DR_SETFILE';"
    printf '%s\n' "\$settings['shakekee_preflight_secret'] = '$SK_INT_SECRET';"
    printf '%s\n' "\$config['shakekee.settings']['preflight_url'] = '$SK_INT_URL';"
    printf '%s\n' "\$config['shakekee.settings']['host'] = '$SK_WP_HOST';"
  } >"$SK_DR_SETFILE") || sk_fail 1 SK-E209 "Cannot write $SK_DR_SETFILE." "Check the permissions of $SK_HOME."
  sk_created "$SK_DR_SETFILE"
}

recipe_apply() {
  sk_int_backup_once "$SK_WP_ROOT/composer.json"
  if [ -f "$SK_WP_ROOT/composer.lock" ]; then
    sk_int_backup_once "$SK_WP_ROOT/composer.lock"
  fi
  dr_art=$SK_WP_ROOT/shakekee-artifacts
  if [ ! -d "$dr_art" ]; then
    sk_step_run mkdir "Creating shakekee-artifacts/" sk_int_as mkdir "$dr_art" || sk_fail 1 SK-E205 "Cannot create $dr_art." "Check that the owner of the project may write there."
    sk_created "$dr_art"
  fi
  sk_step_run keep "Keeping the verified package" sk_int_as sh -c 'rm -f "$1"/shakekee-drupal-*.zip && cp "$2" "$1"/' sh "$dr_art" "$SK_DR_PKG" ||
    sk_fail 1 SK-E205 "The package could not be copied to $dr_art." "Check that the owner of the project may write there."
  if [ "$SK_DRY" != 1 ]; then
    sk_created "$dr_art/${SK_DR_PKG##*/}"
  fi
  sk_step_run repo "Adding the artifact repository to composer.json" dr_composer config repositories.shakekee artifact ./shakekee-artifacts ||
    sk_fail 1 SK-E205 "Composer could not add the artifact repository." "The output is in $SK_LOG."
  sk_step_run require "composer require shakekee/drupal" dr_composer require shakekee/drupal ||
    sk_fail 1 SK-E205 "Composer could not install shakekee/drupal." "The output is in $SK_LOG. Check the PHP version (8.2+) and that Drupal is 10.3 or newer."
  sk_step_run show "Checking the package" dr_composer show shakekee/drupal || true
  sk_int_changed "$SK_WP_ROOT/composer.json"
  if [ -f "$SK_WP_ROOT/composer.lock" ]; then
    sk_int_changed "$SK_WP_ROOT/composer.lock"
  fi
  SK_DR_USED=0
  if [ "$DR_ENABLE" = 1 ]; then
    sk_int_daemon_secret
    sk_int_daemon_url
    dr_drush_find
    if [ -n "$SK_DR_DRUSH" ]; then
      SK_DR_USED=1
      sk_step_run enable "drush en shakekee" dr_drush en shakekee -y ||
        sk_fail 1 SK-E205 "drush could not enable the module." "The output is in $SK_LOG. Enable it at Extend, then run this installer again."
      sk_step_run url "Setting the daemon address" dr_drush config:set shakekee.settings preflight_url "$SK_INT_URL" --input-format=string -y ||
        sk_fail 1 SK-E209 "drush could not set the daemon address." "The output is in $SK_LOG."
      sk_step_run host "Setting the host name $SK_WP_HOST" dr_drush config:set shakekee.settings host "$SK_WP_HOST" --input-format=string -y ||
        sk_fail 1 SK-E209 "drush could not set the host name." "The output is in $SK_LOG."
      if [ "$SK_DRY" != 1 ]; then
        sk_int_stash "$SK_INT_SECRET"
      fi
      sk_int_step_in "${SK_INT_STASH:-/dev/null}" secret "Storing the shared secret (outside the configuration, so it is never exported)" dr_drush state:set shakekee.preflight_secret - --input-format=string ||
        sk_fail 1 SK-E209 "drush could not store the shared secret." "The output is in $SK_LOG."
    elif [ "$SK_DRY" = 1 ]; then
      ui_say "[dry-run] write the daemon settings to $SK_HOME/drupal-settings-$SK_WP_HOST.php"
    else
      dr_settings_file
      ui_step_start settings "Writing the daemon settings"
      ui_step_ok "$SK_DR_SETFILE"
      sk_remains "Remove the line that includes $SK_DR_SETFILE from settings.php, and enable or uninstall the module at Extend."
      ui_note "drush is not installed. Enable the module at Extend (Administration > Extend), and add this one line to your settings.php:  include '$SK_DR_SETFILE';"
    fi
  fi
  SK_WP_EXDIR=
  if [ "$DR_EXAMPLE" = yes ]; then
    SK_WP_EXDIR=$SK_HOME/examples/drupal/$SK_WP_HOST
    sk_int_example_place drupal "$SK_WP_EXDIR"
    sk_int_example_note "$SK_WP_EXDIR/urls" "protected https://$SK_WP_HOST/"
  fi
  SK_RECIPE_VERSION=${SK_DR_PKG##*/}
  SK_RECIPE_VERSION=${SK_RECIPE_VERSION#shakekee-drupal-}
  SK_RECIPE_VERSION=${SK_RECIPE_VERSION%.zip}
  sk_int_site_record drupal "$SK_DR_USED" "${SK_WP_EXDIR:--}"
}

recipe_verify() {
  drr_bad=0
  ui_step_start package "The package is installed"
  if dr_composer show shakekee/drupal >/dev/null 2>&1; then
    ui_step_ok
  else
    ui_step_fail "composer does not list shakekee/drupal"
    drr_bad=1
  fi
  if [ "$DR_ENABLE" = 1 ] && [ -n "$SK_DR_DRUSH" ]; then
    ui_step_start module "The module is enabled"
    if dr_drush pm:list --status=enabled --format=json 2>/dev/null </dev/null | grep -q '"shakekee"'; then
      ui_step_ok
    else
      ui_step_fail "drush does not list shakekee among the enabled modules"
      drr_bad=1
    fi
  fi
  if [ "$DR_ENABLE" = 1 ]; then
    ui_step_start daemon "The daemon accepts the shared secret"
    sk_int_daemon_probe
    case $SK_INT_PROBE in
      ok) ui_step_ok "$SK_INT_URL" ;;
      secret)
        ui_step_fail "the daemon answers 401: it does not accept the shared secret${UI_NL}Fix: run this installer again."
        drr_bad=1
        ;;
      down) ui_step_warn "nothing answers at $SK_INT_URL (is the daemon running?)" ;;
      *) ui_step_warn "the daemon's answer was not understood" ;;
    esac
  fi
  if [ "$DR_EXAMPLE" = yes ] && [ "$DR_ENABLE" = 1 ] && [ -f "$SK_WP_EXDIR/verify.sh" ] && [ "$SK_DR_USED" = 1 ]; then
    ui_step_start example "The site answers with the wall"
    drr_v=$(sh "$SK_WP_EXDIR/verify.sh" 2>&1 </dev/null) && drr_vc=0 || drr_vc=$?
    case $drr_vc in
      0) ui_step_ok ;;
      2) ui_step_warn "the site could not be reached from here:${UI_NL}$drr_v" ;;
      *) ui_step_fail "$drr_v${UI_NL}Fix: see $SK_ORIGIN/help/SK-E214"; drr_bad=1 ;;
    esac
  fi
  if [ "$drr_bad" = 1 ]; then
    sk_fail 1 SK-E214 "The module is installed but a live check failed." "Read the failed line above, fix it and run the installer again."
  fi
}

recipe_handover() {
  ui_title "Drupal is set up for $SK_WP_HOST"
  if [ "$DR_ENABLE" = 1 ] && [ "$SK_DR_USED" = 1 ]; then
    ui_say "  Settings:  /admin/config/system/shakekee (address, host name, fail open or closed, timeout)"
  fi
  if [ "$DR_EXAMPLE" = yes ] && [ "$SK_DRY" != 1 ]; then
    ui_say "  Check it:  sh $SK_WP_EXDIR/verify.sh"
  fi
  ui_note "The daemon's protect list decides which paths are walled (the whole site by default). The web server must pass /.shakekee/ and /.well-known/shakekee/ to the daemon's public listener; the wall needs them."
}

# ---- the hooks ----

dr_status_one() {
  dr_drush_find
  ui_step_start "site$SK_WP_N" "Drupal at $SK_WP_ROOT ($SK_WP_HOST)"
  if ! dr_composer show shakekee/drupal >/dev/null 2>&1; then
    ui_step_fail "composer does not list shakekee/drupal${UI_NL}Fix: run the installer again."
    return 1
  fi
  if [ -n "$SK_DR_DRUSH" ]; then
    if ! dr_drush pm:list --status=enabled --format=json 2>/dev/null </dev/null | grep -q '"shakekee"'; then
      ui_step_fail "the module shakekee is installed but not enabled${UI_NL}Fix: drush en shakekee, or enable it at Extend."
      return 1
    fi
  fi
  sk_int_daemon_find
  sk_int_daemon_url
  SK_INT_SECRET=$(sk_int_daemon print-config --preflight-secret 2>/dev/null </dev/null) || SK_INT_SECRET=
  sk_int_daemon_probe
  case $SK_INT_PROBE in
    ok) ui_step_ok "module enabled; the daemon accepts the shared secret" ;;
    secret)
      ui_step_fail "the daemon does not accept the shared secret${UI_NL}Fix: run the installer again; it stores the daemon's secret in Drupal."
      return 1
      ;;
    *) ui_step_warn "module enabled; the daemon at $SK_INT_URL did not answer" ;;
  esac
  return 0
}

recipe_status() {
  sk_int_each drupal dr_status_one composer "$DR_PATH"
}

dr_doctor_one() {
  dr_status_one || return 1
  ui_step_start "zip$SK_WP_N" "PHP's zip extension is available"
  if php -r 'exit(class_exists("ZipArchive") ? 0 : 1);' >/dev/null 2>&1; then
    ui_step_ok
  else
    ui_step_warn "PHP's zip extension is missing; Composer needs it to read the package${UI_NL}Fix: install php-zip."
  fi
  return 0
}

recipe_doctor() {
  sk_int_each drupal dr_doctor_one composer "$DR_PATH"
}

dr_update_one() {
  sk_fetch_drupal
  if [ "$SK_INT_AS" != none ]; then
    chmod 644 "$SK_FILE" 2>/dev/null || true
  fi
  dr_art=$SK_WP_ROOT/shakekee-artifacts
  sk_step_run keep "Keeping the verified package" sk_int_as sh -c 'rm -f "$1"/shakekee-drupal-*.zip && cp "$2" "$1"/' sh "$dr_art" "$SK_FILE" || return 1
  if [ "$SK_DRY" != 1 ]; then
    sk_created "$dr_art/${SK_FILE##*/}"
  fi
  sk_step_run update "composer update shakekee/drupal" dr_composer update shakekee/drupal --with-dependencies || return 1
  sk_int_changed "$SK_WP_ROOT/composer.json"
  if [ -f "$SK_WP_ROOT/composer.lock" ]; then
    sk_int_changed "$SK_WP_ROOT/composer.lock"
  fi
  dr_drush_find
  if [ -n "$SK_DR_DRUSH" ]; then
    sk_step_run updb "drush updatedb" dr_drush updatedb -y || true
  fi
  sk_integration_add drupal "${SK_FILE##*/}"
  return 0
}

recipe_update() {
  sk_int_each drupal dr_update_one composer "$DR_PATH"
}

dr_uninstall_one() {
  ui_step_start "site$SK_WP_N" "Removing ShakeKee from $SK_WP_ROOT ($SK_WP_HOST)"
  dr_drush_find
  if [ "$SK_WP_F5" = 1 ] && [ -z "$SK_DR_DRUSH" ]; then
    ui_step_fail "the module was enabled with drush and drush cannot be found now${UI_NL}Fix: install drush, or uninstall the module at Extend > Uninstall first, then run this again."
    return 1
  fi
  ui_step_ok
  if [ "$SK_DRY" = 1 ]; then
    ui_say "[dry-run] drush pm:uninstall shakekee, composer remove shakekee/drupal, put composer.json and composer.lock back, remove shakekee-artifacts/"
    return 0
  fi
  sk_log_open
  if [ -n "$SK_DR_DRUSH" ]; then
    # the secret is kept in state, outside the configuration; the module's configuration goes with it
    sk_step_run state "Deleting the stored shared secret" dr_drush state:delete shakekee.preflight_secret -y || true
    sk_step_run pmu "drush pm:uninstall shakekee" dr_drush pm:uninstall shakekee -y || return 1
  fi
  # Composer rewrites composer.json and composer.lock; when they are still as our install left them, the backups go back
  # over them afterwards, byte for byte (otherwise Composer's own edit stays and --uninstall says so)
  if sk_int_untouched "$SK_WP_ROOT/composer.json"; then
    dr_restore_json=1
  else
    dr_restore_json=0
  fi
  if [ -f "$SK_WP_ROOT/composer.lock" ] && sk_int_untouched "$SK_WP_ROOT/composer.lock"; then
    dr_restore_lock=1
  else
    dr_restore_lock=0
  fi
  sk_step_run remove "composer remove shakekee/drupal" dr_composer remove shakekee/drupal || return 1
  sk_step_run repo "Removing the artifact repository from composer.json" dr_composer config --unset repositories.shakekee || true
  if [ "$dr_restore_json" = 1 ]; then
    sk_int_copy_back "$SK_WP_ROOT/composer.json"
  fi
  if [ "$dr_restore_lock" = 1 ]; then
    sk_int_copy_back "$SK_WP_ROOT/composer.lock"
  fi
  sk_unrecord "$SK_WP_RECKEY"
  return 0
}

recipe_uninstall() {
  sk_int_each drupal dr_uninstall_one composer "$DR_PATH"
}

sk_phases
}
sk_recipe_prestashop() {
# ---- prestashop: unpack the module into modules/, install it with the PrestaShop console, configure it for the daemon ----
# The module works with the ShakeKee daemon next to the web server (no account mode). Its settings are shop
# configuration values; the installer writes them through PrestaShop's own Configuration class with the secret on the
# standard input. The console and PHP run as the owner of the shop. Shared parts: integrate.sh; docs/installer-cms.md
# says what is written and how it is undone.
PS_PATH=
PS_HOST=
PS_MODE=
PS_EXAMPLE=yes

usage() {
  cat <<'USAGE'
Usage: curl -fsSL https://get.shakekee.com/prestashop | sh -s -- [options]
  --path DIR      the PrestaShop root (default: the current directory)
  --host NAME     the licensed host name of the shop (default: the shop's own domain; or SHAKEKEE_HOST)
  --daemon        use the ShakeKee daemon (the only mode of this module; the host must be registered with it)
  --no-example    do not write the example (verify.sh)
  --force         replace example files that differ from the example
  --dry-run       download and verify, change nothing
Run it as the user that owns the shop (or any user that may become the owner: PHP and the console then run as the
owner), with PHP and unzip (or PHP's zip extension). Safe to run again: the module is replaced by the verified
version and its settings (stored in the shop's database) are kept.
USAGE
  sk_usage_common
}

while [ $# -gt 0 ]; do
  case $1 in
    --path) sk_value "$1" $#; PS_PATH=$2; shift ;;
    --host) sk_value "$1" $#; PS_HOST=$2; shift ;;
    --daemon) PS_MODE=daemon ;;
    --account) PS_MODE=account ;;
    --example) PS_EXAMPLE=yes ;;
    --no-example) PS_EXAMPLE=no ;;
    --force) SK_FORCE=1 ;;
    --dry-run) SK_DRY=1 ;;
    --help | -h) usage; exit 0 ;;
    *) die "unknown option $1 (see --help)" ;;
  esac
  shift
done

# ps_script: writes the helper that reads and changes the module's configuration through PrestaShop's own classes.
# Anybody may read it: it holds no secret. Secrets reach it on the standard input.
ps_script() {
  sk_int_task
  SK_PS_SCRIPT=$SK_TASK/shakekee-prestashop.php
  cat >"$SK_PS_SCRIPT" <<'PHP'
<?php
// php shakekee-prestashop.php ROOT info|state|config
//   info    prints domain=<the shop's domain>
//   state   prints installed, enabled, host, url, has_secret (never the secret)
//   config  reads lines name=value from the standard input into the module's configuration and switches it on
$root = $argv[1] ?? '';
$action = $argv[2] ?? '';
if ($root === '' || !in_array($action, ['info', 'state', 'config'], true)) {
    fwrite(STDERR, "usage: php shakekee-prestashop.php ROOT info|state|config\n");
    exit(2);
}
require_once $root . '/config/config.inc.php';
switch ($action) {
    case 'info':
        echo 'domain=', (string) Configuration::get('PS_SHOP_DOMAIN'), "\n";
        break;
    case 'state':
        echo 'installed=', Module::isInstalled('shakekee') ? 1 : 0, "\n";
        echo 'enabled=', (int) Configuration::get('SHAKEKEE_ENABLED'), "\n";
        echo 'host=', (string) Configuration::get('SHAKEKEE_HOST'), "\n";
        echo 'url=', (string) Configuration::get('SHAKEKEE_PREFLIGHT_URL'), "\n";
        echo 'has_secret=', (string) Configuration::get('SHAKEKEE_PREFLIGHT_SECRET') !== '' ? 1 : 0, "\n";
        break;
    case 'config':
        $names = ['SHAKEKEE_PREFLIGHT_URL', 'SHAKEKEE_PREFLIGHT_SECRET', 'SHAKEKEE_HOST'];
        foreach (preg_split('/\r?\n/', (string) stream_get_contents(STDIN)) as $line) {
            $pos = strpos($line, '=');
            if ($pos === false) {
                continue;
            }
            $name = substr($line, 0, $pos);
            $value = substr($line, $pos + 1);
            if (in_array($name, $names, true) && $value !== '') {
                Configuration::updateValue($name, $value);
            }
        }
        Configuration::updateValue('SHAKEKEE_ENABLED', 1);
        break;
}
PHP
  chmod 644 "$SK_PS_SCRIPT"
}

# ps_helper ACTION: the helper script as the owner of the shop
ps_helper() {
  ps_script
  sk_int_as php "$SK_PS_SCRIPT" "$SK_WP_ROOT" "$@"
}

# ps_console ARGS...: the PrestaShop console in the shop's directory, as its owner
ps_console() {
  (cd "$SK_WP_ROOT" && sk_int_as php bin/console "$@")
}

recipe_detect() {
  ui_step_start shop "Looking for PHP and the PrestaShop shop"
  if ! command -v php >/dev/null 2>&1; then
    ui_step_fail "php was not found on the PATH"
    ui_say ""
    ui_say "Without PHP on the command line the module is installed by hand:"
    sk_int_keep_package sk_fetch_prestashop
    ui_say "  1. In the back office: Modules > Module Manager > Upload a module, choose the package."
    ui_say "  2. Modules > ShakeKee: the daemon address, the host name and the daemon's shared secret, then switch \"Ask the daemon\" on."
    ui_say "     (Or install PHP's command line and run this installer again: it does all of it.)"
    sk_fail 1 SK-E213 "PHP is not installed or not on the PATH, so the module was not installed." "Install PHP (the one the shop uses) and run this again, or follow the manual steps above."
  fi
  ps_dir=${PS_PATH:-.}
  if [ ! -f "$ps_dir/bin/console" ] || [ ! -d "$ps_dir/modules" ] || { [ ! -f "$ps_dir/config/config.inc.php" ] && [ ! -d "$ps_dir/src/PrestaShopBundle" ]; }; then
    ui_step_fail "no PrestaShop 1.7.6+/8/9 shop in $ps_dir (bin/console and modules/ not found)"
    sk_fail 1 SK-E212 "There is no PrestaShop shop in $ps_dir (bin/console and modules/ were not found)." "Run it in the shop root or pass --path DIR."
  fi
  SK_WP_ROOT=$(cd "$ps_dir" && pwd -P) || sk_fail 1 SK-E212 "Cannot enter $ps_dir." "Check the permissions."
  if ! sk_int_nobar "$SK_WP_ROOT"; then
    sk_fail 1 SK-E212 "The path $SK_WP_ROOT holds a bar or a line break, which the installer cannot record." "Link the shop into a directory with a plain name."
  fi
  SK_WP_URL=
  if ! command -v unzip >/dev/null 2>&1 && ! php -r 'exit(class_exists("ZipArchive") ? 0 : 1);' >/dev/null 2>&1; then
    ui_step_fail "neither unzip nor PHP's zip extension is available"
    sk_fail 1 SK-E213 "The module package cannot be unpacked: unzip and PHP's zip extension are both missing." "Install unzip (or php-zip) and run this again."
  fi
  sk_int_as_setup "$SK_WP_ROOT"
  ui_step_ok "$SK_WP_ROOT${SK_INT_OWNER:+, owner $SK_INT_OWNER}"
  sk_int_as_note
  if sk_is_root && [ "$SK_INT_AS" = none ]; then
    ui_note "Running as root: the files of the module will belong to root. The shop's cache is better cleared by its own user (php bin/console cache:clear)."
  fi
  SK_WP_HOST=${PS_HOST:-${SHAKEKEE_HOST:-}}
  if [ -z "$SK_WP_HOST" ]; then
    # the shop's own domain, from its configuration (the temporary directory is made here, not in the subshell)
    sk_int_task
    ps_info=$(ps_helper info 2>/dev/null </dev/null) || ps_info=
    SK_WP_HOST=$(printf '%s\n' "$ps_info" | sed -n 's/^domain=//p' | head -n 1) || SK_WP_HOST=
    case $SK_WP_HOST in *:*) SK_WP_HOST=${SK_WP_HOST%%:*} ;; esac
  fi
}

recipe_choose() {
  if ! sk_int_host_ok "$SK_WP_HOST"; then
    if [ "$SK_YES" = 1 ] || [ "$UI_TTY" = 0 ] || [ "$UI_MODE" = json ]; then
      sk_fail 2 SK-E001 "The host name of the shop is needed and cannot be found, and there is no terminal to ask for it." "Pass --host NAME (the name the daemon has registered), or set SHAKEKEE_HOST."
    fi
    ui_ask "Which host name is this shop? (as registered with the ShakeKee daemon)"
    SK_WP_HOST=$UI_REPLY
    if ! sk_int_host_ok "$SK_WP_HOST"; then
      sk_fail 1 SK-E212 "The host name $SK_WP_HOST cannot be used (letters, digits, dots and dashes only)." "Pass the name of the shop with --host NAME."
    fi
  fi
  sk_int_choose prestashop "$SK_WP_HOST" daemon "$PS_MODE"
}

recipe_check() {
  sk_fetch_prestashop
  SK_PS_PKG=$SK_FILE
}

recipe_plan() {
  sk_plan_add "Replace $SK_WP_ROOT/modules/shakekee with the verified module and install it with the PrestaShop console$SK_INT_ASTXT"
  sk_plan_add "Give the module the daemon address, the host name $SK_WP_HOST and the shared secret, and switch it on (shop configuration)"
  if [ "$PS_EXAMPLE" = yes ]; then
    sk_plan_add "Write verify.sh to $SK_HOME/examples/prestashop"
  fi
  sk_plan_add "Remember what was done in $SK_HOME/install.json"
}

recipe_apply() {
  psa_unpack=$SK_TASK/unpacked
  if [ "$SK_DRY" = 1 ]; then
    ui_say "[dry-run] replace $SK_WP_ROOT/modules/shakekee with the verified module, php bin/console prestashop:module install shakekee"
  else
    rm -rf "$psa_unpack"
    mkdir -p "$psa_unpack"
    if command -v unzip >/dev/null 2>&1; then
      sk_step_run unpack "Unpacking the module" unzip -q "$SK_PS_PKG" -d "$psa_unpack" ||
        sk_fail 1 SK-E205 "The module package could not be unpacked." "The output is in $SK_LOG."
    else
      sk_step_run unpack "Unpacking the module" php -r '$z = new ZipArchive; if ($z->open($argv[1]) !== true || !$z->extractTo($argv[2])) { exit(1); }' "$SK_PS_PKG" "$psa_unpack" ||
        sk_fail 1 SK-E205 "The module package could not be unpacked." "The output is in $SK_LOG."
    fi
    [ -f "$psa_unpack/shakekee/shakekee.php" ] ||
      sk_fail 1 SK-E205 "The package does not contain shakekee/shakekee.php." "Nothing was installed. Run the installer again; if it keeps failing, the package on the server is wrong."
    # the owner of the shop reads what was unpacked
    chmod -R a+rX "$psa_unpack" 2>/dev/null || true
    sk_step_run place "Replacing modules/shakekee with the verified module" sk_int_as sh -c 'rm -rf "$2/modules/shakekee" && cp -R "$1/shakekee" "$2/modules/shakekee"' sh "$psa_unpack" "$SK_WP_ROOT" ||
      sk_fail 1 SK-E205 "The module could not be copied into $SK_WP_ROOT/modules." "The output is in $SK_LOG. Check that the owner of the shop may write there."
  fi
  # install is a no-op for a module that is installed already, upgrade runs the module's upgrade scripts: both are safe to repeat
  sk_step_run install "php bin/console prestashop:module install shakekee" ps_console prestashop:module install shakekee ||
    sk_fail 1 SK-E205 "The module files are in place but the install command failed." "The output is in $SK_LOG. Is the shop installed and its database reachable?"
  sk_step_run upgrade "php bin/console prestashop:module upgrade shakekee" ps_console prestashop:module upgrade shakekee ||
    sk_fail 1 SK-E205 "The module is installed but its upgrade failed." "The output is in $SK_LOG."
  sk_int_daemon_secret
  sk_int_daemon_url
  if [ "$SK_DRY" = 1 ]; then
    ui_say "[dry-run] write the module's configuration: daemon address, host name $SK_WP_HOST, shared secret, switched on"
  else
    ps_script
    sk_int_stash "SHAKEKEE_PREFLIGHT_URL=$SK_INT_URL${UI_NL}SHAKEKEE_HOST=$SK_WP_HOST${UI_NL}SHAKEKEE_PREFLIGHT_SECRET=$SK_INT_SECRET${UI_NL}"
    sk_int_step_in "$SK_INT_STASH" config "Writing the module's configuration" sk_int_as php "$SK_PS_SCRIPT" "$SK_WP_ROOT" config ||
      sk_fail 1 SK-E209 "The module's configuration could not be written." "The output is in $SK_LOG. The module must be installed (see the steps above) and the shop's database reachable."
  fi
  SK_WP_EXDIR=
  if [ "$PS_EXAMPLE" = yes ]; then
    SK_WP_EXDIR=$SK_HOME/examples/prestashop/$SK_WP_HOST
    sk_int_example_place prestashop "$SK_WP_EXDIR"
    sk_int_example_note "$SK_WP_EXDIR/urls" "protected https://$SK_WP_HOST/"
  fi
  SK_RECIPE_VERSION=${SK_PS_PKG##*/}
  SK_RECIPE_VERSION=${SK_RECIPE_VERSION#shakekee-}
  SK_RECIPE_VERSION=${SK_RECIPE_VERSION%.zip}
  sk_int_site_record prestashop - "${SK_WP_EXDIR:--}"
}

recipe_verify() {
  psr_bad=0
  ui_step_start module "The module is installed, switched on and has its settings"
  psr_s=$(ps_helper state 2>&1 </dev/null) || psr_s=
  case $psr_s in
    *installed=1*enabled=1*host=?*has_secret=1*) ui_step_ok ;;
    *installed=1*)
      ui_step_fail "the module is installed but not switched on or not fully set up"
      psr_bad=1
      ;;
    *)
      ui_step_fail "the module shakekee does not appear in PrestaShop"
      psr_bad=1
      ;;
  esac
  ui_step_start daemon "The daemon accepts the shared secret"
  sk_int_daemon_probe
  case $SK_INT_PROBE in
    ok) ui_step_ok "$SK_INT_URL" ;;
    secret)
      ui_step_fail "the daemon answers 401: it does not accept the shared secret${UI_NL}Fix: run this installer again."
      psr_bad=1
      ;;
    down) ui_step_warn "nothing answers at $SK_INT_URL (is the daemon running?)" ;;
    *) ui_step_warn "the daemon's answer was not understood" ;;
  esac
  if [ "$PS_EXAMPLE" = yes ] && [ -f "$SK_WP_EXDIR/verify.sh" ]; then
    ui_step_start example "The shop answers with the wall"
    psr_v=$(sh "$SK_WP_EXDIR/verify.sh" 2>&1 </dev/null) && psr_vc=0 || psr_vc=$?
    case $psr_vc in
      0) ui_step_ok ;;
      2) ui_step_warn "the shop could not be reached from here:${UI_NL}$psr_v" ;;
      *) ui_step_fail "$psr_v${UI_NL}Fix: see $SK_ORIGIN/help/SK-E214"; psr_bad=1 ;;
    esac
  fi
  if [ "$psr_bad" = 1 ]; then
    sk_fail 1 SK-E214 "The module is installed but a live check failed." "Read the failed line above, fix it and run the installer again."
  fi
}

recipe_handover() {
  ui_title "PrestaShop is set up for $SK_WP_HOST"
  ui_say "  Settings:  Modules > ShakeKee in the back office (address, host name, fail open or closed, timeout)"
  if [ "$PS_EXAMPLE" = yes ] && [ "$SK_DRY" != 1 ]; then
    ui_say "  Check it:  sh $SK_WP_EXDIR/verify.sh"
  fi
  ui_note "The daemon's protect list decides which pages are walled (the whole site by default). The web server must pass /.shakekee/ and /.well-known/shakekee/ to the daemon's public listener; the wall needs them."
}

# ---- the hooks ----

ps_status_one() {
  ui_step_start "site$SK_WP_N" "PrestaShop at $SK_WP_ROOT ($SK_WP_HOST)"
  pss_s=$(ps_helper state 2>&1 </dev/null) || pss_s=
  case $pss_s in
    *installed=1*) ;;
    *)
      ui_step_fail "the module shakekee does not appear in PrestaShop (or the shop cannot be read)${UI_NL}Fix: run the installer again."
      return 1
      ;;
  esac
  case $pss_s in
    *enabled=1*) ;;
    *)
      ui_step_fail "the module is installed but switched off${UI_NL}Fix: Modules > ShakeKee > switch \"Ask the daemon\" on, or run the installer again."
      return 1
      ;;
  esac
  case $pss_s in
    *has_secret=1*) ;;
    *)
      ui_step_fail "the module has no shared secret${UI_NL}Fix: run the installer again."
      return 1
      ;;
  esac
  sk_int_daemon_find
  sk_int_daemon_url
  SK_INT_SECRET=$(sk_int_daemon print-config --preflight-secret 2>/dev/null </dev/null) || SK_INT_SECRET=
  sk_int_daemon_probe
  case $SK_INT_PROBE in
    ok) ui_step_ok "module on; the daemon accepts the shared secret" ;;
    secret)
      ui_step_fail "the daemon does not accept the shared secret${UI_NL}Fix: run the installer again; it stores the daemon's secret in the shop."
      return 1
      ;;
    *) ui_step_warn "module on; the daemon at $SK_INT_URL did not answer" ;;
  esac
  return 0
}

recipe_status() {
  sk_int_each prestashop ps_status_one php "$PS_PATH"
}

recipe_doctor() {
  sk_int_each prestashop ps_status_one php "$PS_PATH"
}

ps_update_one() {
  sk_fetch_prestashop
  psu_unpack=$SK_TASK/unpacked
  rm -rf "$psu_unpack"
  mkdir -p "$psu_unpack"
  if command -v unzip >/dev/null 2>&1; then
    sk_step_run unpack "Unpacking the module" unzip -q "$SK_FILE" -d "$psu_unpack" || return 1
  else
    sk_step_run unpack "Unpacking the module" php -r '$z = new ZipArchive; if ($z->open($argv[1]) !== true || !$z->extractTo($argv[2])) { exit(1); }' "$SK_FILE" "$psu_unpack" || return 1
  fi
  chmod -R a+rX "$psu_unpack" 2>/dev/null || true
  sk_step_run place "Replacing modules/shakekee" sk_int_as sh -c 'rm -rf "$2/modules/shakekee" && cp -R "$1/shakekee" "$2/modules/shakekee"' sh "$psu_unpack" "$SK_WP_ROOT" || return 1
  sk_step_run upgrade "php bin/console prestashop:module upgrade shakekee" ps_console prestashop:module upgrade shakekee || return 1
  sk_integration_add prestashop "${SK_FILE##*/}"
  return 0
}

recipe_update() {
  sk_int_each prestashop ps_update_one php "$PS_PATH"
}

ps_uninstall_one() {
  ui_step_start "site$SK_WP_N" "Removing ShakeKee from $SK_WP_ROOT ($SK_WP_HOST)"
  ui_step_ok
  if [ "$SK_DRY" = 1 ]; then
    ui_say "[dry-run] php bin/console prestashop:module uninstall shakekee, delete $SK_WP_ROOT/modules/shakekee"
    return 0
  fi
  sk_log_open
  # the module's uninstall removes its configuration values
  sk_step_run uninstall "php bin/console prestashop:module uninstall shakekee" ps_console prestashop:module uninstall shakekee || return 1
  case $SK_WP_ROOT in
    '' | /) ;;
    *) sk_step_run delete "Deleting modules/shakekee" sk_int_as rm -rf "$SK_WP_ROOT/modules/shakekee" || return 1 ;;
  esac
  sk_unrecord "$SK_WP_RECKEY"
  return 0
}

recipe_uninstall() {
  sk_int_each prestashop ps_uninstall_one php "$PS_PATH"
}

sk_phases
}
sk_recipe_laravel() {
# ---- laravel: Composer package from a local artifact repository, then vendor:publish; the key in .shakekee/ ----
sk_app_init laravel Laravel account shakekee-example "composer|install Composer (https://getcomposer.org) and run this again${UI_NL}php|install PHP and run this again"
APP_PKG=Composer

usage() {
  cat <<'USAGE'
Usage: curl -fsSL https://get.shakekee.com/laravel | sh -s -- [options]
USAGE
  sk_app_usage
  cat <<'USAGE'
Needs Composer and PHP. The verified package is kept in DIR/shakekee-artifacts/, the key in DIR/.shakekee/ (private).
Safe to run again; an existing config/shakekee.php is not overwritten.
USAGE
  sk_usage_common
}

while [ $# -gt 0 ]; do
  case $1 in
    --path | --host | --daemon | --account | --no-example | --force) sk_app_option "$@"; shift "$SK_APP_USED" ;;
    --dry-run) SK_DRY=1 ;;
    --help | -h) usage; exit 0 ;;
    *) die "unknown option $1 (see --help)" ;;
  esac
  shift
done

app_extra() {
  sk_require_php_zip
  sk_app_not_root
}
app_find() {
  if [ ! -f artisan ] || [ ! -f composer.json ]; then
    sk_error SK-E301 "no Laravel project in $APP_ROOT (artisan and composer.json not found)." "Run it in the project root or pass --path DIR."
  fi
}
app_fetch() { sk_fetch_laravel; }
app_install() {
  sk_app_safety composer.json
  sk_app_keep "$SK_FILE" shakekee-artifacts 'shakekee-laravel-*.zip'
  sk_app_track_begin config/shakekee.php public/vendor resources/views/vendor
  sk_app_run repo "Telling Composer where the package is" composer --no-interaction config repositories.shakekee artifact ./shakekee-artifacts
  sk_app_run require "Installing shakekee/shakekee-laravel with Composer" composer --no-interaction require shakekee/shakekee-laravel
  sk_app_run publish "Publishing the configuration and the widget" php artisan vendor:publish --tag=shakekee
  sk_app_track_end
}
app_uninstall() {
  sk_app_try remove "Removing shakekee/shakekee-laravel with Composer" composer --no-interaction remove shakekee/shakekee-laravel
  if sk_app_only_ours shakekee-artifacts 'shakekee-laravel-*.zip'; then
    sk_app_try repo "Removing the package folder from composer.json" composer --no-interaction config --unset repositories.shakekee
  fi
}
app_loaded() { composer --no-interaction show shakekee/shakekee-laravel; }
app_entry() {
  if [ "$APP_EXAMPLE" = 1 ]; then
    ui_heading "Add this line to routes/web.php to load the example (an open and a protected page)"
    ui_copy_block "require base_path('shakekee-example/routes.php');"
  fi
  ui_heading "Protect your own routes with the middleware"
  ui_copy_block "Route::middleware('shakekee')->group(function () { /* your routes */ });"
}

sk_app_hooks
sk_phases
}
sk_recipe_django() {
# ---- django: pip installs the wheel; the key in .shakekee/ and a settings file the project includes ----
sk_app_init django Django account shakekee_example ''
# the addresses of another ShakeKee go into shakekee_settings.py, not into an environment file
APP_BASES=settings
APP_PKG=pip
DJ_PYTHON=
DJ_GIVEN=

usage() {
  cat <<'USAGE'
Usage: curl -fsSL https://get.shakekee.com/django | sh -s -- [options]
  --python PATH   the Python to install into (default: the active or the project's virtualenv, else python3; you are asked
                  when there is more than one)
USAGE
  sk_app_usage
  cat <<'USAGE'
Safe to run again. Django and cryptography are installed from PyPI as dependencies when missing. The key goes to
DIR/.shakekee/ (private); DIR/shakekee_settings.py is the file your settings.py includes.
USAGE
  sk_usage_common
}

while [ $# -gt 0 ]; do
  case $1 in
    --python) sk_value "$1" $#; DJ_GIVEN=$2; shift ;;
    --path | --host | --daemon | --account | --no-example | --force) sk_app_option "$@"; shift "$SK_APP_USED" ;;
    --dry-run) SK_DRY=1 ;;
    --help | -h) usage; exit 0 ;;
    *) die "unknown option $1 (see --help)" ;;
  esac
  shift
done

# dj_check PYTHON: it runs, has pip and is new enough.
dj_check() {
  command -v "$1" >/dev/null 2>&1 || sk_error SK-E302 "Python is not installed ($1 not found). Install it, or pass --python PATH."
  "$1" -m pip --version >/dev/null 2>&1 || sk_error SK-E302 "pip is not available for $1." "python3 -m ensurepip, or install python3-pip."
  "$1" -c 'import sys; sys.exit(0 if sys.version_info >= (3, 10) else 1)' || sk_error SK-E302 "ShakeKee for Django needs Python 3.10 or newer ($1 is older)." "Use --python with a newer Python, or a virtualenv made with one."
}

# the interpreters that could take the package: --python, else the active virtualenv, the project's own, python3 on the PATH
app_extra() {
  DJ_CANDS=
  if [ -n "$DJ_GIVEN" ]; then
    DJ_PYTHON=$DJ_GIVEN
  elif [ -n "$DJ_PYTHON" ]; then
    : # --update: the one that was recorded
  else
    for dj_p in "${VIRTUAL_ENV:-/nonexistent}/bin/python|the active virtualenv" "$APP_ROOT/.venv/bin/python|the virtualenv .venv of the project" "$APP_ROOT/venv/bin/python|the virtualenv venv of the project" "$APP_ROOT/env/bin/python|the virtualenv env of the project"; do
      if [ -x "${dj_p%%|*}" ]; then
        DJ_CANDS="$DJ_CANDS${DJ_CANDS:+$UI_NL}$dj_p"
      fi
    done
    for dj_p in python3 python; do
      if command -v "$dj_p" >/dev/null 2>&1; then
        DJ_CANDS="$DJ_CANDS${DJ_CANDS:+$UI_NL}$(command -v "$dj_p")|$dj_p on the PATH"
        break
      fi
    done
    DJ_PYTHON=${DJ_CANDS%%"|"*}
    if [ -z "$DJ_PYTHON" ]; then
      sk_error SK-E302 "Python is not installed (python3 not found). Install it, or pass --python PATH."
    fi
  fi
  dj_check "$DJ_PYTHON"
}
app_find() {
  if [ ! -f manage.py ]; then
    sk_error SK-E301 "no Django project in $APP_ROOT (manage.py not found)." "Run it in the folder of manage.py or pass --path DIR."
  fi
}
# more than one interpreter: the person says which (the first is the default; --yes takes it)
app_choose() {
  case $DJ_CANDS in
    *"$UI_NL"*)
      set --
      dj_n=0
      while IFS='|' read -r dj_path dj_label; do
        dj_n=$((dj_n + 1))
        if [ "$dj_n" = 1 ]; then dj_pre='+'; else dj_pre=; fi
        set -- "$@" "$dj_pre$dj_n|$dj_label|$dj_path"
      done <<DJ_LIST
$DJ_CANDS
DJ_LIST
      ui_menu_single "Which Python should the package be installed into?" "$@"
      DJ_PYTHON=$(printf '%s\n' "$DJ_CANDS" | sed -n "${UI_REPLY}p")
      DJ_PYTHON=${DJ_PYTHON%%"|"*}
      dj_check "$DJ_PYTHON"
      ;;
  esac
}
app_fetch() { sk_fetch_django; }
app_install() {
  sk_app_run pip "Installing the package with pip ($DJ_PYTHON)" "$DJ_PYTHON" -m pip install --disable-pip-version-check "$SK_FILE"
}
app_uninstall() {
  sk_app_try pip "Removing the package with pip" "$DJ_PYTHON" -m pip uninstall -y shakekee-django
}
app_loaded() { "$DJ_PYTHON" -c 'import shakekee_django'; }
app_save() { sk_record django.python "$DJ_PYTHON"; }
app_enter() {
  if sk_rec_get django.python; then DJ_PYTHON=$SK_RV; fi
}
# the file settings.py includes: it adds the app, the middleware and the example's protected path
app_config() {
  if [ "$APP_EXAMPLE" = 1 ]; then dj_prot="'/shakekee-example/protected'"; else dj_prot=; fi
  sk_account_bases
  dj_bases=
  if [ "$APP_MODE" = account ]; then
    if [ -n "$SK_BASE_API" ]; then dj_bases="    settings.setdefault('SHAKEKEE_API_BASE', '$SK_BASE_API')"; fi
    if [ -n "$SK_BASE_CDN" ]; then dj_bases="${dj_bases:+$dj_bases$UI_NL}    settings.setdefault('SHAKEKEE_CDN_URL', '$SK_BASE_CDN')"; fi
  fi
  sk_app_put "$APP_ROOT/shakekee_settings.py" 644 <<DJ_SETTINGS
# ShakeKee settings, generated by $SK_ORIGIN/django. Add this line at the end of your settings.py:
#     import shakekee_settings; shakekee_settings.apply(globals())
# The key is read from .shakekee/ in the project (SHAKEKEE_STATE_DIR changes that).
PROTECT = [$dj_prot]


def _add(settings, name, value):
    current = list(settings.get(name, []))
    if value not in current:
        current.append(value)
    settings[name] = current


def apply(settings):
    _add(settings, 'INSTALLED_APPS', 'shakekee_django')
    _add(settings, 'MIDDLEWARE', 'shakekee_django.middleware.ShakeKeeMiddleware')
    for path in PROTECT:
        _add(settings, 'SHAKEKEE_PROTECT_PATHS', path)${dj_bases:+$UI_NL$dj_bases}
DJ_SETTINGS
}
app_entry() {
  ui_heading "Add this line at the end of your settings.py"
  ui_copy_block "import shakekee_settings; shakekee_settings.apply(globals())"
  ui_note "It adds the app, the middleware and the example's protected page."
  if [ "$APP_EXAMPLE" = 1 ]; then
    ui_heading "Add this to the urlpatterns of your urls.py, for the example"
    ui_copy_block "path('shakekee-example/', include('shakekee_example.urls')),"
  fi
  ui_note "Protect your own pages with SHAKEKEE_PROTECT_PATHS = ['/members'] in settings.py."
}

sk_app_hooks
sk_phases
}
sk_recipe_nodejs() {
# ---- nodejs: npm installs the package tarball; the key or the daemon's secret in .shakekee/ ----
sk_app_init nodejs Node.js "account daemon" shakekee-example "node|install Node.js 18.14 or newer${UI_NL}npm|install npm"
APP_PKG=npm

usage() {
  cat <<'USAGE'
Usage: curl -fsSL https://get.shakekee.com/nodejs | sh -s -- [options]
USAGE
  sk_app_usage
  cat <<'USAGE'
The verified tarball is kept in DIR/shakekee-artifacts/ (package.json refers to it), the settings in DIR/.shakekee/
(private). Safe to run again.
USAGE
  sk_usage_common
}

while [ $# -gt 0 ]; do
  case $1 in
    --path | --host | --daemon | --account | --no-example | --force) sk_app_option "$@"; shift "$SK_APP_USED" ;;
    --dry-run) SK_DRY=1 ;;
    --help | -h) usage; exit 0 ;;
    *) die "unknown option $1 (see --help)" ;;
  esac
  shift
done

app_extra() {
  node -e 'const [a, b] = process.versions.node.split(".").map(Number); process.exit(a > 18 || (a === 18 && b >= 14) ? 0 : 1)' ||
    sk_error SK-E302 "ShakeKee for Node.js needs Node.js 18.14 or newer (this is $(node --version))." "Install a newer Node.js."
}
app_find() {
  if [ ! -f package.json ]; then
    sk_error SK-E301 "no package.json in $APP_ROOT." "Run it in your project (npm init creates one) or pass --path DIR."
  fi
}
app_fetch() { sk_fetch_nodejs; }
app_install() {
  sk_app_safety package.json
  sk_app_keep "$SK_FILE" shakekee-artifacts 'shakekee-nodejs-*.tgz'
  sk_app_run npm "Installing the package with npm" npm install --no-audit --no-fund "./shakekee-artifacts/${SK_KEPT##*/}"
}
app_uninstall() {
  sk_app_try npm "Removing shakekee-nodejs with npm" npm uninstall --no-audit --no-fund shakekee-nodejs
}
app_loaded() { node -e 'require("shakekee-nodejs")'; }
# the daemon's address as the SDK wants it, written in two parts so that no plain-http address is spelled out in this script
app_env_extra() { printf 'SHAKEKEE_PREFLIGHT_URL=%s//127.0.0.1:7878' 'http:'; }
app_entry() {
  ui_heading "Add this to your Express app, before the routes it should guard"
  if [ "$APP_MODE" = daemon ]; then
    ui_copy_block "app.use(require('shakekee-nodejs/shakekeed').shakekeed({" \
      "  url: process.env.SHAKEKEE_PREFLIGHT_URL," \
      "  secret: process.env.SHAKEKEE_PREFLIGHT_SECRET," \
      "  host: process.env.SHAKEKEE_HOST," \
      "}));"
    ui_note "The daemon decides which paths are protected; the application does not list them."
  else
    ui_copy_block "const shakekee = require('shakekee-nodejs'), config = shakekee.loadConfig();" \
      "app.use(shakekee.middleware({ config, protectPaths: ['/members'] }));" \
      "app.use(shakekee.routes(config));"
    ui_note "Start the application from $APP_ROOT, or set SHAKEKEE_STATE_DIR=$APP_ROOT/.shakekee, so that it finds its key."
  fi
  if [ "$APP_EXAMPLE" = 1 ]; then
    ui_note "The example is a small server of its own: sh $APP_EXDIR/verify.sh --serve 3099 (needs express in the project)."
  fi
}

sk_app_hooks
sk_phases
}
sk_recipe_cloudflare() {
# ---- cloudflare: unpacks the Worker into ./shakekee-cloudflare, writes its configuration and stores its secrets ----
# The Worker never provisions and never holds the raw API key: it gets a credential id, the API key id and the
# private key (Worker secrets, and .dev.vars for `wrangler dev`). Phases, hooks and the files it writes are in
# docs/recipes-platforms.md.
CF_DIR=shakekee-cloudflare
CF_DIR_GIVEN=0
CF_INSTALL=1
CF_HOST=
CF_ZONE=
CF_WRANGLER=1
CF_SECRETS_ENV="SHAKEKEE_PRIVATE_KEY SHAKEKEE_CREDENTIAL_ID SHAKEKEE_API_KEY_ID"

usage() {
  cat <<'USAGE'
Usage: curl -fsSL https://get.shakekee.com/cloudflare | sh -s -- [options]
  --dir DIR       where to unpack the Worker (default: ./shakekee-cloudflare)
  --host HOST     the site the Worker protects, for example www.example.com (or SHAKEKEE_HOST)
  --zone ZONE     the Cloudflare zone of that site (default: the last two labels of the host)
  --no-install    do not run npm install
  --no-wrangler   do not talk to Cloudflare: no KV namespace, no secrets (the commands are printed)
  --account       use a ShakeKee account key (the only way here: a Worker cannot reach a local daemon)
  --dry-run       download and verify, change nothing
Deploying is yours: the installer writes wrangler.toml and the secrets, you run npx wrangler deploy. Safe to run again with the
same version; a directory that holds something else is not touched. The key comes from the dashboard (the installer can create
it), or from SHAKEKEE_API_KEY_ID, SHAKEKEE_CREDENTIAL_ID and SHAKEKEE_PRIVATE_KEY_B64 when it runs without a terminal.
Also: --status, --update, --doctor, --uninstall.
USAGE
  sk_usage_common
}

while [ $# -gt 0 ]; do
  case $1 in
    --dir) sk_value "$1" $#; CF_DIR=$2; CF_DIR_GIVEN=1; shift ;;
    --host) sk_value "$1" $#; CF_HOST=$2; shift ;;
    --zone) sk_value "$1" $#; CF_ZONE=$2; shift ;;
    --no-install) CF_INSTALL=0 ;;
    --no-wrangler) CF_WRANGLER=0 ;;
    --account) ;;
    --daemon) sk_error SK-E403 "A Cloudflare Worker runs at Cloudflare and cannot reach a ShakeKee daemon on your machine." "Leave out --daemon: the Worker signs in with a ShakeKee account key." ;;
    --dry-run) SK_DRY=1 ;;
    --help | -h) usage; exit 0 ;;
    *) die "unknown option $1 (see --help)" ;;
  esac
  shift
done

# cf_host_ok HOST: a host name of letters, digits, dots and hyphens with at least one dot.
cf_host_ok() {
  case $1 in
    '' | -* | .* | *. | *..* | *[!A-Za-z0-9.-]*) return 1 ;;
    *.*) return 0 ;;
  esac
  return 1
}

# cf_zone_of HOST: CF_ZONE_GUESS is the registered domain, as far as the name tells (the last two labels, three
# for names like example.co.uk).
cf_zone_of() {
  cf_z=$1
  cf_n=$(printf '%s\n' "$cf_z" | tr -cd . | wc -c)
  cf_n=$((cf_n + 0))
  if [ "$cf_n" -le 1 ]; then
    CF_ZONE_GUESS=$cf_z
    return 0
  fi
  cf_last=${cf_z##*.}
  cf_rest=${cf_z%.*}
  cf_mid=${cf_rest##*.}
  case $cf_mid in
    co | com | org | net | gov | edu | ac)
      if [ ${#cf_last} = 2 ] && [ "$cf_n" -ge 2 ]; then
        cf_rest2=${cf_rest%.*}
        CF_ZONE_GUESS=${cf_rest2##*.}.$cf_mid.$cf_last
        return 0
      fi
      ;;
  esac
  CF_ZONE_GUESS=$cf_mid.$cf_last
}

# cf_route_host: the host of the route in an existing wrangler.toml (not the placeholders), in CF_ROUTE_HOST.
cf_route_host() {
  CF_ROUTE_HOST=
  if [ -f "$CF_DIR/wrangler.toml" ]; then
    cf_rh=$(sed -n 's/^pattern[ ]*=[ ]*"\([^/"]*\)\/.*$/\1/p' "$CF_DIR/wrangler.toml" | head -n 1)
    case $cf_rh in
      '' | example.com | localhost) ;;
      *) CF_ROUTE_HOST=$cf_rh ;;
    esac
  fi
}

# cf_same: the directory holds this version (all of it, or the package.json and the source of an installed one).
cf_same() {
  if diff -r "$CF_SRC" "$CF_DIR" >/dev/null 2>&1; then
    return 0
  fi
  [ -f "$CF_DIR/package.json" ] && diff -r "$CF_SRC/src" "$CF_DIR/src" >/dev/null 2>&1
}

# cf_fetch: downloads and checks the Worker, unpacks it to CF_SRC and sets CF_SHA and the recorded version.
cf_fetch() {
  need tar "install tar"
  sk_fetch_cloudflare
  CF_SHA=$(sk_sha256 "$SK_FILE")
  cf_vf=${SK_FILE##*/}
  case $cf_vf in
    shakekee-cloudflare-*.tgz)
      cf_vf=${cf_vf#shakekee-cloudflare-}
      SK_RECIPE_VERSION=${cf_vf%.tgz}
      ;;
  esac
  sk_extract_tgz "$SK_FILE" "$SK_TASK/unpacked"
  CF_SRC=$SK_TASK/unpacked/shakekee-cloudflare
  [ -f "$CF_SRC/package.json" ] || die "the package does not contain shakekee-cloudflare/package.json"
}

# cf_load: the directory and host recorded by the install (an explicit --dir or --host wins).
cf_load() {
  if [ "$CF_DIR_GIVEN" = 0 ] && sk_rec_get cloudflare.dir; then
    CF_DIR=$SK_RV
  fi
  if [ -z "$CF_HOST" ] && sk_rec_get cloudflare.host; then
    CF_HOST=$SK_RV
  fi
}

# cf_created_hash FILE: CF_CH is the hash recorded when ShakeKee wrote FILE (empty when it did not).
cf_created_hash() {
  CF_CH=
  sk_rec_load
  if [ -n "$SK_REC" ]; then
    while IFS=$UI_TAB read -r cf_hk cf_hv; do
      case $cf_hk in
        created.*)
          sk__unjq "$cf_hv"
          sk__fields "$SK_RV"
          if [ "$SK_F1" = "$1" ]; then
            CF_CH=$SK_F2
          fi
          ;;
      esac
    done <<REC
$SK_REC
REC
  fi
}

# cf_record_tree DIR: records DIR and everything in it as created by this installer (parents first). Not in a
# pipe: the record is cached in this shell.
cf_record_tree() {
  find "$1" \( -type d -o -type f \) -print >"$SK_TASK/cf-tree"
  while IFS= read -r cf_p; do
    sk_created "$cf_p"
  done <"$SK_TASK/cf-tree"
}

# cf_mode_private FILE: succeeds when only the owner can read FILE.
cf_mode_private() {
  cf_ls=$(ls -ld "$1" 2>/dev/null) || return 1
  case ${cf_ls%% *} in
    -rw-------*) return 0 ;;
  esac
  return 1
}

# cf_wr ARGS...: wrangler, in the Worker's directory: the installed one first, then one on the PATH.
cf_wr() {
  if [ -x "$CF_DIR/node_modules/.bin/wrangler" ]; then
    (cd "$CF_DIR" && ./node_modules/.bin/wrangler "$@")
  elif command -v wrangler >/dev/null 2>&1; then
    (cd "$CF_DIR" && wrangler "$@")
  else
    return 127
  fi
}

# cf_wr_ready: wrangler is there and knows a Cloudflare account.
cf_wr_ready() {
  if [ "$CF_WRANGLER" != 1 ] || [ ! -d "$CF_DIR" ]; then
    return 1
  fi
  cf_wo=$(cf_wr whoami 2>&1 </dev/null) || return 1
  case $cf_wo in
    *[Nn]ot\ authenticated* | *[Nn]ot\ logged\ in*) return 1 ;;
  esac
  return 0
}

# cf_put_secret NAME: stores one of the three values as a Worker secret. The value goes in on the standard
# input of wrangler; it is never an argument.
cf_put_secret() {
  case $1 in
    SHAKEKEE_PRIVATE_KEY) cf_sv=$SK_PRIVATE_KEY_B64 ;;
    SHAKEKEE_CREDENTIAL_ID) cf_sv=$SK_CREDENTIAL_ID ;;
    SHAKEKEE_API_KEY_ID) cf_sv=$SK_API_KEY_ID ;;
    *) return 1 ;;
  esac
  printf '%s\n' "$cf_sv" | cf_wr secret put "$1"
}

# cf_kv_create: makes the KV namespace for the session and leaves its id in CF_KVID.
cf_kv_create() {
  CF_KVID=
  cf_ko=$(cf_wr kv namespace create KV 2>&1 </dev/null) || {
    printf '%s\n' "$cf_ko"
    return 1
  }
  printf '%s\n' "$cf_ko"
  CF_KVID=$(printf '%s\n' "$cf_ko" | sed -n 's/.*id[^0-9a-fA-F]*\([0-9a-fA-F]\{32\}\).*/\1/p' | head -n 1)
  [ -n "$CF_KVID" ]
}

# cf_env_set FILE: sets the three SHAKEKEE_ values in a dotenv file (.dev.vars), keeping every other line. When this
# ShakeKee is not the production one (SK_BASE_API, SK_BASE_CDN, see sk_account_bases) its addresses are set too. The
# edit is backed up and recorded; the new file is private.
cf_env_set() {
  cf_ef=$1
  sk_edit_begin "$cf_ef"
  if [ "$SK_DRY" = 1 ]; then
    return 0
  fi
  cf_ew=$(sk_edit_path "$cf_ef")
  chmod 600 "$cf_ew"
  if [ -s "$cf_ef" ]; then
    : >"$cf_ew"
    while IFS= read -r cf_l || [ -n "$cf_l" ]; do
      case $cf_l in
        SHAKEKEE_PRIVATE_KEY=* | SHAKEKEE_PRIVATE_KEY_B64=* | SHAKEKEE_CREDENTIAL_ID=* | SHAKEKEE_API_KEY_ID=*) ;;
        SHAKEKEE_API_BASE=*) if [ -z "$SK_BASE_API" ]; then printf '%s\n' "$cf_l" >>"$cf_ew"; fi ;;
        SHAKEKEE_CDN_URL=*) if [ -z "$SK_BASE_CDN" ]; then printf '%s\n' "$cf_l" >>"$cf_ew"; fi ;;
        *) printf '%s\n' "$cf_l" >>"$cf_ew" ;;
      esac
    done <"$cf_ef"
  fi
  cat >>"$cf_ew" <<EOF
SHAKEKEE_PRIVATE_KEY=$SK_PRIVATE_KEY_B64
SHAKEKEE_CREDENTIAL_ID=$SK_CREDENTIAL_ID
SHAKEKEE_API_KEY_ID=$SK_API_KEY_ID
EOF
  if [ -n "$SK_BASE_API" ]; then printf 'SHAKEKEE_API_BASE=%s\n' "$SK_BASE_API" >>"$cf_ew"; fi
  if [ -n "$SK_BASE_CDN" ]; then printf 'SHAKEKEE_CDN_URL=%s\n' "$SK_BASE_CDN" >>"$cf_ew"; fi
  sk_edit_commit "$cf_ef"
}

# cf_write_toml: wrangler.toml from the example, with the route of the site (and the KV namespace if there is one) and,
# when this ShakeKee is not the production one, its API and CDN addresses in [vars] (the Worker reads them there; they
# are not secrets, and a secret of the same name would clash with them).
cf_write_toml() {
  cf_t=$CF_DIR/wrangler.toml
  if [ -e "$cf_t" ] || [ -L "$cf_t" ]; then
    ui_say "$cf_t exists and is left as it is."
    cf_route_host
    if [ "$CF_ROUTE_HOST" != "$CF_HOST" ]; then
      ui_warn "The route in $cf_t is not $CF_HOST/*: check the [[routes]] block."
    fi
    if [ -n "$SK_BASE_API" ] && ! grep -F SHAKEKEE_API_BASE "$cf_t" 2>/dev/null | grep -Fq "\"$SK_BASE_API\""; then
      ui_warn "This ShakeKee is not the production one: set SHAKEKEE_API_BASE = \"$SK_BASE_API\" in the [vars] of $cf_t."
    fi
    if [ -n "$SK_BASE_CDN" ] && ! grep -F SHAKEKEE_CDN_URL "$cf_t" 2>/dev/null | grep -Fq "\"$SK_BASE_CDN\""; then
      ui_warn "This ShakeKee is not the production one: set SHAKEKEE_CDN_URL = \"$SK_BASE_CDN\" in the [vars] of $cf_t."
    fi
    return 0
  fi
  if [ "$SK_DRY" = 1 ]; then
    ui_say "[dry-run] write $cf_t for $CF_HOST/*"
    return 0
  fi
  sed -e "s|^pattern .*|pattern   = \"$CF_HOST/*\"|" -e "s|^zone_name .*|zone_name = \"$CF_ZONE\"|" \
    -e "s|REPLACE_WITH_YOUR_KV_NAMESPACE_ID|${CF_KVID:-REPLACE_WITH_YOUR_KV_NAMESPACE_ID}|" \
    -e "s|^SHAKEKEE_API_BASE .*|SHAKEKEE_API_BASE = \"${SK_BASE_API:-https://api.shakekee.com}\"|" \
    -e "s|^SHAKEKEE_CDN_URL .*|SHAKEKEE_CDN_URL = \"${SK_BASE_CDN:-https://cdn.shakekee.com}\"|" "$CF_DIR/wrangler.toml.example" >"$cf_t.shakekee-new" && mv -f "$cf_t.shakekee-new" "$cf_t" ||
    sk_fail 1 SK-E010 "Cannot write $cf_t." "Check the permissions of $CF_DIR."
  sk_created "$cf_t"
  ui_say "Wrote $cf_t for $CF_HOST/* (zone $CF_ZONE)."
}

# cf_write_ignore: .gitignore for the files that hold secrets, when the directory has none.
cf_write_ignore() {
  if [ -e "$CF_DIR/.gitignore" ] || [ "$SK_DRY" = 1 ]; then
    return 0
  fi
  printf '%s\n' .dev.vars node_modules/ .wrangler/ >"$CF_DIR/.gitignore"
  sk_created "$CF_DIR/.gitignore"
}

# cf_record_example DIR: records the example files that sk_example_install wrote, parents first.
cf_record_example() {
  if [ "$SK_DRY" = 1 ]; then
    return 0
  fi
  sk_created "$1"
  cf_el=$(sk_example_list_cloudflare)
  while IFS='|' read -r cf_ep cf_em; do
    cf_ed=$1
    cf_er=$cf_ep
    while :; do
      case $cf_er in
        */*)
          cf_ed=$cf_ed/${cf_er%%/*}
          cf_er=${cf_er#*/}
          sk_created "$cf_ed"
          ;;
        *) break ;;
      esac
    done
    sk_created "$1/$cf_ep"
  done <<EXAMPLE_LIST
$cf_el
EXAMPLE_LIST
}

recipe_detect() {
  need tar "install tar"
  if [ "$CF_INSTALL" = 1 ]; then
    need npm "install Node.js and npm, or pass --no-install"
  fi
}

recipe_choose() {
  if [ -z "$CF_HOST" ]; then
    CF_HOST=${SHAKEKEE_HOST:-}
  fi
  if [ -z "$CF_HOST" ]; then
    cf_route_host
    CF_HOST=$CF_ROUTE_HOST
  fi
  if [ -z "$CF_HOST" ]; then
    if [ "$SK_YES" = 1 ] || [ "$UI_TTY" = 0 ] || [ "$UI_MODE" = json ]; then
      sk_error SK-E401 "The Worker needs the host name of the site it protects, and none was given." "Run again with --host HOST (for example --host www.example.com), or set SHAKEKEE_HOST."
    fi
    ui_ask "Which site does the Worker protect? (host name, for example www.example.com)"
    CF_HOST=$UI_REPLY
  fi
  if ! cf_host_ok "$CF_HOST"; then
    sk_error SK-E401 "\"$CF_HOST\" is not a host name the Worker can protect." "Give the host name only, for example www.example.com: letters, digits, dots and hyphens."
  fi
  if [ -z "$CF_ZONE" ]; then
    cf_zone_of "$CF_HOST"
    CF_ZONE=$CF_ZONE_GUESS
  fi
  if ! cf_host_ok "$CF_ZONE"; then
    sk_error SK-E401 "\"$CF_ZONE\" is not a zone name." "Give the domain that is registered on Cloudflare, for example example.com (--zone)."
  fi
}

# Download and verify, and look at the directory; nothing is changed.
recipe_check() {
  cf_fetch
  CF_STATE=new
  if [ -e "$CF_DIR" ]; then
    if cf_same; then
      CF_STATE=same
    else
      die "$CF_DIR exists and is different (local changes?). Move it away or choose another --dir."
    fi
  fi
}

recipe_plan() {
  sk_plan_add "Get a ShakeKee key for $CF_HOST (the Worker never gets the raw API key)"
  if [ "$CF_STATE" = new ]; then
    sk_plan_add "Unpack the Worker into $CF_DIR"
  else
    sk_plan_add "Keep $CF_DIR: it already holds this version"
  fi
  if [ "$CF_INSTALL" = 1 ]; then
    sk_plan_add "Run npm install in $CF_DIR"
  fi
  sk_plan_add "Write wrangler.toml for $CF_HOST/* (zone $CF_ZONE) if there is none"
  sk_plan_add "Write $CF_DIR/.dev.vars with the credential (readable by you only)"
  sk_account_bases
  if [ -n "$SK_BASE_API$SK_BASE_CDN" ]; then
    sk_plan_add "Write the API and CDN addresses of this ShakeKee (not the production ones) into wrangler.toml [vars] and .dev.vars"
  fi
  sk_plan_add "Write the example pages and verify.sh into $CF_DIR/example"
  if [ "$CF_WRANGLER" = 1 ]; then
    sk_plan_add "With wrangler, if logged in: make the KV namespace, store the 3 secrets"
  fi
  sk_plan_add "Deploy nothing: that is your step"
}

recipe_apply() {
  sk_account_credentials "$CF_HOST"
  sk_account_no_raw_key
  sk_account_bases
  if [ "$SK_DRY" = 0 ] && { [ -z "$SK_CREDENTIAL_ID" ] || [ -z "$SK_PRIVATE_KEY_B64" ]; }; then
    sk_error SK-E402 "The Worker needs a credential id and its private key, and only an API key was given." "A Worker never makes its own credential. Create one in the dashboard (API keys, then credentials), or run this in a terminal and choose \"Create a key for me\"."
  fi
  if [ "$CF_STATE" = new ]; then
    sk_do "unpack the Worker into $CF_DIR" cp -R "$CF_SRC" "$CF_DIR"
    if [ "$SK_DRY" = 0 ]; then
      cf_record_tree "$CF_DIR"
    fi
  else
    say "$CF_DIR already holds this version."
  fi
  CF_NPM=0
  if [ "$CF_INSTALL" = 1 ]; then
    sk_step_run npm "Installing the Worker's dependencies" cf_npm_install || die "npm install failed in $CF_DIR"
    CF_NPM=1
  fi
  CF_KVID=
  if [ "$CF_WRANGLER" = 1 ] && [ "$SK_DRY" = 0 ] && [ ! -e "$CF_DIR/wrangler.toml" ] && cf_wr_ready; then
    sk_step_run kv "Creating the KV namespace for the session" cf_kv_create || ui_warn "The KV namespace was not created; wrangler.toml keeps the placeholder id (see $SK_ORIGIN/help/SK-E404)."
  fi
  cf_write_toml
  cf_write_ignore
  ui_step_start devvars "Writing $CF_DIR/.dev.vars"
  cf_env_set "$CF_DIR/.dev.vars"
  if [ "$SK_DRY" = 1 ] || cf_mode_private "$CF_DIR/.dev.vars"; then
    ui_step_ok
  else
    ui_step_warn "the file is readable by others (it was there before). Fix: chmod 600 $CF_DIR/.dev.vars"
  fi
  sk_example_install cloudflare "$CF_DIR/example"
  cf_record_example "$CF_DIR/example"
  CF_SECRETS=manual
  cf_store_secrets
  if [ "$SK_DRY" = 0 ]; then
    cf_abs=$CF_DIR
    case $cf_abs in
      /*) ;;
      *) cf_abs=$PWD/${cf_abs#./} ;;
    esac
    sk_record cloudflare.dir "$cf_abs"
    sk_record cloudflare.host "$CF_HOST"
    sk_record cloudflare.zone "$CF_ZONE"
    sk_record cloudflare.artifact "$CF_SHA"
    sk_record cloudflare.npm "$CF_NPM"
    sk_record cloudflare.secrets "$CF_SECRETS"
    if ! sk_rec_get cloudflare.remains; then
      sk_remains "Cloudflare: the Worker secrets SHAKEKEE_PRIVATE_KEY, SHAKEKEE_CREDENTIAL_ID and SHAKEKEE_API_KEY_ID (wrangler secret delete NAME), the Worker itself if you deployed it, and its KV namespace."
      sk_remains "The ShakeKee API key and credential of $CF_HOST in the dashboard, and the file $SK_HOME/credentials/$CF_HOST.json (re-run with --uninstall to be asked)."
      sk_record cloudflare.remains 1
    fi
  fi
}

cf_npm_install() {
  (cd "$CF_DIR" && npm install --no-audit --no-fund)
}

# cf_store_secrets: the three values into the Worker's secret store, when wrangler is ready; else says how.
cf_store_secrets() {
  if [ "$CF_WRANGLER" != 1 ]; then
    return 0
  fi
  if [ "$SK_DRY" = 1 ]; then
    ui_say "[dry-run] store the secrets with wrangler secret put (if wrangler is ready)"
    return 0
  fi
  if ! cf_wr_ready; then
    ui_step_start wrangler "Storing the secrets with wrangler"
    ui_step_warn "wrangler is not installed or not logged in to Cloudflare, so nothing was sent. The commands are at the end."
    return 0
  fi
  cf_bad=0
  for cf_s in $CF_SECRETS_ENV; do
    sk_step_run "secret-$cf_s" "Storing $cf_s as a Worker secret" cf_put_secret "$cf_s" || {
      cf_bad=1
      break
    }
  done
  if [ "$cf_bad" = 0 ]; then
    CF_SECRETS=stored
  else
    ui_warn "The secrets were not stored (see $SK_ORIGIN/help/SK-E404). The commands are at the end."
  fi
}

# Looks at the result without a network: the files, the permissions, and wrangler's own bundling.
recipe_verify() {
  ui_step_start files "The Worker's files and secrets file"
  cf_miss=
  for cf_f in src/index.ts wrangler.toml .dev.vars example/verify.sh; do
    if [ ! -f "$CF_DIR/$cf_f" ]; then
      cf_miss="$cf_miss $cf_f"
    fi
  done
  if [ -n "$cf_miss" ]; then
    ui_step_fail "missing in $CF_DIR:$cf_miss"
  elif ! cf_mode_private "$CF_DIR/.dev.vars"; then
    ui_step_warn "$CF_DIR/.dev.vars can be read by others. Fix: chmod 600 $CF_DIR/.dev.vars"
  else
    ui_step_ok
  fi
  if [ "$CF_INSTALL" = 1 ] && { [ -x "$CF_DIR/node_modules/.bin/wrangler" ] || command -v wrangler >/dev/null 2>&1; }; then
    sk_step_run bundle "Letting wrangler bundle the Worker (nothing is deployed)" cf_wr deploy --dry-run --outdir "$SK_TASK/wrangler-bundle" ||
      ui_note "wrangler could not bundle the Worker; the log says why. Check wrangler.toml, then run npx wrangler deploy --dry-run."
  fi
}

recipe_handover() {
  if [ "$SK_DRY" = 1 ]; then
    ui_note "Dry run: nothing was changed."
    return 0
  fi
  ui_title "What is left"
  cf_n=1
  if [ "$CF_SECRETS" != stored ]; then
    ui_say "  $cf_n. Store the secrets in Cloudflare (log in first with: npx wrangler login). When asked for a value, paste the one"
    ui_say "     of the same name from $CF_DIR/.dev.vars:"
    for cf_s in $CF_SECRETS_ENV; do
      ui_copy_hint "cd $CF_DIR && npx wrangler secret put $cf_s"
    done
    cf_n=$((cf_n + 1))
  fi
  if grep -q REPLACE_WITH_YOUR_KV_NAMESPACE_ID "$CF_DIR/wrangler.toml" 2>/dev/null; then
    ui_say "  $cf_n. Create the KV namespace and put its id into wrangler.toml (without it every request signs in again):"
    ui_copy_hint "cd $CF_DIR && npx wrangler kv namespace create KV"
    cf_n=$((cf_n + 1))
  fi
  ui_say "  $cf_n. Check the route and the paths to protect in $CF_DIR/wrangler.toml (SHAKEKEE_PROTECT is /protected), then deploy:"
  ui_copy_hint "cd $CF_DIR && npx wrangler deploy"
  cf_n=$((cf_n + 1))
  ui_say "  $cf_n. Put $CF_DIR/example/open and $CF_DIR/example/protected on your site, then prove the Worker works:"
  ui_copy_hint "sh $CF_DIR/example/verify.sh https://$CF_HOST"
  ui_note "The Worker has no raw API key. The key and the private key are in $SK_HOME/credentials/$CF_HOST.json (readable by you only)."
}

# ---- --status, --doctor, --update, --uninstall ----

recipe_status() {
  cf_load
  cf_bad=0
  ui_step_start dir "The Worker is in $CF_DIR"
  if [ -f "$CF_DIR/package.json" ] && [ -f "$CF_DIR/src/index.ts" ]; then
    ui_step_ok
  else
    ui_step_fail "the Worker's files are not there. Fix: run the installer again."
    return 1
  fi
  ui_step_start toml "wrangler.toml routes ${CF_HOST:-the site}"
  cf_route_host
  if [ ! -f "$CF_DIR/wrangler.toml" ]; then
    ui_step_fail "there is no wrangler.toml. Fix: copy wrangler.toml.example and set the route."
    cf_bad=1
  elif [ -n "$CF_HOST" ] && [ "$CF_ROUTE_HOST" != "$CF_HOST" ]; then
    ui_step_warn "the route is ${CF_ROUTE_HOST:-not set}, not $CF_HOST. Fix: edit the [[routes]] block."
  else
    ui_step_ok
  fi
  ui_step_start devvars "$CF_DIR/.dev.vars holds the credential"
  if [ -f "$CF_DIR/.dev.vars" ] && grep -q '^SHAKEKEE_CREDENTIAL_ID=.' "$CF_DIR/.dev.vars" && grep -q '^SHAKEKEE_PRIVATE_KEY=.' "$CF_DIR/.dev.vars"; then
    ui_step_ok
  else
    ui_step_warn "the credential is not in .dev.vars (fine if you only use secrets stored at Cloudflare)."
  fi
  ui_step_start secrets "The Worker's secrets at Cloudflare"
  if cf_wr_ready; then
    cf_sl=$(cf_wr secret list 2>/dev/null </dev/null) || cf_sl=
    cf_miss=
    for cf_s in $CF_SECRETS_ENV; do
      case $cf_sl in
        *"$cf_s"*) ;;
        *) cf_miss="$cf_miss $cf_s" ;;
      esac
    done
    if [ -z "$cf_miss" ]; then
      ui_step_ok
    else
      ui_step_fail "not stored:$cf_miss. Fix: cd $CF_DIR && npx wrangler secret put NAME"
      cf_bad=1
    fi
  else
    ui_step_warn "wrangler is not installed or not logged in, so this was not checked."
  fi
  return "$cf_bad"
}

recipe_doctor() {
  cf_load
  cf_bad=0
  ui_step_start node "Node.js 18 or newer for wrangler"
  if ! command -v node >/dev/null 2>&1; then
    ui_step_fail "node is not installed. Fix: install Node.js 18 or newer."
    cf_bad=1
  else
    cf_nv=$(node --version 2>/dev/null) || cf_nv=
    cf_nv=${cf_nv#v}
    cf_nv=${cf_nv%%.*}
    case $cf_nv in
      '' | *[!0-9]*) ui_step_warn "cannot tell the version of node" ;;
      *)
        if [ "$cf_nv" -ge 18 ]; then
          ui_step_ok
        else
          ui_step_fail "node $cf_nv is too old. Fix: install Node.js 18 or newer."
          cf_bad=1
        fi
        ;;
    esac
  fi
  ui_step_start wrangler "wrangler is installed and logged in to Cloudflare"
  if cf_wr_ready; then
    ui_step_ok
  elif [ -x "$CF_DIR/node_modules/.bin/wrangler" ] || command -v wrangler >/dev/null 2>&1; then
    ui_step_warn "wrangler is not logged in. Fix: cd $CF_DIR && npx wrangler login"
  else
    ui_step_warn "wrangler is not installed. Fix: cd $CF_DIR && npm install"
  fi
  ui_step_start kv "The KV namespace is set in wrangler.toml"
  if grep -q REPLACE_WITH_YOUR_KV_NAMESPACE_ID "$CF_DIR/wrangler.toml" 2>/dev/null; then
    ui_step_fail "wrangler.toml still has the placeholder id. Fix: cd $CF_DIR && npx wrangler kv namespace create KV, then put the id into wrangler.toml."
    cf_bad=1
  else
    ui_step_ok
  fi
  ui_step_start route "The route is not a placeholder"
  cf_route_host
  if [ -z "$CF_ROUTE_HOST" ]; then
    ui_step_fail "wrangler.toml has no route for your site (or still example.com). Fix: edit the [[routes]] block."
    cf_bad=1
  else
    ui_step_ok
  fi
  ui_step_start credentials "The saved credential for ${CF_HOST:-the site}"
  if [ -n "$CF_HOST" ] && [ -f "$SK_HOME/credentials/$CF_HOST.json" ]; then
    ui_step_ok
  else
    ui_step_warn "no file in $SK_HOME/credentials. Fine if you stored the secrets by hand."
  fi
  return "$cf_bad"
}

recipe_update() {
  cf_load
  if [ ! -f "$CF_DIR/package.json" ]; then
    sk_error SK-E405 "The Worker is not in $CF_DIR any more." "Run the installer again (without --update) to unpack it."
  fi
  need tar "install tar"
  cf_fetch
  if sk_rec_get cloudflare.artifact && [ "$SK_RV" = "$CF_SHA" ]; then
    ui_say "The Worker in $CF_DIR is the newest version (${SK_RECIPE_VERSION:-installed})."
    return 0
  fi
  cf_kept=
  cf_changed=0
  find "$CF_SRC" -type f -print >"$SK_TASK/cf-files"
  while IFS= read -r cf_p; do
    cf_rel=${cf_p#"$CF_SRC"/}
    cf_dest=$CF_DIR/$cf_rel
    if [ -e "$cf_dest" ]; then
      if cmp -s "$cf_p" "$cf_dest"; then
        continue
      fi
      cf_created_hash "$cf_dest"
      if [ -z "$CF_CH" ] || [ ! -f "$cf_dest" ] || [ -L "$cf_dest" ] || [ "$(sk_sha256 "$cf_dest")" != "$CF_CH" ]; then
        cf_kept="$cf_kept $cf_rel"
        continue
      fi
    else
      cf_dd=${cf_dest%/*}
      if [ ! -d "$cf_dd" ]; then
        sk_do "create $cf_dd" mkdir -p "$cf_dd"
        sk_created "$cf_dd"
      fi
    fi
    sk_do "write $cf_dest" cp "$cf_p" "$cf_dest"
    sk_created "$cf_dest"
    cf_changed=$((cf_changed + 1))
  done <"$SK_TASK/cf-files"
  if [ -n "$cf_kept" ]; then
    ui_warn "Kept your own version of:$cf_kept. The new ones are in the package; compare them by hand."
  fi
  if [ "$cf_changed" -gt 0 ] && sk_rec_get cloudflare.npm && [ "$SK_RV" = 1 ]; then
    sk_step_run npm "Updating the Worker's dependencies" cf_npm_install || ui_warn "npm install failed; the log says why."
  fi
  sk_record cloudflare.artifact "$CF_SHA"
  sk_integration_add cloudflare "${SK_RECIPE_VERSION:-installed}"
  ui_say "Updated $cf_changed file(s) in $CF_DIR. Deploy again with: cd $CF_DIR && npx wrangler deploy"
}

recipe_uninstall() {
  cf_load
  if sk_rec_get cloudflare.secrets && [ "$SK_RV" = stored ] && cf_wr_ready; then
    ui_menu_single "Delete the three Worker secrets at Cloudflare now?" "+keep|Keep them|you can delete them later with wrangler secret delete" "delete|Delete them"
    if [ "$UI_REPLY" = delete ]; then
      for cf_s in $CF_SECRETS_ENV; do
        sk_step_run "del-$cf_s" "Deleting the secret $cf_s" cf_wr secret delete "$cf_s" || :
      done
    fi
  fi
  if [ -n "$CF_HOST" ]; then
    ui_menu_single "Remove the ShakeKee API key and credential of $CF_HOST at the dashboard?" "+keep|Keep them|you can remove them in the dashboard" "revoke|Remove them|a login code is mailed to you"
    if [ "$UI_REPLY" = revoke ]; then
      sk_account_revoke "$CF_HOST" || ui_warn "The key was not removed; do it in the dashboard (API keys)."
    fi
  fi
  if sk_rec_get cloudflare.npm && [ "$SK_RV" = 1 ] && [ -d "$CF_DIR/node_modules" ] && [ ! -L "$CF_DIR/node_modules" ]; then
    sk_do "remove $CF_DIR/node_modules" rm -rf "$CF_DIR/node_modules"
  fi
  for cf_k in dir host zone artifact npm secrets remains; do
    sk_unrecord "cloudflare.$cf_k"
  done
}

sk_phases
}
sk_recipe_shopify() {
# ---- shopify: unpacks the app bundle next to your app and writes the app's credential into its .env ----
# The app never provisions and never holds the raw API key: it gets a credential id, the API key id and the private key in its
# environment. Nothing of yours is overwritten. Phases, hooks and the files it writes are in docs/recipes-platforms.md.
SH_DIR=shakekee-shopify
SH_DIR_GIVEN=0
SH_ENV=
SH_HOST=
SH_ENV_VARS="SHAKEKEE_CREDENTIAL_ID SHAKEKEE_API_KEY_ID SHAKEKEE_PRIVATE_KEY"

usage() {
  cat <<'USAGE'
Usage: curl -fsSL https://get.shakekee.com/shopify | sh -s -- [options]
  --dir DIR       where to unpack the bundle (default: ./shakekee-shopify)
  --host HOST     the storefront the app serves, for example shop.example.com (or SHAKEKEE_HOST)
  --env-file FILE where the app's environment is written (default: DIR/.env; other lines in it are kept)
  --account       use a ShakeKee account key (the only way here: the app runs on Shopify's side, not next to a daemon)
  --dry-run       download and verify, change nothing
The bundle (shopify.app.toml, app/ routes, extensions/) is laid over a Shopify Remix app, see its README.md. Safe to run
again with the same version. The key comes from the dashboard (the installer can create it), or from SHAKEKEE_API_KEY_ID,
SHAKEKEE_CREDENTIAL_ID and SHAKEKEE_PRIVATE_KEY_B64 when it runs without a terminal.
Also: --status, --update, --doctor, --uninstall.
USAGE
  sk_usage_common
}

while [ $# -gt 0 ]; do
  case $1 in
    --dir) sk_value "$1" $#; SH_DIR=$2; SH_DIR_GIVEN=1; shift ;;
    --host) sk_value "$1" $#; SH_HOST=$2; shift ;;
    --env-file) sk_value "$1" $#; SH_ENV=$2; shift ;;
    --account) ;;
    --daemon) sk_error SK-E403 "A Shopify app runs at its own host and cannot use a ShakeKee daemon on your machine." "Leave out --daemon: the app signs in with a ShakeKee account key." ;;
    --dry-run) SK_DRY=1 ;;
    --help | -h) usage; exit 0 ;;
    *) die "unknown option $1 (see --help)" ;;
  esac
  shift
done

# shp_host_ok HOST: a host name of letters, digits, dots and hyphens with at least one dot.
shp_host_ok() {
  case $1 in
    '' | -* | .* | *. | *..* | *[!A-Za-z0-9.-]*) return 1 ;;
    *.*) return 0 ;;
  esac
  return 1
}

# shp_same: every file of the bundle is in SH_DIR with the same text (other files there are none of our business).
shp_same() {
  find "$SH_SRC" -type f -print >"$SK_TASK/shp-files"
  while IFS= read -r shp_p; do
    cmp -s "$shp_p" "$SH_DIR/${shp_p#"$SH_SRC"/}" || return 1
  done <"$SK_TASK/shp-files"
}

# shp_fetch: downloads and checks the bundle, unpacks it to SH_SRC and sets SH_SHA and the recorded version.
shp_fetch() {
  need tar "install tar"
  sk_fetch_shopify
  SH_SHA=$(sk_sha256 "$SK_FILE")
  shp_vf=${SK_FILE##*/}
  case $shp_vf in
    shakekee-shopify-*.tgz)
      shp_vf=${shp_vf#shakekee-shopify-}
      SK_RECIPE_VERSION=${shp_vf%.tgz}
      ;;
  esac
  sk_extract_tgz "$SK_FILE" "$SK_TASK/unpacked"
  SH_SRC=$SK_TASK/unpacked/shakekee-shopify
  [ -f "$SH_SRC/shopify.app.toml" ] || die "the package does not contain shakekee-shopify/shopify.app.toml"
}

# shp_load: the directory, environment file and host recorded by the install (an explicit option wins).
shp_load() {
  if [ "$SH_DIR_GIVEN" = 0 ] && sk_rec_get shopify.dir; then
    SH_DIR=$SK_RV
  fi
  if [ -z "$SH_ENV" ] && sk_rec_get shopify.env; then
    SH_ENV=$SK_RV
  fi
  if [ -z "$SH_ENV" ]; then
    SH_ENV=$SH_DIR/.env
  fi
  if [ -z "$SH_HOST" ] && sk_rec_get shopify.host; then
    SH_HOST=$SK_RV
  fi
}

# shp_created_hash FILE: SH_CH is the hash recorded when ShakeKee wrote FILE (empty when it did not).
shp_created_hash() {
  SH_CH=
  sk_rec_load
  if [ -n "$SK_REC" ]; then
    while IFS=$UI_TAB read -r shp_hk shp_hv; do
      case $shp_hk in
        created.*)
          sk__unjq "$shp_hv"
          sk__fields "$SK_RV"
          if [ "$SK_F1" = "$1" ]; then
            SH_CH=$SK_F2
          fi
          ;;
      esac
    done <<REC
$SK_REC
REC
  fi
}

# shp_record_tree DIR: records DIR and everything in it as created by this installer (parents first). Not in a
# pipe: the record is cached in this shell.
shp_record_tree() {
  find "$1" \( -type d -o -type f \) -print >"$SK_TASK/shp-tree"
  while IFS= read -r shp_p; do
    sk_created "$shp_p"
  done <"$SK_TASK/shp-tree"
}

# shp_mode_private FILE: succeeds when only the owner can read FILE.
shp_mode_private() {
  shp_ls=$(ls -ld "$1" 2>/dev/null) || return 1
  case ${shp_ls%% *} in
    -rw-------*) return 0 ;;
  esac
  return 1
}

# shp_env_set FILE: sets the three SHAKEKEE_ values in a dotenv file, keeping every other line. When this ShakeKee is
# not the production one (SK_BASE_API, see sk_account_bases) its API address is set too (the app reads no CDN address).
# The edit is backed up and recorded; a new file is private.
shp_env_set() {
  shp_ef=$1
  sk_edit_begin "$shp_ef"
  if [ "$SK_DRY" = 1 ]; then
    return 0
  fi
  shp_ew=$(sk_edit_path "$shp_ef")
  chmod 600 "$shp_ew"
  if [ -s "$shp_ef" ]; then
    : >"$shp_ew"
    while IFS= read -r shp_l || [ -n "$shp_l" ]; do
      case $shp_l in
        SHAKEKEE_PRIVATE_KEY=* | SHAKEKEE_PRIVATE_KEY_B64=* | SHAKEKEE_CREDENTIAL_ID=* | SHAKEKEE_API_KEY_ID=*) ;;
        SHAKEKEE_API_BASE=*) if [ -z "$SK_BASE_API" ]; then printf '%s\n' "$shp_l" >>"$shp_ew"; fi ;;
        *) printf '%s\n' "$shp_l" >>"$shp_ew" ;;
      esac
    done <"$shp_ef"
  fi
  cat >>"$shp_ew" <<EOF
SHAKEKEE_CREDENTIAL_ID=$SK_CREDENTIAL_ID
SHAKEKEE_API_KEY_ID=$SK_API_KEY_ID
SHAKEKEE_PRIVATE_KEY=$SK_PRIVATE_KEY_B64
EOF
  if [ -n "$SK_BASE_API" ]; then printf 'SHAKEKEE_API_BASE=%s\n' "$SK_BASE_API" >>"$shp_ew"; fi
  sk_edit_commit "$shp_ef"
}

# shp_record_example DIR: records the example files that sk_example_install wrote, parents first.
shp_record_example() {
  if [ "$SK_DRY" = 1 ]; then
    return 0
  fi
  sk_created "$1"
  shp_el=$(sk_example_list_shopify)
  while IFS='|' read -r shp_ep shp_em; do
    shp_ed=$1
    shp_er=$shp_ep
    while :; do
      case $shp_er in
        */*)
          shp_ed=$shp_ed/${shp_er%%/*}
          shp_er=${shp_er#*/}
          sk_created "$shp_ed"
          ;;
        *) break ;;
      esac
    done
    sk_created "$1/$shp_ep"
  done <<EXAMPLE_LIST
$shp_el
EXAMPLE_LIST
}

recipe_detect() {
  need tar "install tar"
}

recipe_choose() {
  if [ -z "$SH_HOST" ]; then
    SH_HOST=${SHAKEKEE_HOST:-}
  fi
  if [ -z "$SH_HOST" ]; then
    if [ "$SK_YES" = 1 ] || [ "$UI_TTY" = 0 ] || [ "$UI_MODE" = json ]; then
      sk_error SK-E401 "The app needs the host name of the storefront it serves, and none was given." "Run again with --host HOST (for example --host shop.example.com), or set SHAKEKEE_HOST."
    fi
    ui_ask "Which storefront does the app serve? (host name, for example shop.example.com)"
    SH_HOST=$UI_REPLY
  fi
  if ! shp_host_ok "$SH_HOST"; then
    sk_error SK-E401 "\"$SH_HOST\" is not a host name the app can serve." "Give the host name only, for example shop.example.com: letters, digits, dots and hyphens."
  fi
  if [ -z "$SH_ENV" ]; then
    SH_ENV=$SH_DIR/.env
  fi
}

# Download and verify, and look at the directory; nothing is changed.
recipe_check() {
  shp_fetch
  SH_STATE=new
  if [ -e "$SH_DIR" ]; then
    if shp_same; then
      SH_STATE=same
    else
      die "$SH_DIR exists and is different. Move it away or choose another --dir."
    fi
  fi
}

recipe_plan() {
  sk_plan_add "Get a ShakeKee key for $SH_HOST (the app never gets the raw API key)"
  if [ "$SH_STATE" = new ]; then
    sk_plan_add "Unpack the bundle into $SH_DIR"
  else
    sk_plan_add "Keep $SH_DIR: it already holds this version"
  fi
  sk_plan_add "Write the credential into $SH_ENV (other lines stay, a copy is saved)"
  sk_account_bases
  if [ -n "$SK_BASE_API" ]; then
    sk_plan_add "Write the API address of this ShakeKee (not the production one) into $SH_ENV"
  fi
  sk_plan_add "Write the example theme sections and verify.sh into $SH_DIR/example"
  sk_plan_add "Change nothing in your own app or theme"
}

recipe_apply() {
  sk_account_credentials "$SH_HOST"
  sk_account_no_raw_key
  sk_account_bases
  if [ "$SK_DRY" = 0 ] && { [ -z "$SK_CREDENTIAL_ID" ] || [ -z "$SK_PRIVATE_KEY_B64" ]; }; then
    sk_error SK-E402 "The app needs a credential id and its private key, and only an API key was given." "A Shopify app never makes its own credential. Create one in the dashboard (API keys, then credentials), or run this in a terminal and choose \"Create a key for me\"."
  fi
  if [ "$SH_STATE" = new ]; then
    sk_do "unpack the bundle into $SH_DIR" cp -R "$SH_SRC" "$SH_DIR"
    if [ "$SK_DRY" = 0 ]; then
      shp_record_tree "$SH_DIR"
    fi
  else
    say "$SH_DIR already holds this version."
  fi
  if [ ! -e "$SH_DIR/.gitignore" ] && [ "$SK_DRY" = 0 ]; then
    printf '%s\n' .env node_modules/ >"$SH_DIR/.gitignore"
    sk_created "$SH_DIR/.gitignore"
  fi
  ui_step_start env "Writing the credential into $SH_ENV"
  shp_env_set "$SH_ENV"
  if [ "$SK_DRY" = 1 ] || shp_mode_private "$SH_ENV"; then
    ui_step_ok
  else
    ui_step_warn "the file can be read by others (it was there before). Fix: chmod 600 $SH_ENV"
  fi
  sk_example_install shopify "$SH_DIR/example"
  shp_record_example "$SH_DIR/example"
  if [ "$SK_DRY" = 0 ]; then
    shp_abs=$SH_DIR
    case $shp_abs in
      /*) ;;
      *) shp_abs=$PWD/${shp_abs#./} ;;
    esac
    shp_envabs=$SH_ENV
    case $shp_envabs in
      /*) ;;
      *) shp_envabs=$PWD/${shp_envabs#./} ;;
    esac
    sk_record shopify.dir "$shp_abs"
    sk_record shopify.env "$shp_envabs"
    sk_record shopify.host "$SH_HOST"
    sk_record shopify.artifact "$SH_SHA"
    if ! sk_rec_get shopify.remains; then
      sk_remains "Shopify: the app's environment settings SHAKEKEE_CREDENTIAL_ID, SHAKEKEE_API_KEY_ID and SHAKEKEE_PRIVATE_KEY wherever you host the app (not in $SH_ENV only), and the app itself if you deployed it."
      sk_remains "The ShakeKee API key and credential of $SH_HOST in the dashboard, and the file $SK_HOME/credentials/$SH_HOST.json (re-run with --uninstall to be asked)."
      sk_record shopify.remains 1
    fi
  fi
}

# Looks at the result without a network.
recipe_verify() {
  ui_step_start files "The bundle's files and the app's environment"
  shp_miss=
  for shp_f in shopify.app.toml app/shakekee.server.js app/routes/app.proxy.jsx example/verify.sh; do
    if [ ! -f "$SH_DIR/$shp_f" ]; then
      shp_miss="$shp_miss $shp_f"
    fi
  done
  for shp_v in $SH_ENV_VARS; do
    if ! grep -q "^$shp_v=." "$SH_ENV" 2>/dev/null; then
      shp_miss="$shp_miss $shp_v(in $SH_ENV)"
    fi
  done
  if [ -n "$shp_miss" ]; then
    ui_step_fail "missing:$shp_miss"
  elif ! shp_mode_private "$SH_ENV"; then
    ui_step_warn "$SH_ENV can be read by others. Fix: chmod 600 $SH_ENV"
  else
    ui_step_ok
  fi
}

recipe_handover() {
  if [ "$SK_DRY" = 1 ]; then
    ui_note "Dry run: nothing was changed."
    return 0
  fi
  ui_title "What is left"
  ui_say "  1. In $SH_DIR/shopify.app.toml replace the client id and your-app.example.com with your app's values, and set up the App"
  ui_say "     Proxy in the Partner Dashboard (the README in $SH_DIR says how)."
  ui_say "  2. Install the app's packages and start it:"
  ui_copy_hint "cd $SH_DIR/app && npm install"
  ui_copy_hint "cd $SH_DIR && shopify app dev"
  ui_say "  3. Where you host the app, set the same three settings as in $SH_ENV: ${SH_ENV_VARS}."
  if [ -n "$SK_BASE_API" ]; then
    ui_say "     This ShakeKee is not the production one: set SHAKEKEE_API_BASE=$SK_BASE_API there too."
  fi
  ui_say "  4. In the theme editor add the block ShakeKee Widget to a form section. Sections to try are in $SH_DIR/example."
  ui_say "  5. Prove the app works (its address is the application_url of shopify.app.toml):"
  ui_copy_hint "sh $SH_DIR/example/verify.sh https://your-app.example.com"
  ui_note "The app has no raw API key. The key and the private key are in $SK_HOME/credentials/$SH_HOST.json (readable by you only)."
}

# ---- --status, --doctor, --update, --uninstall ----

recipe_status() {
  shp_load
  shp_bad=0
  ui_step_start dir "The bundle is in $SH_DIR"
  if [ -f "$SH_DIR/shopify.app.toml" ] && [ -f "$SH_DIR/app/shakekee.server.js" ]; then
    ui_step_ok
  else
    ui_step_fail "the bundle's files are not there. Fix: run the installer again."
    return 1
  fi
  ui_step_start env "$SH_ENV holds the app's credential"
  shp_miss=
  for shp_v in $SH_ENV_VARS; do
    if ! grep -q "^$shp_v=." "$SH_ENV" 2>/dev/null; then
      shp_miss="$shp_miss $shp_v"
    fi
  done
  if [ -z "$shp_miss" ]; then
    ui_step_ok
  else
    ui_step_warn "missing in $SH_ENV:$shp_miss (fine if the app gets them from its host's settings)."
  fi
  ui_step_start client "shopify.app.toml has your client id"
  if grep -q 'REPLACE_WITH_YOUR_SHOPIFY_APP_CLIENT_ID' "$SH_DIR/shopify.app.toml" 2>/dev/null; then
    ui_step_warn "it still has the placeholder. Fix: edit client_id in $SH_DIR/shopify.app.toml."
  else
    ui_step_ok
  fi
  return "$shp_bad"
}

recipe_doctor() {
  shp_load
  shp_bad=0
  ui_step_start node "Node.js 20 or newer for the app"
  if ! command -v node >/dev/null 2>&1; then
    ui_step_fail "node is not installed. Fix: install Node.js 20 or newer."
    shp_bad=1
  else
    shp_nv=$(node --version 2>/dev/null) || shp_nv=
    shp_nv=${shp_nv#v}
    shp_nv=${shp_nv%%.*}
    case $shp_nv in
      '' | *[!0-9]*) ui_step_warn "cannot tell the version of node" ;;
      *)
        if [ "$shp_nv" -ge 20 ]; then
          ui_step_ok
        else
          ui_step_fail "node $shp_nv is too old. Fix: install Node.js 20 or newer."
          shp_bad=1
        fi
        ;;
    esac
  fi
  ui_step_start cli "The Shopify CLI is installed"
  if command -v shopify >/dev/null 2>&1; then
    ui_step_ok
  else
    ui_step_warn "shopify is not on the PATH. Fix: npm install -g @shopify/cli"
  fi
  ui_step_start modules "The app's packages are installed"
  if [ -d "$SH_DIR/app/node_modules/shakekee-nodejs" ]; then
    ui_step_ok
  else
    ui_step_warn "shakekee-nodejs is not in $SH_DIR/app/node_modules. Fix: cd $SH_DIR/app && npm install"
  fi
  ui_step_start envmode "$SH_ENV is private"
  if [ ! -f "$SH_ENV" ]; then
    ui_step_warn "there is no $SH_ENV (fine if the app gets its settings from its host)."
  elif shp_mode_private "$SH_ENV"; then
    ui_step_ok
  else
    ui_step_fail "others can read $SH_ENV. Fix: chmod 600 $SH_ENV"
    shp_bad=1
  fi
  ui_step_start credentials "The saved credential for ${SH_HOST:-the storefront}"
  if [ -n "$SH_HOST" ] && [ -f "$SK_HOME/credentials/$SH_HOST.json" ]; then
    ui_step_ok
  else
    ui_step_warn "no file in $SK_HOME/credentials. Fine if you set the values by hand."
  fi
  return "$shp_bad"
}

recipe_update() {
  shp_load
  if [ ! -f "$SH_DIR/shopify.app.toml" ]; then
    sk_error SK-E405 "The bundle is not in $SH_DIR any more." "Run the installer again (without --update) to unpack it."
  fi
  shp_fetch
  if sk_rec_get shopify.artifact && [ "$SK_RV" = "$SH_SHA" ]; then
    ui_say "The bundle in $SH_DIR is the newest version (${SK_RECIPE_VERSION:-installed})."
    return 0
  fi
  shp_kept=
  shp_changed=0
  find "$SH_SRC" -type f -print >"$SK_TASK/shp-files"
  while IFS= read -r shp_p; do
    shp_rel=${shp_p#"$SH_SRC"/}
    shp_dest=$SH_DIR/$shp_rel
    if [ -e "$shp_dest" ]; then
      if cmp -s "$shp_p" "$shp_dest"; then
        continue
      fi
      shp_created_hash "$shp_dest"
      if [ -z "$SH_CH" ] || [ ! -f "$shp_dest" ] || [ -L "$shp_dest" ] || [ "$(sk_sha256 "$shp_dest")" != "$SH_CH" ]; then
        shp_kept="$shp_kept $shp_rel"
        continue
      fi
    else
      shp_dd=${shp_dest%/*}
      if [ ! -d "$shp_dd" ]; then
        sk_do "create $shp_dd" mkdir -p "$shp_dd"
        sk_created "$shp_dd"
      fi
    fi
    sk_do "write $shp_dest" cp "$shp_p" "$shp_dest"
    sk_created "$shp_dest"
    shp_changed=$((shp_changed + 1))
  done <"$SK_TASK/shp-files"
  if [ -n "$shp_kept" ]; then
    ui_warn "Kept your own version of:$shp_kept. The new ones are in the package; compare them by hand."
  fi
  sk_record shopify.artifact "$SH_SHA"
  sk_integration_add shopify "${SK_RECIPE_VERSION:-installed}"
  ui_say "Updated $shp_changed file(s) in $SH_DIR. Restart the app so that it uses them."
}

recipe_uninstall() {
  shp_load
  if [ -n "$SH_HOST" ]; then
    ui_menu_single "Remove the ShakeKee API key and credential of $SH_HOST at the dashboard?" "+keep|Keep them|you can remove them in the dashboard" "revoke|Remove them|a login code is mailed to you"
    if [ "$UI_REPLY" = revoke ]; then
      sk_account_revoke "$SH_HOST" || ui_warn "The key was not removed; do it in the dashboard (API keys)."
    fi
  fi
  for shp_k in dir env host artifact remains; do
    sk_unrecord "shopify.$shp_k"
  done
}

sk_phases
}
sk_recipe_go() {
# ---- go: unpacks the module and points the project at it with a replace directive; the daemon's secret in .shakekee/ ----
sk_app_init go Go daemon shakekee-example "go|install Go 1.22 or newer"
APP_PKG="the go command"
GO_DIR=third_party/shakekee-go

usage() {
  cat <<'USAGE'
Usage: curl -fsSL https://get.shakekee.com/go | sh -s -- [options]
  --dir DIR       where to unpack the module, relative to go.mod (default: third_party/shakekee-go)
USAGE
  sk_app_usage
  cat <<'USAGE'
Run it in the directory of your go.mod. Online you can also use: go get github.com/shakekee/shakekee-go
Needs the ShakeKee daemon (shakekeed); the settings go to DIR/.shakekee/shakekee.env (private). Safe to run again.
USAGE
  sk_usage_common
}

while [ $# -gt 0 ]; do
  case $1 in
    --dir) sk_value "$1" $#; GO_DIR=$2; shift ;;
    --path | --host | --daemon | --account | --no-example | --force) sk_app_option "$@"; shift "$SK_APP_USED" ;;
    --dry-run) SK_DRY=1 ;;
    --help | -h) usage; exit 0 ;;
    *) die "unknown option $1 (see --help)" ;;
  esac
  shift
done

app_extra() {
  GO_DIR=${GO_DIR#./}
  GO_DIR=${GO_DIR%/}
  case $GO_DIR in
    '' | . | ./ | /* | *..*) die "--dir must be a relative path inside the module" ;;
  esac
}
app_find() {
  if [ ! -f go.mod ]; then
    sk_error SK-E301 "no go.mod in $APP_ROOT." "Run it in your module (go mod init creates one) or pass --path DIR."
  fi
}
app_fetch() {
  sk_fetch_go
  sk_extract_tgz "$SK_FILE" "$SK_TASK/unpacked"
  grep -q '^module github.com/shakekee/shakekee-go$' "$SK_TASK/unpacked/shakekee-go/go.mod" || die "the package is not the shakekee-go module"
}
app_install() {
  sk_app_safety go.mod
  # an old copy of the module goes; anything else in that folder is not ours to remove
  if [ -e "$GO_DIR" ] && ! grep -q '^module github.com/shakekee/shakekee-go$' "$GO_DIR/go.mod" 2>/dev/null; then
    sk_error SK-E307 "$GO_DIR exists and is not the ShakeKee module." "Name another folder with --dir, or move it away."
  fi
  if [ "$SK_DRY" = 1 ]; then
    ui_say "[dry-run] unpack the module into $GO_DIR"
  else
    rm -rf "$GO_DIR"
    sk_app_track_begin "${GO_DIR%%/*}"
    mkdir -p "$GO_DIR" || sk_error SK-E307 "Cannot create $GO_DIR." "Check the permissions."
    cp -R "$SK_TASK/unpacked/shakekee-go/." "$GO_DIR/" || sk_error SK-E307 "Cannot write $GO_DIR." "Check the permissions."
    sk_app_track_end
  fi
  sk_app_run edit "Pointing go.mod at the module" go mod edit -require=github.com/shakekee/shakekee-go@v0.0.0 -replace=github.com/shakekee/shakekee-go="./$GO_DIR"
  sk_app_run list "Checking that Go can resolve the module" go list github.com/shakekee/shakekee-go
}
app_uninstall() {
  sk_app_try edit "Removing the module from go.mod" go mod edit -dropreplace=github.com/shakekee/shakekee-go -droprequire=github.com/shakekee/shakekee-go
}
app_loaded() { go list github.com/shakekee/shakekee-go; }
app_save() { sk_record go.dir "$GO_DIR"; }
app_enter() {
  if sk_rec_get go.dir; then GO_DIR=$SK_RV; fi
}
app_entry() {
  ui_heading "Wrap your handler, and let the module serve the wall's own files"
  ui_copy_block "import \"github.com/shakekee/shakekee-go\"" \
    "g, err := shakekee.New(shakekee.Config{Secret: os.Getenv(\"SHAKEKEE_PREFLIGHT_SECRET\"), Host: os.Getenv(\"SHAKEKEE_HOST\")})" \
    "mux.Handle(\"/.shakekee/\", g.Proxy())" \
    "mux.Handle(\"/.well-known/shakekee/\", g.Proxy())" \
    "mux.Handle(\"/\", g.Middleware(app))"
  ui_note "The daemon decides which paths are protected. See $GO_DIR/README.md."
  if [ "$APP_EXAMPLE" = 1 ]; then
    ui_note "The example is a small program of its own: go run ./$APP_EXDIR (see $APP_EXDIR/verify.sh --serve 8099)."
  fi
}

sk_app_hooks
sk_phases
}
sk_recipe_rust() {
# ---- rust: unpacks the crate and adds it as a path dependency; the daemon's secret in .shakekee/ ----
sk_app_init rust Rust daemon shakekee-example "cargo|install Rust (https://rustup.rs)"
APP_PKG=cargo
RS_DIR=vendor/shakekee
RS_FEATURES=

usage() {
  cat <<'USAGE'
Usage: curl -fsSL https://get.shakekee.com/rust | sh -s -- [options]
  --dir DIR       where to unpack the crate, relative to Cargo.toml (default: vendor/shakekee)
  --features F    cargo features of the dependency (for example axum)
USAGE
  sk_app_usage
  cat <<'USAGE'
Run it in the directory of your Cargo.toml. Needs the ShakeKee daemon (shakekeed); the settings go to
DIR/.shakekee/shakekee.env (private). The example (a crate of its own) needs the default --dir. Safe to run again.
USAGE
  sk_usage_common
}

while [ $# -gt 0 ]; do
  case $1 in
    --dir) sk_value "$1" $#; RS_DIR=$2; shift ;;
    --features) sk_value "$1" $#; RS_FEATURES=$2; shift ;;
    --path | --host | --daemon | --account | --no-example | --force) sk_app_option "$@"; shift "$SK_APP_USED" ;;
    --dry-run) SK_DRY=1 ;;
    --help | -h) usage; exit 0 ;;
    *) die "unknown option $1 (see --help)" ;;
  esac
  shift
done

app_extra() {
  RS_DIR=${RS_DIR#./}
  RS_DIR=${RS_DIR%/}
  case $RS_DIR in
    '' | . | /* | *..*) die "--dir must be a relative path inside the project" ;;
  esac
  case $RS_FEATURES in
    *[!A-Za-z0-9_,-]*) die "--features takes feature names separated by commas" ;;
  esac
  if [ "$RS_DIR" != vendor/shakekee ] && [ "$APP_EXAMPLE" = 1 ]; then
    ui_note "The example refers to the crate in vendor/shakekee, so it is not added with --dir $RS_DIR."
    APP_EXAMPLE=0
  fi
}
app_find() {
  if [ ! -f Cargo.toml ]; then
    sk_error SK-E301 "no Cargo.toml in $APP_ROOT." "Run it in your crate or pass --path DIR."
  fi
}
app_fetch() {
  sk_fetch_rust
  sk_extract_tgz "$SK_FILE" "$SK_TASK/unpacked"
  RS_SRC=$(ls -d "$SK_TASK"/unpacked/shakekee-*/ 2>/dev/null | head -n 1)
  if [ -z "$RS_SRC" ] || [ ! -f "${RS_SRC}Cargo.toml" ]; then
    die "the package does not contain shakekee-<version>/Cargo.toml"
  fi
}
app_install() {
  sk_app_safety Cargo.toml
  if [ -e "$RS_DIR" ] && ! grep -q '^name = "shakekee"$' "$RS_DIR/Cargo.toml" 2>/dev/null; then
    sk_error SK-E307 "$RS_DIR exists and is not the ShakeKee crate." "Name another folder with --dir, or move it away."
  fi
  if [ "$SK_DRY" = 1 ]; then
    ui_say "[dry-run] unpack the crate into $RS_DIR"
  else
    rm -rf "$RS_DIR"
    sk_app_track_begin "${RS_DIR%%/*}"
    mkdir -p "$RS_DIR" || sk_error SK-E307 "Cannot create $RS_DIR." "Check the permissions."
    cp -R "${RS_SRC}." "$RS_DIR/" || sk_error SK-E307 "Cannot write $RS_DIR." "Check the permissions."
    sk_app_track_end
  fi
  if [ -n "$RS_FEATURES" ]; then
    sk_app_run add "Adding the crate with cargo add" cargo add shakekee --path "$RS_DIR" --features "$RS_FEATURES"
  else
    sk_app_run add "Adding the crate with cargo add" cargo add shakekee --path "$RS_DIR"
  fi
}
app_uninstall() {
  sk_app_try remove "Removing the crate with cargo remove" cargo remove shakekee
}
app_loaded() { grep -q '^shakekee' Cargo.toml; }
app_save() {
  sk_record rust.dir "$RS_DIR"
  sk_record rust.features "$RS_FEATURES"
}
app_enter() {
  if sk_rec_get rust.dir; then RS_DIR=$SK_RV; fi
  if sk_rec_get rust.features; then RS_FEATURES=$SK_RV; fi
}
app_entry() {
  ui_heading "Guard your routes with the layer, and merge the proxy routes after it (axum)"
  ui_copy_block "let config = shakekee::Config::new(std::env::var(\"SHAKEKEE_PREFLIGHT_SECRET\")?, std::env::var(\"SHAKEKEE_HOST\")?);" \
    ".layer(from_fn_with_state(shakekee::Preflight::new(&config)?, shakekee::axum::guard))" \
    ".merge(shakekee::axum::proxy_routes(shakekee::Proxy::new(&config)?))"
  ui_note "The daemon decides which paths are protected. See $RS_DIR/README.md (the tower layer works with any hyper-based framework; the axum helpers need --features axum)."
  if [ "$APP_EXAMPLE" = 1 ]; then
    ui_note "The example is a crate of its own: cargo run --manifest-path $APP_EXDIR/Cargo.toml (see $APP_EXDIR/verify.sh --serve 8099)."
  fi
}

sk_app_hooks
sk_phases
}
sk_recipe_php() {
# ---- php: Composer package from a local artifact repository; the daemon's secret in .shakekee/ ----
sk_app_init php PHP daemon shakekee-example "composer|install Composer (https://getcomposer.org) and run this again${UI_NL}php|install PHP and run this again"
APP_SECRET=SHAKEKEE_SECRET
APP_PKG=Composer

usage() {
  cat <<'USAGE'
Usage: curl -fsSL https://get.shakekee.com/php | sh -s -- [options]
USAGE
  sk_app_usage
  cat <<'USAGE'
Needs Composer and the ShakeKee daemon (shakekeed). The verified package is kept in DIR/shakekee-artifacts/, the
settings in DIR/.shakekee/shakekee.env (private). Safe to run again.
USAGE
  sk_usage_common
}

while [ $# -gt 0 ]; do
  case $1 in
    --path | --host | --daemon | --account | --no-example | --force) sk_app_option "$@"; shift "$SK_APP_USED" ;;
    --dry-run) SK_DRY=1 ;;
    --help | -h) usage; exit 0 ;;
    *) die "unknown option $1 (see --help)" ;;
  esac
  shift
done

app_extra() {
  sk_require_php_zip
  sk_app_not_root
}
app_find() {
  if [ ! -f composer.json ]; then
    sk_error SK-E301 "no composer.json in $APP_ROOT." "Run it in your project (composer init creates one) or pass --path DIR."
  fi
}
app_fetch() { sk_fetch_php; }
app_install() {
  sk_app_safety composer.json
  sk_app_keep "$SK_FILE" shakekee-artifacts php.zip
  sk_app_run repo "Telling Composer where the package is" composer --no-interaction config repositories.shakekee artifact ./shakekee-artifacts
  sk_app_run require "Installing shakekee/php with Composer" composer --no-interaction require shakekee/php
}
app_uninstall() {
  sk_app_try remove "Removing shakekee/php with Composer" composer --no-interaction remove shakekee/php
  if sk_app_only_ours shakekee-artifacts php.zip; then
    sk_app_try repo "Removing the package folder from composer.json" composer --no-interaction config --unset repositories.shakekee
  fi
}
app_loaded() { composer --no-interaction show shakekee/php; }
app_entry() {
  ui_heading "Add this to the front controller of your site (public/index.php)"
  ui_copy_block "ShakeKee\\Guard::protect();"
  ui_note "After Composer's autoloader and before any output. It reads SHAKEKEE_HOST and SHAKEKEE_SECRET from the environment of PHP (PHP-FPM: env[...] in the pool, Apache: SetEnv)."
  if [ "$APP_EXAMPLE" = 1 ]; then
    ui_note "The example's pages are $APP_EXDIR/open/ and $APP_EXDIR/protected/ (serve the folder, or run its verify.sh --serve 8099)."
  fi
}

sk_app_hooks
sk_phases
}
sk_recipe_csharp() {
# ---- csharp: a local NuGet feed in ./shakekee-packages, then dotnet add package; the daemon's secret in .shakekee/ ----
sk_app_init csharp "C#" daemon shakekee-example ''
APP_SECRET=SHAKEKEE_SECRET
APP_PKG="the .NET SDK"
CS_PROJECT=
CS_FEED=shakekee-packages

usage() {
  cat <<'USAGE'
Usage: curl -fsSL https://get.shakekee.com/csharp | sh -s -- [options]
  --project FILE  the .csproj to add the package to (default: the only .csproj in the current directory)
  --feed DIR      the local NuGet feed to keep the verified package in (default: ./shakekee-packages)
USAGE
  sk_app_usage
  cat <<'USAGE'
Needs the .NET SDK. Without it (Mono, .NET Framework) the verified package is kept in the feed and the
steps for nuget.exe / a manual reference are printed. Needs the ShakeKee daemon (shakekeed); the settings go to
DIR/.shakekee/shakekee.env (private). The example needs the default --feed. Safe to run again.
USAGE
  sk_usage_common
}

while [ $# -gt 0 ]; do
  case $1 in
    --project) sk_value "$1" $#; CS_PROJECT=$2; shift ;;
    --feed) sk_value "$1" $#; CS_FEED=$2; shift ;;
    --path | --host | --daemon | --account | --no-example | --force) sk_app_option "$@"; shift "$SK_APP_USED" ;;
    --dry-run) SK_DRY=1 ;;
    --help | -h) usage; exit 0 ;;
    *) die "unknown option $1 (see --help)" ;;
  esac
  shift
done

app_extra() {
  CS_FEED=${CS_FEED%/}
  if [ "$CS_FEED" != shakekee-packages ] && [ "$APP_EXAMPLE" = 1 ]; then
    ui_note "The example refers to the feed ./shakekee-packages, so it is not added with --feed $CS_FEED."
    APP_EXAMPLE=0
  fi
}
# with the SDK the project must be known before anything is downloaded
app_find() {
  if command -v dotnet >/dev/null 2>&1; then
    if [ -z "$CS_PROJECT" ]; then
      set -- ./*.csproj
      cs_count=$#
      if [ ! -f "$1" ]; then cs_count=0; fi
      if [ "$cs_count" != 1 ]; then
        sk_error SK-E301 "found $cs_count .csproj files in $APP_ROOT; expected one." "Run it in the folder of your project or pass --project FILE."
      fi
      CS_PROJECT=$1
    fi
    if [ ! -f "$CS_PROJECT" ]; then
      sk_error SK-E301 "$CS_PROJECT not found." "Pass the .csproj of your project with --project FILE."
    fi
  fi
}
app_fetch() { sk_fetch_csharp; }
app_install() {
  sk_app_keep "$SK_FILE" "$CS_FEED" 'ShakeKee.*.nupkg'
  cs_pkg=${SK_KEPT##*/}
  cs_version=${cs_pkg#ShakeKee.}
  cs_version=${cs_version%.nupkg}
  if command -v dotnet >/dev/null 2>&1; then
    sk_app_safety "$CS_PROJECT"
    sk_app_run add "Adding the package with dotnet add package" dotnet add "$CS_PROJECT" package ShakeKee --version "$cs_version" --source "$CS_FEED"
  else
    ui_warn "the .NET SDK (dotnet) is not installed, so the project was not changed"
    ui_say "The verified package is $SK_KEPT. With nuget.exe: nuget install ShakeKee -Version $cs_version -Source $CS_FEED -OutputDirectory packages"
    ui_say "or reference lib/net461/ShakeKee.dll from the unzipped .nupkg."
  fi
}
app_uninstall() {
  if [ -n "$CS_PROJECT" ]; then
    sk_app_try remove "Removing the package with dotnet remove package" dotnet remove "$CS_PROJECT" package ShakeKee
  fi
}
app_loaded() {
  if [ -n "$CS_PROJECT" ]; then
    grep -q 'Include="ShakeKee"' "$CS_PROJECT"
  else
    ls "$CS_FEED"/ShakeKee.*.nupkg
  fi
}
app_save() {
  sk_record csharp.project "$CS_PROJECT"
  sk_record csharp.feed "$CS_FEED"
}
app_enter() {
  if sk_rec_get csharp.project; then CS_PROJECT=$SK_RV; fi
  if sk_rec_get csharp.feed; then CS_FEED=$SK_RV; fi
}
app_entry() {
  ui_heading "ASP.NET (IIS, Mono): add the module and the host to web.config"
  ui_copy_block "<add key=\"ShakeKee.Host\" value=\"$APP_HOST\" />" \
    "<httpModules><add name=\"ShakeKee\" type=\"ShakeKee.ShakeKeeModule, ShakeKee\" /></httpModules>"
  ui_note "The secret comes from the environment (SHAKEKEE_SECRET)."
  ui_heading "A self-hosted HttpListener application"
  ui_copy_block "var shakekee = new ShakeKee.Listener.Middleware(ShakeKee.GuardOptions.FromEnvironment());" \
    "// then, for each request: if (shakekee.Handle(context)) return;"
  if [ "$APP_EXAMPLE" = 1 ]; then
    ui_note "The example is a small program of its own: sh $APP_EXDIR/verify.sh --serve 8099"
  fi
}

sk_app_hooks
sk_phases
}
sk_recipe_nginx() {
# ---- nginx: the integration files, filled in for this machine; they go into the server's include directory only with --install ----
SERVER_DIR=
SERVER_INSTALL=0
SERVER_FORCE=0
SERVER_UNPACK=shakekee-nginx
SERVER_SITES=
SERVER_SECRET_FILE=

usage() {
  cat <<'USAGE'
Usage: curl -fsSL https://get.shakekee.com/nginx | sh -s -- [options]
  (no option)     unpack the verified files into ./shakekee-nginx and print what to do; nothing else is touched
  --install       copy the files to the standard places (see below) with the daemon's secret filled in; where you cannot
                  write there, sudo is used for those steps only, each command shown before it runs
  --dir DIR       with --install: use DIR as the base directory instead of the standard one
  --force         with --install: replace files that differ (a copy of the old one is kept; local edits are otherwise
                  kept and reported)
  --site HOST     with --install: a site to wire up (repeat it for more); the include lines are shown and, only when you
                  allow it, added to the server block of HOST
  --edit-server   allow the installer to edit the nginx configuration for --site without asking (a backup is made first,
                  nginx -t must pass, else everything is put back)
  --no-edit-server  never edit the nginx configuration; only show the lines
  --secret-file FILE  with --install: read the daemon's preflight secret from FILE
  --dry-run       download and verify, change nothing
Standard place: /etc/nginx/shakekee/ (shakekee-http.conf, shakekee-server.conf, shakekee-protect.conf).
Your nginx configuration is edited only when you allow it; otherwise the script tells you which lines to add. Safe to run again.
USAGE
  sk_usage_common
}

while [ $# -gt 0 ]; do
  case $1 in
    --install) SERVER_INSTALL=1 ;;
    --force) SERVER_FORCE=1 ;;
    --dir) sk_value "$1" $#; SERVER_DIR=$2; shift ;;
    --site) sk_value "$1" $#; SERVER_SITES="${SERVER_SITES:+$SERVER_SITES }$2"; shift ;;
    --secret-file) sk_value "$1" $#; SERVER_SECRET_FILE=$2; shift ;;
    --edit-server) SK_SERVER_EDIT=yes ;;
    --no-edit-server) SK_SERVER_EDIT=no ;;
    --dry-run) SK_DRY=1 ;;
    --help | -h) usage; exit 0 ;;
    *) die "unknown option $1 (see --help)" ;;
  esac
  shift
done
case $SERVER_DIR in
  '' | /*) ;;
  *) SERVER_DIR=$PWD/$SERVER_DIR ;;
esac
# in the hub the person chose "install nginx" from a menu
if command -v sk_hub_main >/dev/null 2>&1; then
  SERVER_INSTALL=1
fi
for nginx_s in $SERVER_SITES; do
  sk__srv_site_ok "$nginx_s" || die "--site $nginx_s is not a host name (letters, digits, dots and hyphens)"
done

# nginx_base: where the files go (SERVER_BASE). The per-site files that the daemon step and this recipe share stay where
# sk_server_rule puts them (next to nginx.conf, or SK_SRV_SITESDIR), whatever --dir says.
nginx_base() {
  SERVER_BASE=${SERVER_DIR:-/etc/nginx/shakekee}
  SK_SRV_PKGDIR=$SERVER_BASE
}

# nginx_cache_dir: shakekee-http.conf has a cache zone in a directory that nginx makes itself, but only when the one above it
# exists (on Alpine /var/cache/nginx does not): the test of the include would fail on that. Made if it is missing.
nginx_cache_dir() {
  nginx_cache=$(sed -n 's/^proxy_cache_path[[:space:]][[:space:]]*\([^[:space:]]*\).*/\1/p' "$SK_TASK/filled/shakekee-http.conf" | head -n 1)
  nginx_cache=${nginx_cache%/*}
  if [ -z "$nginx_cache" ] || [ -d "$nginx_cache" ]; then
    return 0
  fi
  if [ "$SK_DRY" = 1 ]; then
    say "[dry-run] create $nginx_cache"
    return 0
  fi
  sk_log_open
  sk__srv_priv_for "$nginx_cache"
  sk__srv_sudo mkdir -p -m 0755 "$nginx_cache" || die "cannot create $nginx_cache: $(sk__srv_head "$SK_SRV_OUTF" 2)"
  say "Created $nginx_cache, where nginx keeps the cache zone of shakekee-http.conf."
  sk_remains "The directory $nginx_cache (nginx's cache zone of ShakeKee is below it) stays; remove it if nothing else uses it."
}

# nginx_files SRC: the three files of the package, the secret and the daemon's addresses filled in, put in place
nginx_files() {
  sk_server_secret "$SERVER_SECRET_FILE" || true
  nginx_base
  # the filled files carry the secret: nobody else may read them, also in a hub run that opens SK_TASK to the site owner
  (umask 077 && mkdir -p "$SK_TASK/filled") || sk_fail 1 SK-E006 "Cannot create $SK_TASK/filled." "Check the permissions of ${TMPDIR:-/tmp}."
  # an earlier run filled the secret in and it cannot be found now (an update, say): the file that holds it stays as it is
  nginx_kept=0
  if [ -z "$SK_SRV_SECRET" ] && sk_rec_get nginx.secret && [ "$SK_RV" = filled ] && [ -e "$SERVER_BASE/shakekee-server.conf" ]; then
    nginx_kept=1
  fi
  for nginx_f in shakekee-http.conf shakekee-server.conf shakekee-protect.conf; do
    if [ "$nginx_kept" = 1 ] && [ "$nginx_f" = shakekee-server.conf ]; then
      say "$SERVER_BASE/$nginx_f keeps the secret it has."
      continue
    fi
    # SK_SRV_CACHE_PATH: where this nginx keeps its caches, if not /var/cache/nginx
    SK_SRV_EXTRA_SED=
    if [ "$nginx_f" = shakekee-http.conf ] && [ -n "${SK_SRV_CACHE_PATH:-}" ]; then
      SK_SRV_EXTRA_SED="s|/var/cache/nginx/shakekee|$(sk__srv_sedq "$SK_SRV_CACHE_PATH")|g"
    fi
    sk_server_fill "$1/$nginx_f" "$SK_TASK/filled/$nginx_f"
    SK_SRV_EXTRA_SED=
    nginx_mode=0644
    if [ "$nginx_f" = shakekee-server.conf ] && [ -n "$SK_SRV_SECRET" ]; then
      nginx_mode=0600
    fi
    sk_server_place "$SK_TASK/filled/$nginx_f" "$SERVER_BASE/$nginx_f" "$nginx_mode"
  done
  nginx_cache_dir
  sk_record nginx.dir "$SERVER_BASE"
  if [ -n "$SK_SRV_SECRET" ] || [ "$nginx_kept" = 1 ]; then
    sk_record nginx.secret filled
  else
    sk_record nginx.secret missing
    warn "The daemon's preflight secret was not found, so CHANGE-ME is still in $SERVER_BASE/shakekee-server.conf. Put the secret in (shakekeed print-config --preflight-secret) or run this again with --secret-file FILE; with a wrong secret nginx fails open and protects nothing."
  fi
}

# nginx_fetch: the verified package, unpacked into $SERVER_SRC
nginx_fetch() {
  sk_fetch_nginx
  sk_extract_tgz "$SK_FILE" "$SK_TASK/unpacked"
  SERVER_SRC=$SK_TASK/unpacked/shakekee-nginx
  [ -f "$SERVER_SRC/shakekee-server.conf" ] || die "the package does not contain shakekee-nginx/shakekee-server.conf"
}

recipe_detect() {
  need tar "install tar and run this again"
  sk_server_detect
  if sk_server_select nginx; then
    nginx_state='not running'
    if [ "$SK_SRV_ISRUN" = yes ]; then
      nginx_state=running
    fi
    ui_say "nginx ${SK_SRV_VER:-} found at $SK_SRV_BIN ($nginx_state); configuration: ${SK_SRV_CONF:-not found}"
  else
    ui_note "nginx was not found on this machine. The files are prepared anyway; they work once nginx is installed."
  fi
}

recipe_plan() {
  sk_plan_add "Download the nginx package and check it against the SHA-256 written into this script"
  if [ "$SERVER_INSTALL" = 1 ]; then
    nginx_base
    sk_plan_add "Put shakekee-http.conf, shakekee-server.conf and shakekee-protect.conf in $SERVER_BASE, with the daemon's secret and addresses filled in"
    for nginx_s in $SERVER_SITES; do
      sk_plan_add "Show the lines for $nginx_s and, only if you allow it, add them to its server block (backup first, nginx -t, undone if it fails)"
    done
  else
    sk_plan_add "Unpack the files into ./$SERVER_UNPACK; nothing else is touched"
  fi
  sk_server_plan_show
}

recipe_apply() {
  nginx_fetch
  if [ "$SERVER_INSTALL" = 0 ]; then
    sk_unpack_only "$SERVER_SRC" "$SERVER_UNPACK"
    return 0
  fi
  nginx_files "$SERVER_SRC"
  sk_server_choose_sites nginx
  sk_server_offer_sites nginx
}

recipe_verify() {
  if [ "$SERVER_INSTALL" = 0 ]; then
    return 0
  fi
  ui_step_start files "The three files are in $SERVER_BASE"
  if [ -f "$SERVER_BASE/shakekee-http.conf" ] && [ -f "$SERVER_BASE/shakekee-server.conf" ] && [ -f "$SERVER_BASE/shakekee-protect.conf" ]; then
    ui_step_ok
  else
    ui_step_fail "a file is missing in $SERVER_BASE"
  fi
}

recipe_handover() {
  if [ "$SERVER_INSTALL" = 0 ]; then
    say "Next, with the files in $SERVER_UNPACK (or after: sh -s -- --install, which copies them to /etc/nginx/shakekee/):"
  else
    say "Files are in $SERVER_BASE."
  fi
  if [ "$SERVER_INSTALL" = 1 ] && [ "${SK_SRV_WIRED_N:-0}" -gt 0 ]; then
    say "  The includes for the site(s) you chose are in place and nginx passed its test."
  else
    say "  1. include shakekee-http.conf once in http { };"
    if [ "$SERVER_INSTALL" = 1 ] && [ -n "${SK_SRV_SECRET:-}" ]; then
      say "  2. include shakekee-server.conf in each protected server { } (the daemon's secret is already in it);"
    else
      say "  2. include shakekee-server.conf in each protected server { } and put your preflight secret in it (replace CHANGE-ME);"
    fi
  fi
  say "  3. include shakekee-protect.conf in each protected location { };"
  say "  4. nginx -t, then reload nginx. See README.md in the package for how it works."
  if [ "${SK_SRV_FAILED:-0}" -gt 0 ]; then
    exit 1
  fi
}

if [ "$SERVER_INSTALL" = 1 ] || [ "$SK_ACTION" != install ]; then
  recipe_status() {
    if ! sk_rec_get nginx.dir; then
      ui_say "  nginx: no files are recorded as installed."
      return 0
    fi
    nginx_dir=$SK_RV
    ui_step_start files "The files in $nginx_dir"
    if [ -f "$nginx_dir/shakekee-http.conf" ] && [ -f "$nginx_dir/shakekee-server.conf" ] && [ -f "$nginx_dir/shakekee-protect.conf" ]; then
      ui_step_ok
    else
      ui_step_fail "a file is missing.${UI_NL}Fix: run the nginx installer again with --install."
      return 1
    fi
    sk_rec_get nginx.secret || true
    ui_step_start secret "The daemon's secret is filled in"
    if [ "$SK_RV" = filled ]; then
      ui_step_ok
    else
      ui_step_warn "CHANGE-ME was left in $nginx_dir/shakekee-server.conf, so nginx fails open and protects nothing.${UI_NL}Fix: run the nginx installer again with --install (the daemon must be installed), or put the secret in by hand."
    fi
    sk_server_check nginx
  }

  recipe_doctor() {
    ui_step_start nginx "nginx is installed"
    if sk_server_select nginx; then
      ui_step_ok "${SK_SRV_VER:-} at $SK_SRV_BIN"
    else
      sk__doc_fail "no nginx program was found.${UI_NL}Fix: install nginx, or put it in the PATH."
      return 0
    fi
    ui_step_start auth_request "nginx has the auth_request module"
    if "$SK_SRV_BIN" -V 2>&1 </dev/null | grep -q 'http_auth_request_module'; then
      ui_step_ok
    else
      sk__doc_fail "this nginx was built without --with-http_auth_request_module.${UI_NL}Fix: install the nginx package of your distribution or the official one; they have it."
    fi
    if sk_rec_get nginx.dir; then
      sk_rec_get nginx.secret || true
      ui_step_start secret "The daemon's secret is filled in"
      if [ "$SK_RV" = filled ]; then
        ui_step_ok
      else
        sk__doc_fail "CHANGE-ME is still in shakekee-server.conf, so nginx fails open and protects nothing.${UI_NL}Fix: run the nginx installer again with --install after the daemon is installed, or put the secret in by hand."
      fi
    fi
    sk_server_check nginx || SK_DOC_BAD=$((SK_DOC_BAD + 1))
  }

  recipe_update() {
    sk_rec_get nginx.dir || die "nginx is recorded as installed without a directory; run the installer with --install"
    SERVER_DIR=$SK_RV
    SERVER_INSTALL=1
    nginx_fetch
    nginx_files "$SERVER_SRC"
    if sk_rec_get "server.nginx._global_" || [ -n "$(sk_server_recorded nginx)" ]; then
      if sk_server_select nginx && sk__srv_run_cmd test; then
        if [ "$SK_SRV_ISRUN" = yes ]; then
          sk__srv_run_cmd reload || true
        fi
      fi
    fi
  }

  recipe_uninstall() {
    nginx_bad=0
    # while an include in the configuration could not be taken out, the files it includes stay
    if ! sk_server_unwire nginx; then
      sk__srv_keep "$SK_INTEGRATION"
      return 1
    fi
    sk_server_restore_all "$SK_INTEGRATION"
    if [ "$SK_LEFT" -gt 0 ]; then
      nginx_bad=1
    else
      sk_unrecord nginx.dir
      sk_unrecord nginx.secret
    fi
    return "$nginx_bad"
  }
fi

sk_phases
}
sk_recipe_apache() {
# ---- apache: the integration files, filled in for this machine; they go into the server's include directory only with --install ----
SERVER_DIR=
SERVER_INSTALL=0
SERVER_FORCE=0
SERVER_UNPACK=shakekee-apache
SERVER_SITES=
SERVER_SECRET_FILE=

usage() {
  cat <<'USAGE'
Usage: curl -fsSL https://get.shakekee.com/apache | sh -s -- [options]
  (no option)     unpack the verified files into ./shakekee-apache and print what to do; nothing else is touched
  --install       copy the files to the standard places (see below) with the daemon's secret written; where you cannot
                  write there, sudo is used for those steps only, each command shown before it runs
  --dir DIR       with --install: use DIR as the base directory instead of the standard one
  --force         with --install: replace files that differ (a copy of the old one is kept; local edits are otherwise
                  kept and reported)
  --site HOST     with --install: a site to wire up (repeat it for more); the Include line is shown and, only when you
                  allow it, added to the <VirtualHost> of HOST
  --edit-server   allow the installer to edit the Apache configuration for --site without asking (a backup is made first,
                  configtest must pass, else everything is put back)
  --no-edit-server  never edit the Apache configuration; only show the lines
  --secret-file FILE  with --install: read the daemon's preflight secret from FILE
  --dry-run       download and verify, change nothing
Standard places: /usr/local/lib/shakekee/ (the two helpers, mode 0755) and /etc/shakekee/ (shakekee.conf, preflight-secret).
Your Apache configuration is edited only when you allow it; otherwise the script tells you which lines to add. Safe to run again.
USAGE
  sk_usage_common
}

while [ $# -gt 0 ]; do
  case $1 in
    --install) SERVER_INSTALL=1 ;;
    --force) SERVER_FORCE=1 ;;
    --dir) sk_value "$1" $#; SERVER_DIR=$2; shift ;;
    --site) sk_value "$1" $#; SERVER_SITES="${SERVER_SITES:+$SERVER_SITES }$2"; shift ;;
    --secret-file) sk_value "$1" $#; SERVER_SECRET_FILE=$2; shift ;;
    --edit-server) SK_SERVER_EDIT=yes ;;
    --no-edit-server) SK_SERVER_EDIT=no ;;
    --dry-run) SK_DRY=1 ;;
    --help | -h) usage; exit 0 ;;
    *) die "unknown option $1 (see --help)" ;;
  esac
  shift
done
case $SERVER_DIR in
  '' | /*) ;;
  *) SERVER_DIR=$PWD/$SERVER_DIR ;;
esac
# in the hub the person chose "install Apache" from a menu
if command -v sk_hub_main >/dev/null 2>&1; then
  SERVER_INSTALL=1
fi
for apache_s in $SERVER_SITES; do
  sk__srv_site_ok "$apache_s" || die "--site $apache_s is not a host name (letters, digits, dots and hyphens)"
done

# apache_base: where the files go (APACHE_LIB, APACHE_ETC). The per-site files that the daemon step and this recipe share stay
# where sk_server_rule puts them (/etc/shakekee/sites, or SK_SRV_SITESDIR), whatever --dir says.
apache_base() {
  if [ -n "$SERVER_DIR" ]; then
    APACHE_LIB=$SERVER_DIR/lib
    APACHE_ETC=$SERVER_DIR/etc
  else
    APACHE_LIB=/usr/local/lib/shakekee
    APACHE_ETC=/etc/shakekee
  fi
  SK_SRV_PKGDIR=$APACHE_ETC
}

# apache_put SRC NAME DEST MODE EXTRA-SED: one file of the package, filled in, put in place
apache_put() {
  SK_SRV_EXTRA_SED=$5
  sk_server_fill "$1/$2" "$SK_TASK/filled/$2"
  SK_SRV_EXTRA_SED=
  sk_server_place "$SK_TASK/filled/$2" "$3" "$4"
}

# apache_files SRC: the package's files, the daemon's addresses filled in, and the secret in its own file
apache_files() {
  sk_server_secret "$SERVER_SECRET_FILE" || true
  apache_base
  sk__srv_addr_ok "$SK_SRV_PREFLIGHT" || die "SK_SRV_PREFLIGHT=$SK_SRV_PREFLIGHT is not an address (HOST:PORT)"
  mkdir -p "$SK_TASK/filled"
  apache_h=${SK_SRV_PREFLIGHT%:*}
  apache_p=${SK_SRV_PREFLIGHT##*:}
  apache_sed="s|^HOST=127.0.0.1|HOST=$(sk__srv_sedq "$apache_h")|
s|^PORT=7878|PORT=$apache_p|
s|^SECRET_FILE=/etc/shakekee/preflight-secret|SECRET_FILE=$(sk__srv_sedq "$APACHE_ETC")/preflight-secret|"
  apache_put "$1" shakekee-preflight.sh "$APACHE_LIB/shakekee-preflight.sh" 0755 "$apache_sed"
  apache_put "$1" shakekee-wall.cgi "$APACHE_LIB/shakekee-wall.cgi" 0755 ""
  apache_put "$1" shakekee.conf "$APACHE_ETC/shakekee.conf" 0644 "s|/usr/local/lib/shakekee/|$(sk__srv_sedq "$APACHE_LIB")/|g"
  sk_record apache.dir "$SERVER_DIR"
  sk_record apache.etc "$APACHE_ETC"
  if [ -n "$SK_SRV_SECRET" ]; then
    # the helper reads it as it is: no newline at the end
    (umask 077 && printf '%s' "$SK_SRV_SECRET" >"$SK_TASK/filled/preflight-secret")
    sk_server_place "$SK_TASK/filled/preflight-secret" "$APACHE_ETC/preflight-secret" 0600
    sk_record apache.secret filled
  elif sk_rec_get apache.secret && [ "$SK_RV" = filled ] && [ -e "$APACHE_ETC/preflight-secret" ]; then
    # written by an earlier run and not found again (an update, say): the file stays
    say "$APACHE_ETC/preflight-secret keeps the secret it has."
  else
    sk_record apache.secret missing
    warn "The daemon's preflight secret was not found, so $APACHE_ETC/preflight-secret was not written. Write it there (shakekeed print-config --preflight-secret; no newline, mode 0600) or run this again with --secret-file FILE; without the secret Apache fails open and protects nothing."
  fi
}

# apache_fetch: the verified package, unpacked into $SERVER_SRC
apache_fetch() {
  sk_fetch_apache
  sk_extract_tgz "$SK_FILE" "$SK_TASK/unpacked"
  SERVER_SRC=$SK_TASK/unpacked/shakekee-apache
  [ -f "$SERVER_SRC/shakekee.conf" ] || die "the package does not contain shakekee-apache/shakekee.conf"
}

# apache_modules: the modules the integration needs that this Apache does not report; APACHE_MISSING lists them. Best effort
# (-M may need rights; then nothing is said to be missing).
apache_modules() {
  APACHE_MISSING=
  sk_server_select apache || return 0
  apache_m=$("${SK_SRV_CTL:-$SK_SRV_BIN}" -M 2>/dev/null </dev/null) || return 0
  for apache_mod in rewrite_module alias_module proxy_module proxy_http_module headers_module authz_core_module; do
    case $apache_m in
      *"${apache_mod} "*) ;;
      *) APACHE_MISSING="${APACHE_MISSING:+$APACHE_MISSING }$apache_mod" ;;
    esac
  done
  case $apache_m in
    *"cgid_module "* | *"cgi_module "*) ;;
    *) APACHE_MISSING="${APACHE_MISSING:+$APACHE_MISSING }cgid_module" ;;
  esac
}

recipe_detect() {
  need tar "install tar and run this again"
  sk_server_detect
  if sk_server_select apache; then
    apache_state='not running'
    if [ "$SK_SRV_ISRUN" = yes ]; then
      apache_state=running
    fi
    ui_say "Apache ${SK_SRV_VER:-} found at ${SK_SRV_CTL:-$SK_SRV_BIN} ($apache_state); configuration: ${SK_SRV_CONF:-not found}"
  else
    ui_note "Apache was not found on this machine. The files are prepared anyway; they work once Apache is installed."
  fi
}

recipe_plan() {
  sk_plan_add "Download the Apache package and check it against the SHA-256 written into this script"
  if [ "$SERVER_INSTALL" = 1 ]; then
    apache_base
    sk_plan_add "Put the two helpers in $APACHE_LIB and shakekee.conf in $APACHE_ETC, with the daemon's addresses filled in"
    sk_plan_add "Write the daemon's preflight secret to $APACHE_ETC/preflight-secret (mode 0600)"
    for apache_s in $SERVER_SITES; do
      sk_plan_add "Show the Include line for $apache_s and, only if you allow it, add it to its <VirtualHost> (backup first, configtest, undone if it fails)"
    done
  else
    sk_plan_add "Unpack the files into ./$SERVER_UNPACK; nothing else is touched"
  fi
  sk_server_plan_show
}

recipe_apply() {
  apache_fetch
  if [ "$SERVER_INSTALL" = 0 ]; then
    sk_unpack_only "$SERVER_SRC" "$SERVER_UNPACK"
    return 0
  fi
  apache_files "$SERVER_SRC"
  sk_server_choose_sites apache
  sk_server_offer_sites apache
}

recipe_verify() {
  if [ "$SERVER_INSTALL" = 0 ]; then
    return 0
  fi
  ui_step_start files "The helpers and shakekee.conf are in place"
  if [ -x "$APACHE_LIB/shakekee-preflight.sh" ] && [ -x "$APACHE_LIB/shakekee-wall.cgi" ] && [ -f "$APACHE_ETC/shakekee.conf" ]; then
    ui_step_ok
  else
    ui_step_fail "a file is missing in $APACHE_LIB or $APACHE_ETC"
  fi
  apache_modules
  ui_step_start modules "Apache has the modules the integration needs"
  if [ -z "$APACHE_MISSING" ]; then
    ui_step_ok
  else
    ui_step_warn "missing: $APACHE_MISSING${UI_NL}Fix: a2enmod rewrite alias proxy proxy_http headers cgid (Debian, Ubuntu), or add the LoadModule lines (other systems); the integration does not work without them.${UI_NL}Help: ${SK_ORIGIN:-}/help/SK-E611"
  fi
}

recipe_handover() {
  if [ "$SERVER_INSTALL" = 0 ]; then
    say "Next, with the files in $SERVER_UNPACK (or after: sh -s -- --install):"
  else
    say "Files are in $APACHE_LIB and $APACHE_ETC."
  fi
  if [ "$SERVER_INSTALL" = 1 ] && [ "${SK_SRV_WIRED_N:-0}" -gt 0 ]; then
    say "  The Include for the site(s) you chose is in place and configtest passed."
  else
    if [ "$SERVER_INSTALL" = 1 ] && [ -n "${SK_SRV_SECRET:-}" ]; then
      say "  1. the preflight secret is in $APACHE_ETC/preflight-secret; check HOST, PORT, FAIL in $APACHE_LIB/shakekee-preflight.sh;"
    else
      say "  1. write the preflight secret to /etc/shakekee/preflight-secret (no newline, mode 0600) and edit HOST, PORT, FAIL in shakekee-preflight.sh;"
    fi
    say "  2. Include shakekee.conf inside each protected <VirtualHost> before any catch-all ProxyPass, and edit the protected URL pattern;"
  fi
  say "  3. enable mod_rewrite, mod_proxy, mod_proxy_http, mod_alias, mod_cgid, mod_headers; apachectl configtest; reload."
  if [ "${SK_SRV_FAILED:-0}" -gt 0 ]; then
    exit 1
  fi
}

if [ "$SERVER_INSTALL" = 1 ] || [ "$SK_ACTION" != install ]; then
  recipe_status() {
    if ! sk_rec_get apache.dir; then
      ui_say "  Apache: no files are recorded as installed."
      return 0
    fi
    apache_d=$SK_RV
    if [ -n "$apache_d" ]; then
      APACHE_LIB=$apache_d/lib
      APACHE_ETC=$apache_d/etc
    else
      APACHE_LIB=/usr/local/lib/shakekee
      APACHE_ETC=/etc/shakekee
    fi
    ui_step_start files "The files in $APACHE_LIB and $APACHE_ETC"
    if [ -x "$APACHE_LIB/shakekee-preflight.sh" ] && [ -x "$APACHE_LIB/shakekee-wall.cgi" ] && [ -f "$APACHE_ETC/shakekee.conf" ]; then
      ui_step_ok
    else
      ui_step_fail "a file is missing.${UI_NL}Fix: run the Apache installer again with --install."
      return 1
    fi
    sk_rec_get apache.secret || true
    ui_step_start secret "The daemon's secret is in $APACHE_ETC/preflight-secret"
    if [ "$SK_RV" = filled ] && [ -e "$APACHE_ETC/preflight-secret" ]; then
      ui_step_ok
    else
      ui_step_warn "no secret was written, so Apache fails open and protects nothing.${UI_NL}Fix: run the Apache installer again with --install (the daemon must be installed), or write the secret there by hand."
    fi
    sk_server_check apache
  }

  recipe_doctor() {
    ui_step_start apache "Apache is installed"
    if sk_server_select apache; then
      ui_step_ok "${SK_SRV_VER:-} at ${SK_SRV_CTL:-$SK_SRV_BIN}"
    else
      sk__doc_fail "no Apache program was found.${UI_NL}Fix: install Apache, or put apache2ctl or apachectl in the PATH."
      return 0
    fi
    apache_modules
    ui_step_start modules "Apache has the modules the integration needs"
    if [ -z "$APACHE_MISSING" ]; then
      ui_step_ok
    else
      sk__doc_fail "missing: $APACHE_MISSING.${UI_NL}Fix: a2enmod rewrite alias proxy proxy_http headers cgid (Debian, Ubuntu), or add the LoadModule lines."
    fi
    if sk_rec_get apache.dir; then
      sk_rec_get apache.secret || true
      ui_step_start secret "The daemon's secret was written"
      if [ "$SK_RV" = filled ]; then
        ui_step_ok
      else
        sk__doc_fail "no secret was written, so Apache fails open and protects nothing.${UI_NL}Fix: run the Apache installer again with --install after the daemon is installed, or write the secret there by hand."
      fi
    fi
    sk_server_check apache || SK_DOC_BAD=$((SK_DOC_BAD + 1))
  }

  recipe_update() {
    sk_rec_get apache.dir || die "Apache is recorded as installed without a directory; run the installer with --install"
    SERVER_DIR=$SK_RV
    SERVER_INSTALL=1
    apache_fetch
    apache_files "$SERVER_SRC"
    if [ -n "$(sk_server_recorded apache)" ]; then
      if sk_server_select apache && sk__srv_run_cmd test; then
        if [ "$SK_SRV_ISRUN" = yes ]; then
          sk__srv_run_cmd reload || true
        fi
      fi
    fi
  }

  recipe_uninstall() {
    apache_bad=0
    # while an include in the configuration could not be taken out, the files it includes stay
    if ! sk_server_unwire apache; then
      sk__srv_keep "$SK_INTEGRATION"
      return 1
    fi
    sk_server_restore_all "$SK_INTEGRATION"
    if [ "$SK_LEFT" -gt 0 ]; then
      apache_bad=1
    else
      sk_unrecord apache.dir
      sk_unrecord apache.etc
      sk_unrecord apache.secret
    fi
    return "$apache_bad"
  }
fi

sk_phases
}
sk_recipe_caddy() {
# ---- caddy: the integration file, filled in for this machine; it goes into the server's include directory only with --install ----
SERVER_DIR=
SERVER_INSTALL=0
SERVER_FORCE=0
SERVER_UNPACK=shakekee-caddy
SERVER_SITES=
SERVER_SECRET_FILE=

usage() {
  cat <<'USAGE'
Usage: curl -fsSL https://get.shakekee.com/caddy | sh -s -- [options]
  (no option)     unpack the verified files into ./shakekee-caddy and print what to do; nothing else is touched
  --install       copy the file to the standard place (see below) with the daemon's secret filled in; where you cannot
                  write there, sudo is used for that step only, each command shown before it runs
  --dir DIR       with --install: use DIR as the base directory instead of the standard one
  --force         with --install: replace a file that differs (a copy of the old one is kept; local edits are otherwise
                  kept and reported)
  --site HOST     with --install: a site to wire up (repeat it for more); the import lines are shown and, only when you
                  allow it, added to the Caddyfile
  --edit-server   allow the installer to edit the Caddyfile for --site without asking (a backup is made first,
                  caddy validate must pass, else everything is put back)
  --no-edit-server  never edit the Caddyfile; only show the lines
  --secret-file FILE  with --install: read the daemon's preflight secret from FILE
  --dry-run       download and verify, change nothing
Standard place: /etc/caddy/shakekee/shakekee.Caddyfile. The Caddyfile that is edited is /etc/caddy/Caddyfile (SK_CADDYFILE names another).
Your Caddyfile is edited only when you allow it; otherwise the script tells you which lines to add. Safe to run again.
USAGE
  sk_usage_common
}

while [ $# -gt 0 ]; do
  case $1 in
    --install) SERVER_INSTALL=1 ;;
    --force) SERVER_FORCE=1 ;;
    --dir) sk_value "$1" $#; SERVER_DIR=$2; shift ;;
    --site) sk_value "$1" $#; SERVER_SITES="${SERVER_SITES:+$SERVER_SITES }$2"; shift ;;
    --secret-file) sk_value "$1" $#; SERVER_SECRET_FILE=$2; shift ;;
    --edit-server) SK_SERVER_EDIT=yes ;;
    --no-edit-server) SK_SERVER_EDIT=no ;;
    --dry-run) SK_DRY=1 ;;
    --help | -h) usage; exit 0 ;;
    *) die "unknown option $1 (see --help)" ;;
  esac
  shift
done
case $SERVER_DIR in
  '' | /*) ;;
  *) SERVER_DIR=$PWD/$SERVER_DIR ;;
esac
# in the hub the person chose "install Caddy" from a menu
if command -v sk_hub_main >/dev/null 2>&1; then
  SERVER_INSTALL=1
fi
for caddy_s in $SERVER_SITES; do
  sk__srv_site_ok "$caddy_s" || die "--site $caddy_s is not a host name (letters, digits, dots and hyphens)"
done

# caddy_base: where the file goes (SERVER_BASE). The per-site files that the daemon step and this recipe share stay where
# sk_server_rule puts them (next to the Caddyfile, or SK_SRV_SITESDIR), whatever --dir says.
caddy_base() {
  SERVER_BASE=${SERVER_DIR:-/etc/caddy/shakekee}
  SK_SRV_PKGDIR=$SERVER_BASE
}

# caddy_secret_file_mode: CADDY_MODE and the owner (SK_SRV_OWNER_UID, SK_SRV_OWNER_GID) of the file that holds the secret. Caddy
# runs as its own user and reads it, so it is root's file with the group of the Caddyfile (or of a group "caddy"), mode 0640;
# in a directory this user writes it is this user's, mode 0600. If no group can be found it stays readable by everyone, and
# the person is told.
caddy_secret_file_mode() {
  SK_SRV_OWNER_UID=
  SK_SRV_OWNER_GID=
  CADDY_MODE=0644
  if [ -z "$SK_SRV_SECRET" ]; then
    return 0
  fi
  sk__srv_priv_for "$SERVER_BASE/x"
  if [ "$SK_SRV_PRIV" = 0 ] && ! sk_is_root; then
    CADDY_MODE=0600
    return 0
  fi
  caddy_g=
  if sk_server_select caddy && [ -f "$SK_SRV_CONF" ]; then
    sk__srv_owner "$SK_SRV_CONF"
    if [ -n "$SK_SRV_GID" ] && [ "$SK_SRV_GID" != 0 ]; then
      caddy_g=$SK_SRV_GID
    fi
  fi
  if [ -z "$caddy_g" ] && command -v getent >/dev/null 2>&1; then
    caddy_g=$(getent group caddy 2>/dev/null | cut -d : -f 3) || caddy_g=
  fi
  if [ -n "$caddy_g" ]; then
    CADDY_MODE=0640
    SK_SRV_OWNER_UID=0
    SK_SRV_OWNER_GID=$caddy_g
  else
    warn "No group of Caddy's was found, so $SERVER_BASE/shakekee.Caddyfile (it holds the daemon's secret) is readable by every user of this machine. Make it readable only by Caddy's user and root (chgrp, chmod 0640)."
  fi
}

# caddy_files SRC: the file of the package, the secret and the daemon's addresses filled in, put in place
caddy_files() {
  sk_server_secret "$SERVER_SECRET_FILE" || true
  caddy_base
  # the filled files carry the secret: nobody else may read them, also in a hub run that opens SK_TASK to the site owner
  (umask 077 && mkdir -p "$SK_TASK/filled") || sk_fail 1 SK-E006 "Cannot create $SK_TASK/filled." "Check the permissions of ${TMPDIR:-/tmp}."
  # an earlier run filled the secret in and it cannot be found now (an update, say): the file that holds it stays as it is
  caddy_kept=0
  if [ -z "$SK_SRV_SECRET" ] && sk_rec_get caddy.secret && [ "$SK_RV" = filled ] && [ -e "$SERVER_BASE/shakekee.Caddyfile" ]; then
    caddy_kept=1
    say "$SERVER_BASE/shakekee.Caddyfile keeps the secret it has."
  else
    sk_server_fill "$1/shakekee.Caddyfile" "$SK_TASK/filled/shakekee.Caddyfile"
    caddy_secret_file_mode
    sk_server_place "$SK_TASK/filled/shakekee.Caddyfile" "$SERVER_BASE/shakekee.Caddyfile" "$CADDY_MODE"
    SK_SRV_OWNER_UID=
    SK_SRV_OWNER_GID=
  fi
  sk_record caddy.dir "$SERVER_BASE"
  if [ -n "$SK_SRV_SECRET" ] || [ "$caddy_kept" = 1 ]; then
    sk_record caddy.secret filled
  else
    sk_record caddy.secret missing
    warn "The daemon's preflight secret was not found, so CHANGE-ME is still in $SERVER_BASE/shakekee.Caddyfile. Put the secret in (shakekeed print-config --preflight-secret) or run this again with --secret-file FILE; with a wrong secret Caddy fails open and protects nothing."
  fi
}

# caddy_fetch: the verified package, unpacked into $SERVER_SRC
caddy_fetch() {
  sk_fetch_caddy
  sk_extract_tgz "$SK_FILE" "$SK_TASK/unpacked"
  SERVER_SRC=$SK_TASK/unpacked/shakekee-caddy
  [ -f "$SERVER_SRC/shakekee.Caddyfile" ] || die "the package does not contain shakekee-caddy/shakekee.Caddyfile"
}

recipe_detect() {
  need tar "install tar and run this again"
  sk_server_detect
  if sk_server_select caddy; then
    caddy_state='not running'
    if [ "$SK_SRV_ISRUN" = yes ]; then
      caddy_state=running
    fi
    ui_say "Caddy ${SK_SRV_VER:-} found at $SK_SRV_BIN ($caddy_state); Caddyfile: ${SK_SRV_CONF:-not found}"
  else
    ui_note "Caddy was not found on this machine. The file is prepared anyway; it works once Caddy is installed."
  fi
}

recipe_plan() {
  sk_plan_add "Download the Caddy package and check it against the SHA-256 written into this script"
  if [ "$SERVER_INSTALL" = 1 ]; then
    caddy_base
    sk_plan_add "Put shakekee.Caddyfile in $SERVER_BASE, with the daemon's secret and addresses filled in"
    for caddy_s in $SERVER_SITES; do
      sk_plan_add "Show the import lines for $caddy_s and, only if you allow it, add them to the Caddyfile (backup first, caddy validate, undone if it fails)"
    done
  else
    sk_plan_add "Unpack the file into ./$SERVER_UNPACK; nothing else is touched"
  fi
  sk_server_plan_show
}

recipe_apply() {
  caddy_fetch
  if [ "$SERVER_INSTALL" = 0 ]; then
    sk_unpack_only "$SERVER_SRC" "$SERVER_UNPACK"
    return 0
  fi
  caddy_files "$SERVER_SRC"
  sk_server_choose_sites caddy
  sk_server_offer_sites caddy
}

recipe_verify() {
  if [ "$SERVER_INSTALL" = 0 ]; then
    return 0
  fi
  ui_step_start files "shakekee.Caddyfile is in $SERVER_BASE"
  if [ -f "$SERVER_BASE/shakekee.Caddyfile" ]; then
    ui_step_ok
  else
    ui_step_fail "the file is missing in $SERVER_BASE"
  fi
}

recipe_handover() {
  if [ "$SERVER_INSTALL" = 0 ]; then
    say "Next, with the file in $SERVER_UNPACK (or after: sh -s -- --install, which copies it to /etc/caddy/shakekee/):"
  else
    say "File is in $SERVER_BASE."
  fi
  if [ "$SERVER_INSTALL" = 1 ] && [ "${SK_SRV_WIRED_N:-0}" -gt 0 ]; then
    say "  The imports for the site(s) you chose are in place and caddy validate passed."
    say "  What is left: import shakekee_protect <licensed host name> before the reverse_proxy of each protected route, then: caddy validate, caddy reload."
  else
    say "  1. import shakekee.Caddyfile once in your Caddyfile;"
    say "  2. in each site block: import shakekee_public, and import shakekee_protect <licensed host name> before the reverse_proxy of each protected route;"
    if [ "$SERVER_INSTALL" = 1 ] && [ -n "${SK_SRV_SECRET:-}" ]; then
      say "  3. the daemon's secret is already in the file; then: caddy validate, caddy reload."
    else
      say "  3. put your preflight secret in place of CHANGE-ME, then: caddy validate, caddy reload."
    fi
  fi
  if [ "${SK_SRV_FAILED:-0}" -gt 0 ]; then
    exit 1
  fi
}

if [ "$SERVER_INSTALL" = 1 ] || [ "$SK_ACTION" != install ]; then
  recipe_status() {
    if ! sk_rec_get caddy.dir; then
      ui_say "  Caddy: no file is recorded as installed."
      return 0
    fi
    caddy_dir=$SK_RV
    ui_step_start files "shakekee.Caddyfile in $caddy_dir"
    if [ -f "$caddy_dir/shakekee.Caddyfile" ]; then
      ui_step_ok
    else
      ui_step_fail "the file is missing.${UI_NL}Fix: run the Caddy installer again with --install."
      return 1
    fi
    sk_rec_get caddy.secret || true
    ui_step_start secret "The daemon's secret is filled in"
    if [ "$SK_RV" = filled ]; then
      ui_step_ok
    else
      ui_step_warn "CHANGE-ME was left in $caddy_dir/shakekee.Caddyfile, so Caddy fails open and protects nothing.${UI_NL}Fix: run the Caddy installer again with --install (the daemon must be installed), or put the secret in by hand."
    fi
    sk_server_check caddy
  }

  recipe_doctor() {
    ui_step_start caddy "Caddy is installed"
    if sk_server_select caddy; then
      ui_step_ok "${SK_SRV_VER:-} at $SK_SRV_BIN"
    else
      sk__doc_fail "no caddy program was found.${UI_NL}Fix: install Caddy 2, or put it in the PATH."
      return 0
    fi
    if sk_rec_get caddy.dir; then
      sk_rec_get caddy.secret || true
      ui_step_start secret "The daemon's secret is filled in"
      if [ "$SK_RV" = filled ]; then
        ui_step_ok
      else
        sk__doc_fail "CHANGE-ME is still in shakekee.Caddyfile, so Caddy fails open and protects nothing.${UI_NL}Fix: run the Caddy installer again with --install after the daemon is installed, or put the secret in by hand."
      fi
    fi
    sk_server_check caddy || SK_DOC_BAD=$((SK_DOC_BAD + 1))
  }

  recipe_update() {
    sk_rec_get caddy.dir || die "Caddy is recorded as installed without a directory; run the installer with --install"
    SERVER_DIR=$SK_RV
    SERVER_INSTALL=1
    caddy_fetch
    caddy_files "$SERVER_SRC"
    if sk_rec_get "server.caddy._global_" || [ -n "$(sk_server_recorded caddy)" ]; then
      if sk_server_select caddy && sk__srv_run_cmd test; then
        if [ "$SK_SRV_ISRUN" = yes ]; then
          sk__srv_run_cmd reload || true
        fi
      fi
    fi
  }

  recipe_uninstall() {
    caddy_bad=0
    # while an include in the configuration could not be taken out, the files it includes stay
    if ! sk_server_unwire caddy; then
      sk__srv_keep "$SK_INTEGRATION"
      return 1
    fi
    sk_server_restore_all "$SK_INTEGRATION"
    if [ "$SK_LEFT" -gt 0 ]; then
      caddy_bad=1
    else
      sk_unrecord caddy.dir
      sk_unrecord caddy.secret
    fi
    return "$caddy_bad"
  }
fi

sk_phases
}
sk_recipe_daemon() {
# ---- daemon: the ShakeKee daemon (shakekeed) wizard; served at /daemon and run by the hub as sk_recipe_daemon ----
# Ported from edge/shakekeed/packaging/install.sh to the phase contract of core.sh. It installs the signed release,
# sets the daemon up as a service (a systemd --user unit in your home when you run it as yourself, a hardened system unit
# when you run it as root), shows the lines for your web server, and registers the site with ShakeKee by driving
# `shakekeed register --json` (see edge/shakekeed/docs/CLI.md): email, mail confirmation (click the link, or paste the link
# or the code), the proof that you own the domain, activation. Then it checks the result and says what is left.
#
# Release layout on $SHAKEKEE_GET_URL (default: the origin this script came from):
#   /daemon/<version>/SHA256SUMS        lines "<sha256 hex>  shakekeed-<os>-<arch>"
#   /daemon/<version>/SHA256SUMS.sig    raw 64-byte Ed25519 signature over the exact bytes of SHA256SUMS
#   /daemon/<version>/shakekeed-<os>-<arch>
# <version> is `latest` or the --version given.
#
# Trust: the Ed25519 release key pinned below (SPKI, base64; the get service writes the real one in when it serves the
# script) is the only trust anchor. The signature on SHA256SUMS is checked with `openssl pkeyutl -verify -rawin`
# (OpenSSL 3), then the SHA-256 of the downloaded binary must equal the signed line for this platform. The binary does
# not verify itself. Not covered: freshness; an old, validly signed release can be replayed by whoever controls the
# download server.
#
# Test hooks (not for users): SHAKEKEE_GET_URL (https, or http on loopback), SHAKEKEE_RELEASE_PUBKEY_B64 (replaces
# the pinned key; a warning is printed), SK_DAEMON_PREFIX (a directory that /usr/local/bin, /etc and the unit
# directory of the system mode are put below), SK_DAEMON_MIN_RETRY (seconds, the shortest pause between two checks
# of the domain proof that this installer asks for itself; 30 is the registry's own pause).
PINNED_PUBKEY_B64='MCowBQYDK2VwAyEA6y2QYyKDvi2VrtFbs65tyOyqHH98KPdyAxhwGh0MT1U='
DM_URL=${SHAKEKEE_GET_URL:-$SK_ORIGIN}
DM_PREFLIGHT_PORT=7878
DM_PUBLIC_PORT=7879
DM_SVC_USER=shakekeed
DM_VERSION=latest
DM_HOSTS=
DM_EMAIL=
DM_METHOD=
DM_WEB_ROOT=
DM_META_FILE=
DM_PROTECT=
DM_REGISTER=1
DM_SERVICE_WANTED=1
DM_FOR=
DM_SERVER=
DM_NOREG_WHY=
DM_DL=
DM_MODE=
DM_BIN=
DM_CONF=
DM_STATE=
DM_UNIT=
DM_SVC=none
DM_ARCH=
DM_NEW_BIN=
DM_NEW_SUM=
DM_NEW_VERSION=
DM_LINGER=
DM_WANT_LINGER=0
DM_INDEX=
DM_RESUME=0
DM_PROBLEMS=0
DM_REG_DONE=0
DM_REG_PID=
DM_REG_RC=
DM_REG_EVENTS=
DM_REG_ERR=
DM_READER_PID=
DM_READER_FILE=
DM_EV=
DM_EV_N=0
DM_V=
DM_DNS=
DM_DNS_SHOWN=0
DM_STEP=

dm_usage() {
  cat <<'USAGE'
Usage: curl -fsSL https://get.shakekee.com/daemon | sh -s -- [options]
  --hostname H     host name to protect; repeat for several names under one host id
  --email E        address for the confirmation mail
  --method M       how you prove the domain is yours: daemon (default), file, meta or dns
  --web-root DIR   document root of the site (method file)
  --meta-file PATH the home page that gets the meta tag (method meta)
  --protect PATH   protected path rule, repeat (default /*; for example /login, /account/*)
  --server NAME    your web server: nginx, apache, caddy or none (default: the one that runs)
  --version V      release to install (default: latest)
  --no-register    install only, do not register
  --no-service     do not set up or start a service
  --edit-server    let the installer add the web server lines to your configuration without asking (a backup is made, the
                   server's own test must pass, else the file is put back); --no-edit-server never does
As yourself it installs into ~/.shakekee (a systemd --user service); as root, into the system (a system service).
Safe to run again: what is already in place is left alone.
USAGE
  sk_usage_common
}

# ---- validation (everything that ends up in a file or on a command line is checked here) ----

# Lowercase LDH name, at least two labels, labels of 1 to 63 characters, not an IP address.
dm_valid_hostname() {
  dm_h=$1
  [ -n "$dm_h" ] || return 1
  [ "${#dm_h}" -le 253 ] || return 1
  case $dm_h in
    *[!a-z0-9.-]* | .* | *. | *..* | -* | *- | *.-* | *-.*) return 1 ;;
  esac
  case $dm_h in
    *.*) ;;
    *) return 1 ;;
  esac
  case $dm_h in
    *[!0-9.]*) ;;
    *) return 1 ;;
  esac
  dm_rest=$dm_h
  while :; do
    dm_label=${dm_rest%%.*}
    [ "${#dm_label}" -le 63 ] || return 1
    case $dm_rest in
      *.*) dm_rest=${dm_rest#*.} ;;
      *) break ;;
    esac
  done
  return 0
}

dm_valid_abs_path() {
  case $1 in
    /*) ;;
    *) return 1 ;;
  esac
  case $1 in
    *[!A-Za-z0-9/._-]* | *..*) return 1 ;;
  esac
  return 0
}

dm_valid_rule() {
  case $1 in
    /*) ;;
    *) return 1 ;;
  esac
  case $1 in
    *[!A-Za-z0-9/._*-]* | *..*) return 1 ;;
  esac
  return 0
}

dm_valid_email() {
  case $1 in
    *[!A-Za-z0-9@._+-]* | '' | @* | *@ | *@*@*) return 1 ;;
    *@*.*) return 0 ;;
  esac
  return 1
}

dm_valid_version() {
  case $1 in
    '' | *[!A-Za-z0-9._-]*) return 1 ;;
  esac
  return 0
}

# dm_bad TEXT: an option or answer that cannot be used.
dm_bad() {
  sk_fail 2 SK-E704 "$1" "${2:-Run it with --help to see what an option takes.}"
}

dm_add_host() {
  dm_valid_hostname "$1" || dm_bad "'$1' is not a valid host name (lowercase letters, digits, '-' and '.', like example.com)."
  case " $DM_HOSTS " in *" $1 "*) ;; *) DM_HOSTS="$DM_HOSTS${DM_HOSTS:+ }$1" ;; esac
}

# ---- small helpers ----

# dm_can_ask: a person can be asked (a terminal, no --yes, not --json).
dm_can_ask() {
  [ "$SK_YES" != 1 ] && [ "$UI_MODE" != json ] && [ "$UI_TTY" = 1 ]
}

dm_nap() {
  sleep 0.2 2>/dev/null || sleep 1
}

# dm_do TEXT COMMAND...: a change, or only the words with --dry-run.
dm_do() {
  dm_dt=$1
  shift
  if [ "$SK_DRY" = 1 ]; then
    ui_say "[dry-run] $dm_dt"
    return 0
  fi
  "$@"
}

# dm_cli ARGS...: the shakekeed binary with the layout of this installation.
dm_cli() {
  if [ "$DM_MODE" = user ]; then
    "$DM_BIN" --home "$SK_HOME" "$@"
  else
    "$DM_BIN" --config "$DM_CONF" "$@"
  fi
}

# dm_cli_hint: the command line a person types, for messages.
dm_cli_hint() {
  if [ "$DM_MODE" = user ]; then
    DM_HINT="$DM_BIN --home $SK_HOME"
  else
    DM_HINT="$DM_BIN --config $DM_CONF"
  fi
}

# dm_layout: where everything lives in this mode.
dm_layout() {
  sk_home
  DM_MODE=$(sk_mode)
  if [ "$DM_MODE" = user ]; then
    DM_BIN=$SK_HOME/bin/shakekeed
    DM_CONF=$SK_HOME/shakekeed.toml
    DM_STATE=$SK_HOME/state
    DM_UNIT=${XDG_CONFIG_HOME:-${HOME:-/nonexistent}/.config}/systemd/user/shakekeed.service
  else
    DM_BIN=${SK_DAEMON_PREFIX:-}/usr/local/bin/shakekeed
    DM_CONF=${SK_DAEMON_PREFIX:-}/etc/shakekeed/shakekeed.toml
    # the daemon's state is the service user's; the installer's own files around it (install.json,
    # backups, examples, logs) stay root's, because root acts on what they say
    DM_STATE=$SK_HOME/state
    DM_UNIT=${SK_DAEMON_PREFIX:-}/etc/systemd/system/shakekeed.service
  fi
  dm_cli_hint
}

# dm_have_service: how the daemon can be kept running here; DM_SVC is systemd-user, systemd or none.
dm_have_service() {
  DM_SVC=none
  if ! command -v systemctl >/dev/null 2>&1; then
    return 0
  fi
  if [ "$DM_MODE" = user ]; then
    if systemctl --user show-environment >/dev/null 2>&1; then
      DM_SVC=systemd-user
    fi
  elif [ -d /run/systemd/system ] || [ -n "${SK_DAEMON_PREFIX:-}" ]; then
    DM_SVC=systemd
  fi
  dm_ctl_hint
}

# dm_systemctl ARGS...: systemctl for the service of this mode.
dm_systemctl() {
  if [ "$DM_SVC" = systemd-user ]; then
    systemctl --user "$@"
  else
    systemctl "$@"
  fi
}

# dm_ctl_hint: how to run systemctl by hand, for messages.
dm_ctl_hint() {
  if [ "$DM_SVC" = systemd-user ]; then
    DM_CTL='systemctl --user'
  else
    DM_CTL=systemctl
  fi
}

dm_reachable() {
  dm_cli status --json 2>/dev/null </dev/null | grep -q '"reachable":true'
}

# dm_registered HOST: HOST is in a registration that is active on this machine.
dm_registered() {
  grep -Fqs "\"$1\"" "$DM_STATE"/hosts/*.json 2>/dev/null
}

dm_pending() {
  [ -f "$DM_STATE/pending-registration.json" ]
}

# dm_pending_facts: the host names and the method of the saved registration, for what this run says and checks about
# them (the lines for the web server, the route check), when this run was not given them.
dm_pending_facts() {
  if [ -z "$DM_HOSTS" ]; then
    for dm_ph in $(awk '/"hostnames"/ { on = 1; next } on && /\]/ { exit } on { gsub(/[ ",]/, ""); if ($0 != "") print }' "$DM_STATE/pending-registration.json" 2>/dev/null); do
      if dm_valid_hostname "$dm_ph"; then
        DM_HOSTS="$DM_HOSTS${DM_HOSTS:+ }$dm_ph"
      fi
    done
  fi
  if [ -z "$DM_METHOD" ]; then
    dm_pm=$(sed -n 's/^ *"method": *"\([a-z]*\)".*/\1/p' "$DM_STATE/pending-registration.json" 2>/dev/null | head -n 1)
    # (not file: its configuration needs the document root, which a saved registration does not hold)
    case $dm_pm in
      daemon | meta | dns) DM_METHOD=$dm_pm ;;
    esac
  fi
}

# dm_all_registered: every host name given is registered already.
dm_all_registered() {
  [ -n "$DM_HOSTS" ] || return 1
  for dm_ar in $DM_HOSTS; do
    dm_registered "$dm_ar" || return 1
  done
  return 0
}

# dm_find_index HOST: DM_INDEX is the static home page (index.html) of HOST when one is found in the usual places.
dm_find_index() {
  DM_INDEX=
  dm_roots=${SHAKEKEE_SITE_ROOTS:-"/var/www /srv/www /srv ${HOME:-/nonexistent}/public_html ${HOME:-/nonexistent}/www"}
  for dm_r in ${DM_WEB_ROOT:-} $dm_roots; do
    for dm_d in "$dm_r" "$dm_r/$1" "$dm_r/html" "$dm_r/public_html" "$dm_r/htdocs" "$dm_r/httpdocs" "$dm_r/$1/html" "$dm_r/$1/public_html"; do
      for dm_f in index.html index.htm; do
        if [ -f "$dm_d/$dm_f" ] && [ ! -L "$dm_d/$dm_f" ]; then
          DM_INDEX=$dm_d/$dm_f
          return 0
        fi
      done
    done
  done
  return 1
}

# ---- events of `shakekeed register --json` ----

# dm_j KEY: DM_V is the value of KEY in the current event (a string, number or boolean); empty when absent.
dm_j() {
  DM_V=$(printf '%s\n' "$DM_EV" | sed -n -E -e 's/.*"'"$1"'":"(([^"\\]|\\.)*)".*/\1/p' -e 't' -e 's/.*"'"$1"'":([0-9][0-9.]*|true|false).*/\1/p' | head -n 1)
  DM_V=$(printf '%s' "$DM_V" | sed -e 's/\\"/"/g' -e 's/\\\\/\\/g' -e 's/\\n/ /g')
}

# dm_reg_next: DM_EV is the next event the register process wrote; fails when there is none yet.
dm_reg_next() {
  dm_en=$(wc -l <"$DM_REG_EVENTS" 2>/dev/null) || dm_en=0
  dm_en=$((dm_en + 0))
  if [ "$dm_en" -le "$DM_EV_N" ]; then
    return 1
  fi
  DM_EV_N=$((DM_EV_N + 1))
  DM_EV=$(sed -n "${DM_EV_N}p" "$DM_REG_EVENTS")
  sk_log "register event: $DM_EV"
  return 0
}

# dm_reg_start ARGS...: runs `shakekeed register --json ARGS` in the background; its events go to a file.
dm_reg_start() {
  if [ -z "$SK_TASK" ]; then
    SK_TASK=$(mktemp -d "${TMPDIR:-/tmp}/shakekee-install.XXXXXX") || sk_fail 1 SK-E006 "Cannot create a temporary directory." "Check that ${TMPDIR:-/tmp} is writable."
  fi
  dm_rn=$((${dm_rn:-0} + 1))
  DM_REG_EVENTS=$SK_TASK/register-$dm_rn.ndjson
  DM_REG_ERR=$SK_TASK/register-$dm_rn.err
  DM_REG_RC=$SK_TASK/register-$dm_rn.rc
  DM_REG_PID=
  DM_EV_N=0
  : >"$DM_REG_EVENTS"
  (
    trap - EXIT
    # the binary itself is the background job (not dm_cli, a function: that would make the job a subshell around it,
    # and a signal to the installer would end the subshell and leave `register` running with its lock)
    if [ "$DM_MODE" = user ]; then
      "$DM_BIN" --home "$SK_HOME" register --json "$@" >>"$DM_REG_EVENTS" 2>"$DM_REG_ERR" </dev/null &
    else
      "$DM_BIN" --config "$DM_CONF" register --json "$@" >>"$DM_REG_EVENTS" 2>"$DM_REG_ERR" </dev/null &
    fi
    dm_child=$!
    printf '%s\n' "$dm_child" >"$DM_REG_RC.pid"
    dm_rc=0
    wait "$dm_child" || dm_rc=$?
    printf '%s\n' "$dm_rc" >"$DM_REG_RC.tmp"
    mv -f "$DM_REG_RC.tmp" "$DM_REG_RC"
  ) >/dev/null 2>&1 &
  sk_log "register started: shakekeed register --json $*"
}

# dm_reg_stop: ends the register process and the reader of pasted text, if they run.
dm_reg_stop() {
  dm_reader_stop
  if [ -n "$DM_REG_RC" ] && [ ! -f "$DM_REG_RC" ] && [ -f "$DM_REG_RC.pid" ]; then
    dm_sp=$(cat "$DM_REG_RC.pid" 2>/dev/null) || dm_sp=
    if [ -n "$dm_sp" ]; then
      kill "$dm_sp" 2>/dev/null || true
    fi
  fi
}

# dm_reg_rc: DM_RC is the exit status of the register process; fails while it runs.
dm_reg_rc() {
  [ -f "$DM_REG_RC" ] || return 1
  DM_RC=$(cat "$DM_REG_RC" 2>/dev/null) || DM_RC=1
  return 0
}

# ---- the reader of pasted text (the mail link or code) ----

dm_reader_start() {
  if [ -n "$DM_READER_PID" ]; then
    return 0
  fi
  DM_READER_FILE=$SK_TASK/pasted
  rm -f "$DM_READER_FILE" "$DM_READER_FILE.tmp"
  (
    trap - EXIT
    dm_line=
    if IFS= read -r dm_line </dev/tty; then
      printf '%s\n' "$dm_line" >"$DM_READER_FILE.tmp"
      mv -f "$DM_READER_FILE.tmp" "$DM_READER_FILE"
    fi
  ) >/dev/null 2>&1 &
  DM_READER_PID=$!
}

dm_reader_stop() {
  if [ -n "$DM_READER_PID" ]; then
    kill "$DM_READER_PID" 2>/dev/null || true
    wait "$DM_READER_PID" 2>/dev/null || true
    DM_READER_PID=
  fi
}

# ---- explanations ----

# dm_reason_text REASON: what a failed domain check means, for DM_METHOD.
dm_reason_text() {
  case $1 in
    not_found)
      case $DM_METHOD in
        dns) DM_TXT='The TXT record was not found at the name servers of your domain.' ;;
        meta) DM_TXT='The meta tag was not found on your home page.' ;;
        file) DM_TXT='The validation file was not found on your site.' ;;
        *) DM_TXT='The daemon was not found behind your site. Your web server has to pass /.shakekee/ and /.well-known/shakekee/ to it.' ;;
      esac
      ;;
    key_mismatch) DM_TXT='The record or file is there, but with another value (an older registration?).' ;;
    redirected) DM_TXT='The name answers with a redirect or a CNAME; the proof has to be at the name itself.' ;;
    blocked_address) DM_TXT='The name points to an address that is not public, so ShakeKee does not ask it.' ;;
    timeout) DM_TXT='The answer took too long.' ;;
    pubkey_mismatch) DM_TXT='The daemon that answered is not this one (another daemon behind the same name?).' ;;
    hostname_taken) DM_TXT="${DM_HOSTS:-The host name} is still registered by another installation." ;;
    *) DM_TXT="The check failed ($1)." ;;
  esac
}

# dm_show_server_lines: the lines for the web server (the daemon rule step of the server installers when there
# is one, else the lines themselves).
dm_server_lines() {
  if [ "$DM_SERVER" = none ] && [ "${DM_FORCE_LINES:-0}" != 1 ]; then
    return 0
  fi
  dm_sh=$DM_HOSTS
  if [ -z "$dm_sh" ]; then
    dm_sh=example.com
  fi
  for dm_h in $dm_sh; do
    if command -v sk_server_rule >/dev/null 2>&1 && [ -n "$DM_SERVER" ] && [ "$DM_SERVER" != none ] && sk_server_select "$DM_SERVER"; then
      sk_server_rule "$DM_SERVER" "$dm_h" "127.0.0.1:$DM_PUBLIC_PORT"
    else
      dm_snippet "${DM_SERVER:-nginx}" "$dm_h"
    fi
  done
}

# dm_snippet SERVER SITE: the lines that route the public side of the daemon through the web server.
dm_snippet() {
  ui_title "Your web server must pass two paths to the daemon ($1, $2)"
  ui_note "The registry checks the daemon through your web server, and visitors reach the wall through it. Put these lines into the server block of $2 and reload the server."
  case $1 in
    caddy)
      ui_say "  handle /.shakekee/* /.well-known/shakekee/* {"
      ui_say "    reverse_proxy 127.0.0.1:$DM_PUBLIC_PORT { flush_interval -1 }"
      ui_say "  }"
      ;;
    apache)
      ui_say "  # needs mod_proxy and mod_proxy_http, inside the VirtualHost"
      ui_say "  ProxyPreserveHost On"
      ui_say "  ProxyPass /.shakekee/ http://127.0.0.1:$DM_PUBLIC_PORT/.shakekee/ flushpackets=on timeout=3600"
      ui_say "  ProxyPass /.well-known/shakekee/ http://127.0.0.1:$DM_PUBLIC_PORT/.well-known/shakekee/"
      ;;
    *)
      ui_say "  location ^~ /.shakekee/ {"
      ui_say "    proxy_pass http://127.0.0.1:$DM_PUBLIC_PORT;"
      ui_say "    proxy_http_version 1.1;"
      ui_say "    proxy_set_header Host \$http_host;"
      ui_say "    proxy_set_header X-SK-Secret \"\";"
      ui_say "    proxy_set_header X-Forwarded-For \$remote_addr;"
      ui_say "    proxy_set_header Forwarded \"\";"
      ui_say "    proxy_set_header Connection \"\";"
      ui_say "    proxy_buffering off;"
      ui_say "    proxy_cache off;"
      ui_say "    proxy_read_timeout 1h;"
      ui_say "    client_max_body_size 256k;"
      ui_say "  }"
      ui_say "  location ^~ /.well-known/shakekee/ {"
      ui_say "    proxy_pass http://127.0.0.1:$DM_PUBLIC_PORT;"
      ui_say "    proxy_set_header Host \$host;"
      ui_say "    proxy_set_header X-SK-Secret \"\";"
      ui_say "    proxy_set_header X-Forwarded-For \$remote_addr;"
      ui_say "    proxy_set_header Forwarded \"\";"
      ui_say "  }"
      ;;
  esac
  ui_note "The integration (plugin or middleware) asks the daemon what to do with a request: POST http://127.0.0.1:$DM_PREFLIGHT_PORT/v1/preflight with the header X-SK-Secret. The secret is in $DM_STATE/preflight.secret."
}

# ---- download and verify ----

dm_fetch() {
  case $1 in
    https://*) dm_proto='=https' ;;
    http://127.0.0.1[:/]* | http://localhost[:/]*) dm_proto='=http' ;;
    *) sk_fail 1 SK-E703 "Refusing to download over an insecure URL: $1" "Use an https address (SHAKEKEE_GET_URL)." ;;
  esac
  if [ "$DM_DL" = curl ]; then
    curl --fail --silent --show-error --location --proto "$dm_proto" --proto-redir "$dm_proto" \
      --max-time 600 --output "$2" "$1" 2>>"${SK_LOG:-/dev/null}" || return 1
  else
    wget -q -O "$2" "$1" 2>>"${SK_LOG:-/dev/null}" || return 1
  fi
}

dm_write_pubkey() {
  dm_key=${SHAKEKEE_RELEASE_PUBKEY_B64:-$PINNED_PUBKEY_B64}
  if [ -n "${SHAKEKEE_RELEASE_PUBKEY_B64:-}" ]; then
    ui_warn "using the release key from SHAKEKEE_RELEASE_PUBKEY_B64 instead of the pinned one"
  fi
  case $dm_key in
    REPLACE_WITH_*) sk_fail 1 SK-E703 "This installer has no release key pinned yet (an unreleased copy); refusing to continue." "Use the installer from get.shakekee.com." ;;
    *[!A-Za-z0-9+/=]* | '') sk_fail 1 SK-E703 "The release key is not valid base64." "Use the installer from get.shakekee.com." ;;
  esac
  {
    printf '%s\n' '-----BEGIN PUBLIC KEY-----'
    printf '%s\n' "$dm_key"
    printf '%s\n' '-----END PUBLIC KEY-----'
  } >"$SK_TASK/release.pem"
}

# dm_download: the signed manifest first, then the binary against the signed line. Sets DM_NEW_BIN and DM_NEW_SUM.
# Nothing on the system is changed (this runs with --dry-run, too).
dm_download() {
  dm_base=$DM_URL/daemon/$DM_VERSION
  dm_name=shakekeed-linux-$DM_ARCH
  if [ -z "$SK_TASK" ]; then
    SK_TASK=$(mktemp -d "${TMPDIR:-/tmp}/shakekee-install.XXXXXX") || sk_fail 1 SK-E006 "Cannot create a temporary directory." "Check that ${TMPDIR:-/tmp} is writable."
  fi
  ui_step_start download "Downloading $dm_name ($DM_VERSION) from $DM_URL"
  for dm_f in SHA256SUMS SHA256SUMS.sig "$dm_name"; do
    if ! dm_fetch "$dm_base/$dm_f" "$SK_TASK/$dm_f"; then
      ui_step_fail "could not download $dm_base/$dm_f"
      sk_fail 1 SK-E703 "The download failed: $dm_base/$dm_f. Nothing was installed." "Check the network connection, the proxy settings and the clock of this machine, then run the command again."
    fi
  done
  ui_step_ok
  ui_step_start verify "Checking the signature and the checksum"
  dm_write_pubkey
  if ! openssl pkeyutl -verify -pubin -inkey "$SK_TASK/release.pem" -rawin \
    -in "$SK_TASK/SHA256SUMS" -sigfile "$SK_TASK/SHA256SUMS.sig" >/dev/null 2>&1; then
    ui_step_fail "the signature of SHA256SUMS does not verify"
    sk_fail 1 SK-E703 "The signature of SHA256SUMS does not verify with the pinned release key. Nothing was installed." "Run the command again; if it keeps failing, the download is being changed on the way."
  fi
  dm_count=$(awk -v n="$dm_name" '$2 == n || $2 == "*" n { c++ } END { print c + 0 }' "$SK_TASK/SHA256SUMS")
  if [ "$dm_count" != 1 ]; then
    ui_step_fail "SHA256SUMS lists $dm_name $dm_count times"
    sk_fail 1 SK-E703 "SHA256SUMS lists $dm_name $dm_count times (expected once). Nothing was installed." "Run the command again later; the release may be incomplete."
  fi
  dm_expected=$(awk -v n="$dm_name" '$2 == n || $2 == "*" n { print $1 }' "$SK_TASK/SHA256SUMS")
  case $dm_expected in
    *[!0-9a-f]*) ui_step_fail "malformed checksum"; sk_fail 1 SK-E703 "SHA256SUMS has a malformed checksum for $dm_name. Nothing was installed." "Run the command again later." ;;
  esac
  if [ "${#dm_expected}" != 64 ]; then
    ui_step_fail "malformed checksum"
    sk_fail 1 SK-E703 "SHA256SUMS has a malformed checksum for $dm_name. Nothing was installed." "Run the command again later."
  fi
  dm_actual=$(sk_sha256 "$SK_TASK/$dm_name")
  if [ "$dm_actual" != "$dm_expected" ]; then
    ui_step_fail "checksum mismatch"
    sk_fail 1 SK-E703 "Checksum mismatch for $dm_name (signed $dm_expected, downloaded $dm_actual). Nothing was installed." "Run the command again; if it keeps failing, the download is being changed on the way."
  fi
  chmod 0755 "$SK_TASK/$dm_name"
  if ! DM_NEW_VERSION=$("$SK_TASK/$dm_name" version 2>/dev/null </dev/null); then
    ui_step_fail "the binary does not run"
    sk_fail 1 SK-E703 "The downloaded binary does not run on this system. Nothing was installed." "Check that this is a Linux machine of the CPU type $DM_ARCH."
  fi
  ui_step_ok "signature and checksum verified, $DM_NEW_VERSION"
  DM_NEW_BIN=$SK_TASK/$dm_name
  DM_NEW_SUM=$dm_actual
}

# ---- changes to the system ----

dm_install_binary() {
  ui_step_start binary "Installing $DM_BIN"
  if [ -x "$DM_BIN" ] && [ "$(sk_sha256 "$DM_BIN")" = "$DM_NEW_SUM" ]; then
    ui_step_ok "already this release"
    return 0
  fi
  if [ "$SK_DRY" = 1 ]; then
    ui_step_ok "[dry-run] would install it"
    return 0
  fi
  dm_bd=${DM_BIN%/*}
  if ! (umask 022 && mkdir -p "$dm_bd") 2>/dev/null || ! cp "$DM_NEW_BIN" "$dm_bd/.shakekeed.new" 2>/dev/null ||
    ! chmod 0755 "$dm_bd/.shakekeed.new" || ! mv -f "$dm_bd/.shakekeed.new" "$DM_BIN"; then
    rm -f "$dm_bd/.shakekeed.new" 2>/dev/null || true
    ui_step_fail "cannot write $DM_BIN"
    sk_fail 1 SK-E705 "Cannot install the daemon as $DM_BIN." "Check the permissions of ${DM_BIN%/*}, or use --home DIR for another place."
  fi
  sk_created "$DM_BIN"
  ui_step_ok
}

dm_create_user() {
  if id -u "$DM_SVC_USER" >/dev/null 2>&1; then
    return 0
  fi
  if [ "$SK_DRY" = 1 ]; then
    ui_say "[dry-run] create the system user $DM_SVC_USER"
    return 0
  fi
  dm_nologin=/bin/false
  for dm_s in /usr/sbin/nologin /sbin/nologin; do
    if [ -x "$dm_s" ]; then
      dm_nologin=$dm_s
      break
    fi
  done
  if command -v useradd >/dev/null 2>&1; then
    sk_step_run user "Creating the system user $DM_SVC_USER" useradd --system --user-group --home-dir "$DM_STATE" --no-create-home --shell "$dm_nologin" "$DM_SVC_USER" || sk_fail 1 SK-E705 "Cannot create the user $DM_SVC_USER." "Create it yourself and run the command again."
  elif command -v adduser >/dev/null 2>&1; then
    addgroup -S "$DM_SVC_USER" 2>/dev/null || true
    sk_step_run user "Creating the system user $DM_SVC_USER" adduser -S -D -H -h "$DM_STATE" -s "$dm_nologin" -G "$DM_SVC_USER" "$DM_SVC_USER" || sk_fail 1 SK-E705 "Cannot create the user $DM_SVC_USER." "Create it yourself and run the command again."
  else
    sk_fail 1 SK-E705 "Cannot create the user $DM_SVC_USER: neither useradd nor adduser is available." "Create the user yourself and run the command again."
  fi
  sk_record daemon.user "$DM_SVC_USER"
  sk_remains "The system user $DM_SVC_USER was created for the daemon. Remove it with: userdel $DM_SVC_USER"
}

dm_create_dirs() {
  if [ "$SK_DRY" = 1 ]; then
    ui_say "[dry-run] create $DM_STATE and the configuration directory"
    return 0
  fi
  if [ "$DM_MODE" = system ]; then
    # root's home around the state: the service user may pass through it, not list or write it
    install -d -m 0711 "$SK_HOME" && chmod 0711 "$SK_HOME" &&
      install -d -m 0700 -o "$DM_SVC_USER" -g "$DM_SVC_USER" "$DM_STATE" &&
      install -d -m 0750 -o root -g "$DM_SVC_USER" "${DM_CONF%/*}" ||
      sk_fail 1 SK-E705 "Cannot create $DM_STATE and ${DM_CONF%/*}." "Check the permissions."
  else
    sk_ensure_dir state
  fi
}

# dm_write_config: written only when there is no config; an existing one is the operator's.
dm_write_config() {
  ui_step_start config "Configuration $DM_CONF"
  if [ -e "$DM_CONF" ]; then
    dm_missing=
    for dm_h in $DM_HOSTS; do
      if ! grep -Fq "\"$dm_h\"" "$DM_CONF"; then
        dm_missing="$dm_missing $dm_h"
      fi
    done
    if [ -n "$dm_missing" ]; then
      ui_step_warn "it exists and is left as it is, but does not list:$dm_missing${UI_NL}Add a [[host]] block for each (hostname = \"...\") and restart the daemon."
    else
      ui_step_ok "exists, left as it is"
    fi
    return 0
  fi
  if [ "$SK_DRY" = 1 ]; then
    ui_step_ok "[dry-run] would write it"
    return 0
  fi
  dm_ct=$SK_TASK/config.toml
  {
    printf '%s\n' '# Written by the ShakeKee installer. `shakekeed print-config` shows the effective values.'
    printf 'registry_url = "%s"\n' "$DM_URL"
    # the sync backend of this ShakeKee (its API); the daemon's own default is the production one
    dm_api=${SK_DASHBOARD_API-}
    dm_api=${dm_api%/}
    case $dm_api in '' | https://api.shakekee.com) ;; *) printf 'api_base = "%s"\n' "$dm_api" ;; esac
    if [ "$DM_MODE" = system ] && [ "$DM_STATE" != /var/lib/shakekeed ]; then
      printf 'state_dir = "%s"\n' "$DM_STATE"
    fi
    for dm_h in $DM_HOSTS; do
      printf '\n[[host]]\nhostname = "%s"\n' "$dm_h"
      printf 'verification = "%s"\n' "${DM_METHOD:-daemon}"
      if [ -n "$DM_WEB_ROOT" ]; then
        printf 'web_root = "%s"\n' "$DM_WEB_ROOT"
      fi
      if [ -n "$DM_PROTECT" ]; then
        dm_list=
        set -f
        for dm_r in $DM_PROTECT; do
          dm_list="$dm_list${dm_list:+, }\"$dm_r\""
        done
        set +f
        printf 'protect = [%s]\n' "$dm_list"
      fi
    done
  } >"$dm_ct"
  if [ "$DM_MODE" = system ]; then
    install -m 0640 -o root -g "$DM_SVC_USER" "$dm_ct" "$DM_CONF" || sk_fail 1 SK-E705 "Cannot write $DM_CONF." "Check the permissions."
  else
    (umask 077 && cp "$dm_ct" "$DM_CONF") || sk_fail 1 SK-E705 "Cannot write $DM_CONF." "Check the permissions."
  fi
  sk_created "$DM_CONF"
  ui_step_ok "written"
}

# dm_unit_text: the text of the unit for this mode.
dm_unit_text() {
  if [ "$DM_MODE" = user ]; then
    cat <<UNIT
[Unit]
Description=ShakeKee local daemon
Documentation=https://shakekee.com

[Service]
Type=simple
ExecStart=$DM_BIN --home $SK_HOME run
Restart=on-failure
# a wall bundle that fails its check (shakekeed exits 78) is not retried: the daemon would ask the CDN again every few seconds
RestartPreventExitStatus=78
RestartSec=5
NoNewPrivileges=yes
LockPersonality=yes
MemoryDenyWriteExecute=yes
RestrictSUIDSGID=yes
RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX

[Install]
WantedBy=default.target
UNIT
  else
    cat <<UNIT
[Unit]
Description=ShakeKee local daemon
Documentation=https://shakekee.com
After=network-online.target
Wants=network-online.target

[Service]
Type=simple
User=$DM_SVC_USER
Group=$DM_SVC_USER
ExecStart=$DM_BIN --config $DM_CONF run
Restart=on-failure
# a wall bundle that fails its check (shakekeed exits 78) is not retried: the daemon would ask the CDN again every few seconds
RestartPreventExitStatus=78
RestartSec=5
LimitNOFILE=65536
ReadWritePaths=$DM_STATE
ReadOnlyPaths=${DM_CONF%/*}
NoNewPrivileges=yes
ProtectSystem=strict
ProtectHome=yes
PrivateTmp=yes
PrivateDevices=yes
ProtectKernelTunables=yes
ProtectKernelModules=yes
ProtectControlGroups=yes
RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX
RestrictNamespaces=yes
RestrictSUIDSGID=yes
LockPersonality=yes
MemoryDenyWriteExecute=yes
SystemCallArchitectures=native
CapabilityBoundingSet=

[Install]
WantedBy=multi-user.target
UNIT
  fi
}

# dm_process_pidfile / dm_process_alive / dm_process_start: the fallback when there is no service manager (a container,
# mostly). In system mode the daemon runs as its service user, through runuser or su.
dm_process_alive() {
  dm_pp=
  if [ -f "$SK_HOME/shakekeed.pid" ]; then
    dm_pp=$(cat "$SK_HOME/shakekeed.pid" 2>/dev/null) || dm_pp=
  fi
  case $dm_pp in
    '' | *[!0-9]*) return 1 ;;
  esac
  kill -0 "$dm_pp" 2>/dev/null
}

dm_process_start() {
  if dm_process_alive; then
    return 0
  fi
  sk_ensure_dir logs
  (
    trap - EXIT
    trap '' HUP
    exec </dev/null >>"$SK_HOME/logs/shakekeed.log" 2>&1
    if [ "$DM_MODE" != system ]; then
      exec "$DM_BIN" --home "$SK_HOME" run
    elif command -v runuser >/dev/null 2>&1; then
      exec runuser -u "$DM_SVC_USER" -- "$DM_BIN" --config "$DM_CONF" run
    else
      exec su -s /bin/sh "$DM_SVC_USER" -c "exec '$DM_BIN' --config '$DM_CONF' run"
    fi
  ) &
  printf '%s\n' "$!" >"$SK_HOME/shakekeed.pid"
  sk_record daemon.service process
}

dm_process_stop() {
  if dm_process_alive; then
    kill "$dm_pp" 2>/dev/null || true
  fi
  rm -f "$SK_HOME/shakekeed.pid" 2>/dev/null || true
}

# dm_run_command: DM_RUN_CMD starts the daemon by hand; in system mode as its user, with what this machine has to change
# users (sudo, else runuser, else su: a container often has no sudo).
dm_run_command() {
  DM_RUN_CMD="$DM_HINT run"
  if [ "$DM_MODE" != system ]; then
    return 0
  fi
  if command -v sudo >/dev/null 2>&1; then
    DM_RUN_CMD="sudo -u $DM_SVC_USER $DM_RUN_CMD"
  elif command -v runuser >/dev/null 2>&1; then
    DM_RUN_CMD="runuser -u $DM_SVC_USER -- $DM_RUN_CMD"
  else
    DM_RUN_CMD="su -s /bin/sh $DM_SVC_USER -c '$DM_RUN_CMD'"
  fi
}

# dm_service: the unit, and the daemon running. Without a service manager the start command is printed (or the
# daemon is started in the background when you agree), because the registration needs it running.
dm_service() {
  dm_run_command
  if [ "$DM_SERVICE_WANTED" = 0 ]; then
    ui_say "Not setting up a service (--no-service). Start the daemon yourself:"
    ui_copy_hint "$DM_RUN_CMD" "start command"
    return 0
  fi
  if [ "$DM_SVC" = none ]; then
    ui_warn "There is no service manager to keep the daemon running here (systemd is not available)."
    ui_say "Start it with:"
    ui_copy_hint "$DM_RUN_CMD" "start command"
    DM_BG=yes
    dm_bg_as=
    if [ "$DM_MODE" = system ]; then
      dm_bg_as="as $DM_SVC_USER; "
    fi
    if dm_can_ask; then
      ui_menu_single "The registration needs the daemon running. Start it now in the background (until the next reboot)?" "+yes|Yes, start it now|${dm_bg_as}stops when the machine restarts" "no|No, I will start it myself in another terminal"
      DM_BG=$UI_REPLY
    fi
    if [ "$DM_BG" = yes ]; then
      if [ "$SK_DRY" = 1 ]; then
        ui_say "[dry-run] start $DM_BIN in the background"
        return 0
      fi
      ui_step_start proc "Starting the daemon in the background"
      dm_process_start
      DM_I=0
      while [ "$DM_I" -lt 50 ] && ! dm_reachable; do
        DM_I=$((DM_I + 1))
        dm_nap
      done
      if dm_reachable; then ui_step_ok "log: $SK_HOME/logs/shakekeed.log"; else ui_step_warn "it does not answer yet; log: $SK_HOME/logs/shakekeed.log"; fi
    fi
    return 0
  fi
  if [ -z "$SK_TASK" ]; then
    SK_TASK=$(mktemp -d "${TMPDIR:-/tmp}/shakekee-install.XXXXXX") || sk_fail 1 SK-E006 "Cannot create a temporary directory." "Check that ${TMPDIR:-/tmp} is writable."
  fi
  dm_unit_text >"$SK_TASK/shakekeed.service"
  ui_step_start unit "Service $DM_UNIT"
  if [ -f "$DM_UNIT" ] && cmp -s "$SK_TASK/shakekeed.service" "$DM_UNIT"; then
    ui_step_ok "up to date"
  elif [ "$SK_DRY" = 1 ]; then
    ui_step_ok "[dry-run] would write it"
  else
    if ! (umask 022 && mkdir -p "${DM_UNIT%/*}" && cp "$SK_TASK/shakekeed.service" "$DM_UNIT.new" && mv -f "$DM_UNIT.new" "$DM_UNIT") 2>/dev/null; then
      rm -f "$DM_UNIT.new" 2>/dev/null || true
      ui_step_fail "cannot write $DM_UNIT"
      sk_fail 1 SK-E705 "Cannot write the service file $DM_UNIT." "Check the permissions, or use --no-service."
    fi
    sk_created "$DM_UNIT"
    ui_step_ok "written"
  fi
  if [ "$SK_DRY" = 1 ]; then
    ui_say "[dry-run] enable and start the shakekeed service"
    return 0
  fi
  if dm_systemctl daemon-reload >>"${SK_LOG:-/dev/null}" 2>&1 && sk_step_run start "Enabling and starting the service" dm_systemctl enable --now shakekeed; then
    DM_I=0
    while [ "$DM_I" -lt 50 ] && ! dm_reachable; do
      DM_I=$((DM_I + 1))
      dm_nap
    done
    if ! dm_reachable; then
      ui_warn "The service is started, but the daemon does not answer yet. See: $DM_CTL status shakekeed"
    fi
  else
    ui_warn "Could not start the service (see: $DM_CTL status shakekeed). The registration with the method daemon needs it running."
  fi
  sk_record daemon.service "$DM_SVC"
}

# dm_restart: after the registration the daemon has to read its new host.
dm_restart() {
  if [ "$SK_DRY" = 1 ] || [ "$DM_SERVICE_WANTED" = 0 ]; then
    return 0
  fi
  case $DM_SVC in
    systemd | systemd-user) sk_step_run restart "Restarting the service to load the new registration" dm_systemctl restart shakekeed || ui_warn "Could not restart the service; do it yourself: $DM_CTL restart shakekeed" ;;
    *)
      if dm_process_alive; then
        dm_process_stop
        dm_process_start
      fi
      ;;
  esac
}

# dm_linger: a user service stops at logout unless lingering is on.
dm_linger() {
  if [ "$DM_MODE" != user ] || [ "$DM_SVC" != systemd-user ] || [ "$DM_WANT_LINGER" != 1 ]; then
    return 0
  fi
  if [ "$SK_DRY" = 1 ]; then
    ui_say "[dry-run] sudo loginctl enable-linger $SK_FACT_USER"
    return 0
  fi
  if sk_sudo loginctl enable-linger "$SK_FACT_USER"; then
    sk_record daemon.linger yes
    sk_remains "Lingering was enabled for $SK_FACT_USER so that the daemon keeps running after you log out. To turn it off: sudo loginctl disable-linger $SK_FACT_USER"
  else
    ui_warn "Could not enable lingering. The daemon will stop when you log out; start it again with: systemctl --user start shakekeed"
  fi
}

# ---- the web server step ----

dm_server_step() {
  if [ "$DM_REGISTER" = 0 ] && [ -z "$DM_HOSTS" ]; then
    return 0
  fi
  dm_server_lines
}

# ---- registration ----

# dm_register_args: the arguments of `register` for this run (DM_RESUME=1 continues a saved registration).
dm_register_args() {
  set --
  if [ "$DM_RESUME" = 1 ]; then
    set -- --resume
  else
    set -- --method "${DM_METHOD:-daemon}"
    for dm_h in $DM_HOSTS; do
      set -- "$@" --hostname "$dm_h"
    done
    if [ -n "$DM_EMAIL" ]; then
      set -- "$@" --email "$DM_EMAIL"
    fi
    if [ -n "$DM_WEB_ROOT" ]; then
      set -- "$@" --web-root "$DM_WEB_ROOT"
    fi
    if [ -n "$DM_META_FILE" ]; then
      set -- "$@" --meta-file "$DM_META_FILE"
    fi
  fi
  DM_REG_ARGS=
  for dm_a in "$@"; do
    DM_REG_ARGS="$DM_REG_ARGS${DM_REG_ARGS:+$UI_NL}$dm_a"
  done
}

# dm_continue_pending: a registration that was started earlier is saved here; succeeds when it is to be continued
# (the person is asked where that can be done; nobody asked means continue).
dm_continue_pending() {
  if dm_can_ask; then
    ui_menu_single "A registration that was started earlier is saved on this machine." "+resume|Continue it|the mail was sent already" "new|Start a new registration|a new mail is sent"
    [ "$UI_REPLY" = resume ]
  else
    ui_say "A registration that was started earlier is saved here; it is continued."
  fi
}

# dm_close_step OK|FAIL DETAIL: ends the open step line, if any.
dm_close_step() {
  if [ -n "$DM_STEP" ]; then
    dm_cs=$DM_STEP
    DM_STEP=
    case $1 in
      ok) ui_step_ok "${2:-}" ;;
      warn) ui_step_warn "${2:-}" ;;
      *) ui_step_fail "${2:-}" ;;
    esac
  fi
}

dm_open_step() {
  dm_close_step ok
  DM_STEP=$1
  ui_step_start "$1" "$2"
}

# dm_show_dns: the TXT records to publish.
dm_show_dns() {
  [ -n "$DM_DNS" ] || return 0
  ui_title "Add this DNS record at your DNS provider"
  ui_note "A TXT record proves that the domain is yours. Add it now, DNS can be slow. ShakeKee reads it straight from the name servers of your domain, so it counts as soon as your provider has saved it."
  while IFS='|' read -r dm_dh dm_dn dm_dv; do
    [ -n "$dm_dh" ] || continue
    ui_say "  For $dm_dh:"
    ui_copy_hint "$dm_dn" "name (type TXT)"
    ui_copy_hint "$dm_dv" "value"
  done <<DM_DNS_LIST
$DM_DNS
DM_DNS_LIST
  DM_DNS_SHOWN=1
}

# dm_parse_dns: DM_DNS from the dns member of the current (registered) event: lines host|name|value.
dm_parse_dns() {
  DM_DNS=$(printf '%s\n' "$DM_EV" | sed -n 's/.*"dns":\[\(.*\)\].*/\1/p' | sed 's/},{/}\
{/g' | sed -n 's/.*"hostname":"\([^"]*\)".*"name":"\([^"]*\)".*"value":"\([^"]*\)".*/\1|\2|\3/p')
  if [ -z "$DM_DNS" ]; then
    # members in another order
    DM_DNS=$(printf '%s\n' "$DM_EV" | sed -n 's/.*"dns":\[\(.*\)\].*/\1/p' | sed 's/},{/}\
{/g' | sed -n 's/.*"name":"\([^"]*\)".*"value":"\([^"]*\)".*"hostname":"\([^"]*\)".*/\3|\1|\2/p')
  fi
}

# dm_detail_lines: the per-server answers of a failed DNS check, one line each.
dm_detail_lines() {
  dm_dl=$(printf '%s\n' "$DM_EV" | sed -n 's/.*"detail":\[\(.*\)\].*/\1/p' | sed 's/},{/}\
{/g')
  printf '%s\n' "$dm_dl" | sed -n -e 's/.*"server":"\([^"]*\)".*"result":"\([^"]*\)".*/  \1: \2/p' -e 't' -e 's/.*"result":"\([^"]*\)".*"server":"\([^"]*\)".*/  \2: \1/p'
}

# dm_verify_trigger: asks the daemon to start the next check; the outcome arrives as an event of the running register.
# Sets DM_CD to the seconds to wait when the registry's 30 second pause between checks is still running.
dm_verify_trigger() {
  DM_CD=0
  dm_vo=$(dm_cli verify --json --wait 0 2>/dev/null </dev/null) && return 0
  dm_vrc=$?
  DM_EV=$dm_vo
  case $dm_vrc in
    20 | 15)
      dm_j retry_after_secs
      DM_CD=${DM_V:-30}
      case $DM_CD in '' | *[!0-9]*) DM_CD=30 ;; esac
      ;;
    21) DM_CD=5 ;;
    *)
      dm_j message
      ui_warn "Could not ask for a check: ${DM_V:-exit status $dm_vrc}"
      DM_CD=30
      ;;
  esac
  return 1
}

# dm_cap TEXT: DM_CAP is TEXT with a capital first letter.
dm_cap() {
  DM_CAP=$(printf '%s' "$1" | cut -c1 | tr 'a-z' 'A-Z')${1#?}
}

# dm_pasted TEXT: what the person pasted, given to `shakekeed confirm` (on its standard input: it holds a secret).
dm_pasted() {
  dm_pt=$1
  case $dm_pt in
    '') return 0 ;;
  esac
  dm_prc=0
  dm_po=$(printf '%s\n' "$dm_pt" | dm_cli confirm --json - 2>/dev/null) || dm_prc=$?
  DM_EV=$dm_po
  dm_j message
  dm_pm=$DM_V
  dm_j retry_after_secs
  dm_pr=$DM_V
  case $dm_prc in
    0)
      if printf '%s' "$dm_po" | grep -q '"matches_pending":false'; then
        ui_warn "That link belongs to another registration, not the one this installer started. Use the mail with the newest date."
        return 0
      fi
      ui_say "  Accepted. The registry is confirming the email address ..."
      DM_PASTE_OK=1
      ;;
    10) ui_warn "That is not the link or the code from the ShakeKee mail${dm_pm:+ ($dm_pm)}. Paste the whole link, or the code like K7QX-92MD." ;;
    11)
      dm_cap "${dm_pm:-that code is not right}"
      ui_warn "$DM_CAP. Check the mail and try again; after 5 wrong codes the mail is cancelled."
      ;;
    12) DM_PASTE_DEAD=1; ui_warn "The link or the code can no longer be used${dm_pm:+: $dm_pm}." ;;
    13) ui_warn "The registry cannot be reached right now${dm_pm:+: $dm_pm}. Try again in a moment." ;;
    14) ui_warn "No registration is waiting on this machine yet. Try again in a moment." ;;
    15) ui_warn "Too many tries; wait ${dm_pr:-a minute} seconds, then try again." ;;
    *) ui_warn "Could not use that${dm_pm:+: $dm_pm}. Try again." ;;
  esac
  return 0
}

# dm_unattended_wait_note: what a run without a person says while it waits for the mail.
dm_confirm_screen() {
  dm_cli_hint
  if dm_can_ask; then
    ui_note "Open the mail from ShakeKee and click the link, on any device. Or type the code from the mail here (like K7QX-92MD), or paste the whole link on one line, and press Enter."
    printf '%s' '> ' >&2
    dm_reader_start
  else
    ui_note "Open the mail from ShakeKee and click the link, on any device; this installer goes on by itself when it did. Nobody can type here, so to use the code instead, run this in another terminal:"
    ui_copy_hint "$DM_HINT confirm CODE" "replace CODE with the code from the mail"
  fi
}

# dm_proof_menu: after the mail is confirmed and the proof cannot be seen by the daemon itself (dns, meta without a file).
dm_proof_menu() {
  if [ "$DM_METHOD" = dns ] && [ "$DM_DNS_SHOWN" = 0 ]; then
    dm_show_dns
  fi
  if [ "$DM_METHOD" = meta ]; then
    ui_title "Add this tag to the <head> of your home page"
    ui_copy_hint "<meta name=\"shakekee-validation\" content=\"$DM_VKEY\">" "the meta tag"
  fi
  if dm_can_ask; then
    ui_menu_single "Is the proof in place?" "+check|Yes, check it now|ShakeKee looks it up itself" "again|Show it again" "stop|Stop here|the registration stays pending; continue later"
    case $UI_REPLY in
      check) DM_VERIFY_NOW=1 ;;
      again) DM_DNS_SHOWN=0; DM_PROOF_AGAIN=1 ;;
      stop) DM_STOP=1 ;;
    esac
  else
    ui_note "Waiting for the proof. This installer asks ShakeKee to check it every minute or so, for up to half an hour."
    DM_NEXT=0
    DM_AUTO=1
  fi
}

# dm_after_failed_check: what to do after a failed check.
dm_after_failed_check() {
  if dm_can_ask; then
    ui_menu_single "What now?" "+again|Check again|after a short pause" "lines|Show the web server lines again" "stop|Stop here|the registration stays pending; continue later: shakekeed register --resume"
    case $UI_REPLY in
      again) DM_NEXT=1 ;;
      lines) DM_FORCE_LINES=1; dm_server_lines; DM_FORCE_LINES=0; DM_NEXT=1 ;;
      stop) DM_STOP=1 ;;
    esac
    if [ "$DM_NEXT" = 1 ]; then
      DM_NEXT=0
      DM_VERIFY_NOW=1
    fi
  else
    DM_AUTO=1
    DM_NEXT=$(($(date +%s) + ${DM_RETRY:-60}))
  fi
}

# dm_daemon_serves_file_proof: with the method file the proof is a file in the web root, but the web server lines
# this installer shows (or adds) send /.well-known/shakekee/ to the daemon, which would hide that file and answer 404.
# The daemon answers that path from validation/<host>.json of its state, so it gets the same body.
dm_daemon_serves_file_proof() {
  case $DM_VKEY in
    '' | *[!A-Za-z0-9_-]*) return 0 ;;
  esac
  mkdir -p "$DM_STATE/validation" 2>/dev/null || true
  for dm_fh in $DM_HOSTS; do
    dm_ff=$DM_STATE/validation/$dm_fh.json
    if (umask 022 && printf '{"validation_key":"%s"}' "$DM_VKEY" >"$dm_ff") 2>/dev/null; then
      if [ "$DM_MODE" = system ]; then
        chown "$DM_SVC_USER:$DM_SVC_USER" "$dm_ff" 2>/dev/null || true
      fi
    else
      ui_warn "Could not write $dm_ff. If the web server sends /.well-known/shakekee/ to the daemon, the validation file in the web root is hidden by it."
    fi
  done
}

# dm_handle: acts on the current event.
dm_handle() {
  dm_j event
  case $DM_V in
    pow) ;;
    registered)
      dm_j reg_id
      DM_REG_ID=$DM_V
      dm_j host_id
      DM_HOST_ID=$DM_V
      dm_j validation_key
      DM_VKEY=$DM_V
      dm_parse_dns
      dm_close_step ok "registration $DM_REG_ID"
      sk_record daemon.registration "$DM_HOST_ID"
      if [ -n "$DM_DNS" ]; then
        while IFS='|' read -r dm_dh dm_dn dm_dv; do
          [ -n "$dm_dn" ] || continue
          sk_remains "Remove the DNS TXT record $dm_dn ($dm_dh) at your DNS provider when you no longer use ShakeKee for $dm_dh."
        done <<DM_DNS_LIST2
$DM_DNS
DM_DNS_LIST2
        dm_show_dns
      fi
      ;;
    proof_placed)
      dm_j method
      dm_pm=$DM_V
      dm_j path
      case $dm_pm in
        file)
          dm_daemon_serves_file_proof
          ui_say "  The validation file is in place: $DM_V"
          sk_remains "Remove the validation file $DM_V from your web root."
          ;;
        meta)
          # shakekeed register keeps the copy: in the home in user mode, in the state directory in system mode
          dm_bk=$SK_HOME/backups
          if [ "$DM_MODE" = system ]; then
            dm_bk=$DM_STATE/backups
          fi
          ui_say "  The meta tag is in place: $DM_V (a copy of the page from before is in $dm_bk/)"
          sk_remains "Remove the meta tag shakekee-validation from $DM_V (the page from before is saved in $dm_bk/)."
          ;;
        *) ui_say "  The daemon serves the proof." ;;
      esac
      ;;
    mail_sent)
      dm_j email
      dm_close_step ok
      ui_say "A confirmation mail was sent to ${DM_V:-$DM_EMAIL}."
      ;;
    waiting)
      dm_j for
      case $DM_V in
        confirm) dm_confirm_screen ;;
        proof) dm_proof_menu ;;
      esac
      ;;
    email_confirmed)
      dm_reader_stop
      ui_say "Email address confirmed."
      ;;
    verifying)
      dm_open_step verify "ShakeKee is checking the proof of ${DM_HOSTS:-the host} (it looks it up itself)"
      ;;
    verify_failed)
      dm_j reason
      dm_vr=$DM_V
      dm_j message
      dm_vm=$DM_V
      dm_j retry_after_secs
      DM_RETRY=${DM_V:-60}
      case $DM_RETRY in '' | *[!0-9]*) DM_RETRY=60 ;; esac
      if [ "$DM_RETRY" -lt "${SK_DAEMON_MIN_RETRY:-30}" ]; then DM_RETRY=${SK_DAEMON_MIN_RETRY:-30}; fi
      dm_reason_text "$dm_vr"
      dm_vd=$(dm_detail_lines)
      if [ -n "$DM_STEP" ]; then
        dm_close_step fail "$DM_TXT${dm_vd:+${UI_NL}Name servers asked:${UI_NL}$dm_vd}${dm_vm:+${UI_NL}$dm_vm}"
      else
        ui_warn "$DM_TXT${dm_vd:+ Name servers asked:${UI_NL}$dm_vd}"
      fi
      if [ "$DM_METHOD" = dns ]; then
        ui_note "A DNS record can take a while to show up, so waiting is normal. ShakeKee asks the authoritative name servers of your domain directly, which is quicker than the DNS caches of the internet."
      fi
      dm_after_failed_check
      ;;
    verified)
      dm_close_step ok "the proof is valid"
      ;;
    dns_published)
      ui_say "ShakeKee published the DNS records of the registration, the signed licence among them."
      ;;
    active)
      dm_j host_id
      dm_close_step ok
      DM_REG_DONE=1
      ;;
    error)
      dm_j code
      DM_ERR_CODE=$DM_V
      dm_j message
      DM_ERR_MSG=$DM_V
      dm_close_step fail "$DM_ERR_MSG"
      ;;
  esac
}

# dm_register_run: one run of the register process until it ends or the person stops. Returns 0 when active.
dm_register_run() {
  DM_STOP=0
  DM_AUTO=0
  DM_NEXT=0
  DM_VERIFY_NOW=0
  DM_PASTE_OK=0
  DM_PASTE_DEAD=0
  DM_PROOF_AGAIN=0
  DM_ERR_CODE=
  DM_ERR_MSG=
  DM_RETRY=60
  DM_REG_DONE=0
  DM_DNS=
  DM_DNS_SHOWN=0
  dm_register_args
  set --
  if [ -n "$DM_REG_ARGS" ]; then
    while IFS= read -r dm_a; do
      set -- "$@" "$dm_a"
    done <<DM_ARGS
$DM_REG_ARGS
DM_ARGS
  fi
  if [ "$DM_RESUME" = 1 ]; then
    dm_open_step register "Continuing the saved registration"
  else
    dm_open_step register "Registering $DM_HOSTS with ShakeKee (solving a small puzzle first)"
  fi
  trap 'dm_reg_stop; exit 130' INT
  trap 'dm_reg_stop; exit 143' TERM
  trap 'dm_reg_stop; exit 129' HUP
  dm_reg_start "$@"
  while :; do
    dm_ended=0
    if dm_reg_rc; then
      dm_ended=1
    fi
    if dm_reg_next; then
      dm_handle
      if [ "$DM_REG_DONE" = 1 ]; then
        break
      fi
      if [ "$DM_STOP" = 1 ]; then
        break
      fi
      continue
    fi
    if [ "$dm_ended" = 1 ]; then
      break
    fi
    # the person pasted something
    if [ -n "$DM_READER_PID" ] && [ -s "$DM_READER_FILE" ]; then
      dm_pt=$(cat "$DM_READER_FILE")
      DM_READER_PID=
      rm -f "$DM_READER_FILE"
      dm_pasted "$dm_pt"
      if [ "$DM_PASTE_DEAD" = 1 ]; then
        break
      fi
      if [ "$DM_PASTE_OK" = 0 ]; then
        printf '%s' '> ' >&2
        dm_reader_start
      fi
      continue
    fi
    if [ "$DM_VERIFY_NOW" = 1 ]; then
      DM_VERIFY_NOW=0
      if ! dm_verify_trigger; then
        if [ "$DM_CD" -gt 0 ]; then
          ui_say "  The registry checks at most every 30 seconds; checking again in $DM_CD seconds."
          DM_NEXT=$(($(date +%s) + DM_CD))
          DM_AUTO=1
        fi
      fi
      continue
    fi
    if [ "$DM_AUTO" = 1 ] && [ "$DM_NEXT" != 0 ] && [ "$(date +%s)" -ge "$DM_NEXT" ]; then
      DM_NEXT=0
      if dm_verify_trigger; then
        DM_AUTO=0
      else
        DM_NEXT=$(($(date +%s) + ${DM_CD:-30}))
      fi
      continue
    fi
    if [ "$DM_AUTO" = 1 ] && [ "$DM_NEXT" = 0 ]; then
      if dm_verify_trigger; then
        DM_AUTO=0
      else
        DM_NEXT=$(($(date +%s) + ${DM_CD:-30}))
      fi
      continue
    fi
    if [ "$DM_PROOF_AGAIN" = 1 ]; then
      DM_PROOF_AGAIN=0
      dm_proof_menu
      continue
    fi
    dm_nap
  done
  dm_reader_stop
  dm_close_step fail
  trap 'exit 130' INT
  trap 'exit 143' TERM
  trap 'exit 129' HUP
  if [ "$DM_REG_DONE" = 1 ]; then
    # the process ends by itself once it is active
    dm_w=0
    while ! dm_reg_rc && [ "$dm_w" -lt 50 ]; do
      dm_w=$((dm_w + 1))
      dm_nap
    done
    return 0
  fi
  dm_reg_stop
  return 1
}

# dm_register: the registration screens (apply).
dm_register() {
  if [ "$DM_REGISTER" = 0 ]; then
    ui_say "Not registering${DM_NOREG_WHY:+ ($DM_NOREG_WHY)}. Later: $DM_HINT register --hostname example.com --email you@example.com"
    return 0
  fi
  if [ "$SK_DRY" = 1 ]; then
    ui_say "[dry-run] would register ${DM_HOSTS:-the saved registration} with the method ${DM_METHOD:-daemon}, using $DM_EMAIL"
    return 0
  fi
  if dm_all_registered; then
    ui_say "The host name(s) are registered already; nothing to do here."
    return 0
  fi
  if [ "${DM_METHOD:-daemon}" = daemon ] && ! dm_reachable; then
    ui_warn "The daemon does not answer yet. The check by the method daemon needs it running; start it as shown above."
  fi
  dm_tries=0
  while :; do
    dm_tries=$((dm_tries + 1))
    if dm_register_run; then
      break
    fi
    if [ "$DM_STOP" = 1 ]; then
      ui_say "Stopped. The registration stays pending. Continue later with: $DM_HINT register --resume"
      exit 3
    fi
    case $DM_ERR_CODE in
      already_running)
        sk_fail 1 SK-E706 "Another registration is still running on this machine." "Wait for it, or stop it, then run this again."
        ;;
      timeout | expired | failed_expired)
        sk_fail 1 SK-E707 "The registration was not completed in time${DM_ERR_MSG:+ ($DM_ERR_MSG)}." "It is saved. Continue with: $DM_HINT register --resume (or start again with this installer)."
        ;;
    esac
    if [ "$DM_PASTE_DEAD" = 1 ] && dm_can_ask && [ "$dm_tries" -lt 3 ]; then
      ui_menu_single "Send a new confirmation mail?" "+again|Yes, send a new mail" "stop|No, stop here"
      if [ "$UI_REPLY" = again ]; then
        dm_reg_stop
        DM_RESUME=0
        continue
      fi
      exit 3
    fi
    # a name held by another installation: the registry's 409 at register, or its final failed event (the daemon
    # reports it as failed_domain with the reason in the message); --resume cannot help here
    case $DM_ERR_CODE:$DM_ERR_MSG in
      hostname_taken:* | failed_domain:*hostname_taken*)
        dm_reason_text hostname_taken
        sk_fail 1 SK-E706 "$DM_TXT" "If that installation is gone, wait about an hour: the registry releases a host that stopped syncing and proving its name, and this command then succeeds. If it still runs, stop it (or revoke its key in the dashboard), then run this command again: curl -fsSL $SK_ORIGIN | sh" # get.shakekee.com
        ;;
    esac
    case $DM_ERR_CODE in
      failed_domain)
        sk_fail 1 SK-E708 "The domain check did not pass${DM_ERR_MSG:+: $DM_ERR_MSG}." "Fix the cause, then continue with: $DM_HINT register --resume"
        ;;
    esac
    sk_fail 1 SK-E706 "The registration did not complete${DM_ERR_MSG:+: $DM_ERR_MSG}." "Read the line above, fix the cause, then continue with: $DM_HINT register --resume"
  done
  sk_record daemon.hosts "$DM_HOSTS"
  sk_record daemon.method "${DM_METHOD:-daemon}"
  ui_say "The site is registered. The keys were made on this machine; only their public halves were sent."
}

# ---- phases ----

recipe_detect() {
  dm_layout
  if command -v sk_detect_run >/dev/null 2>&1; then
    sk_detect_run
  else
    SK_FACT_OS=$(uname -s | tr 'A-Z' 'a-z')
    SK_FACT_ARCH=$(uname -m)
    SK_FACT_USER=$(id -un 2>/dev/null || echo unknown)
    SK_FACT_WEBSERVERS=
  fi
  if [ "$SK_FACT_OS" != linux ]; then
    sk_fail 1 SK-E701 "The daemon runs on Linux only; this is $SK_FACT_OS." "Use a Linux machine for the daemon, or the plugin of your platform without it (get.shakekee.com)."
  fi
  case $SK_FACT_ARCH in
    x86_64 | aarch64) DM_ARCH=$SK_FACT_ARCH ;;
    *) sk_fail 1 SK-E701 "The CPU type $SK_FACT_ARCH is not supported (x86_64 and aarch64 are)." "Use the plugin of your platform without the daemon (get.shakekee.com)." ;;
  esac
  dm_have_service
  DM_LINGER=
  if [ "$DM_MODE" = user ] && [ "$DM_SVC" = systemd-user ] && command -v loginctl >/dev/null 2>&1; then
    DM_LINGER=$(loginctl show-user "$SK_FACT_USER" --property=Linger 2>/dev/null | sed -n 's/^Linger=//p') || DM_LINGER=
  fi
  ui_title "The ShakeKee daemon"
  case $DM_SVC in
    systemd-user) dm_svc_text='systemd --user' ;;
    systemd) dm_svc_text=systemd ;;
    *) dm_svc_text='none (a start command is shown)' ;;
  esac
  if [ "$DM_MODE" = user ]; then
    dm_mode_text="user, files in $SK_HOME"
  else
    dm_mode_text="system (root), state in $DM_STATE"
  fi
  ui_table "Fact|Value" "mode|$dm_mode_text" "system|$SK_FACT_OS $SK_FACT_ARCH, ${SK_FACT_DISTRO:-unknown}" "service|$dm_svc_text" "web servers running|${SK_FACT_WEBSERVERS:-none}"
}

recipe_choose() {
  if [ "$DM_REGISTER" = 1 ]; then
    if dm_all_registered; then
      :
    elif dm_pending && dm_continue_pending; then
      DM_RESUME=1
      dm_pending_facts
    else
      if [ -z "$DM_HOSTS" ]; then
        if dm_can_ask; then
          ui_ask "Host name of the site to protect, like example.com (empty: register later)" ""
          if [ -n "$UI_REPLY" ]; then
            dm_add_host "$UI_REPLY"
          fi
        fi
        if [ -z "$DM_HOSTS" ]; then
          DM_REGISTER=0
          DM_NOREG_WHY='no host name given'
        fi
      fi
      if [ "$DM_REGISTER" = 1 ] && [ -z "$DM_EMAIL" ]; then
        if dm_can_ask; then
          ui_ask "Your email address (the confirmation mail goes there; empty: register later)" ""
          if [ -n "$UI_REPLY" ]; then
            dm_valid_email "$UI_REPLY" || dm_bad "'$UI_REPLY' is not an email address."
            DM_EMAIL=$UI_REPLY
          fi
        fi
        if [ -z "$DM_EMAIL" ]; then
          DM_REGISTER=0
          DM_NOREG_WHY='no email address given'
        fi
      fi
      if [ "$DM_REGISTER" = 1 ] && [ -z "$DM_METHOD" ]; then
        set --
        dm_first=${DM_HOSTS%% *}
        set -- "+daemon|The daemon proves it|your web server passes /.well-known/shakekee/ to the daemon"
        set -- "$@" "file|A file in your web root|you give the document root"
        if dm_find_index "$dm_first" && [ -z "$DM_META_FILE" ]; then
          set -- "$@" "meta|A meta tag on the home page|found $DM_INDEX"
        elif [ -n "$DM_META_FILE" ]; then
          set -- "$@" "meta|A meta tag on the home page|$DM_META_FILE"
        fi
        set -- "$@" "dns|A DNS TXT record|works for every site; you add one record at your DNS provider"
        if dm_can_ask; then
          ui_menu_single "How do you prove that $dm_first is yours?" "$@"
          DM_METHOD=$UI_REPLY
        else
          DM_METHOD=daemon
        fi
      fi
      case ${DM_METHOD:-daemon} in
        file)
          if [ -z "$DM_WEB_ROOT" ] && [ "$DM_REGISTER" = 1 ]; then
            if dm_can_ask; then
              dm_find_index "${DM_HOSTS%% *}" || true
              ui_ask "Document root of ${DM_HOSTS%% *} (the folder of the home page)" "${DM_INDEX%/*}"
              DM_WEB_ROOT=$UI_REPLY
            fi
          fi
          if [ "$DM_REGISTER" = 1 ] && [ -z "$DM_WEB_ROOT" ]; then
            dm_bad "The method file needs the document root of the site." "Pass --web-root DIR."
          fi
          ;;
        meta)
          if [ -z "$DM_META_FILE" ] && [ "$DM_REGISTER" = 1 ] && dm_can_ask; then
            dm_find_index "${DM_HOSTS%% *}" || true
            if [ -n "$DM_INDEX" ]; then
              ui_menu_single "Who puts the meta tag on the home page?" "+file|The installer adds it to $DM_INDEX|a backup of the file is made first" "other|The installer adds it to another file|you name the file" "self|I add the tag myself|the tag is shown at the end"
            else
              UI_REPLY=other
            fi
            case $UI_REPLY in
              file) DM_META_FILE=$DM_INDEX ;;
              other)
                ui_ask "The home page that gets the tag, a static HTML file (empty: you add the tag yourself)" ""
                DM_META_FILE=$UI_REPLY
                ;;
            esac
          fi
          ;;
      esac
    fi
  fi
  if [ -z "$DM_SERVER" ]; then
    set -- $SK_FACT_WEBSERVERS
    if [ $# -eq 1 ]; then
      DM_SERVER=$1
    elif [ $# -gt 1 ] && dm_can_ask; then
      for dm_s in "$@"; do
        set -- "$@" "$dm_s|$dm_s"
        shift
      done
      ui_menu_single "Which web server serves the site?" "$@" "none|None of these|I will add the lines myself"
      DM_SERVER=$UI_REPLY
    elif [ $# -gt 1 ]; then
      DM_SERVER=$1
    fi
  fi
  if [ "$DM_MODE" = user ] && [ "$DM_SVC" = systemd-user ] && [ "$DM_LINGER" = no ] && [ "$DM_SERVICE_WANTED" = 1 ]; then
    if dm_can_ask; then
      ui_note "A service in your home stops when you log out, unless lingering is on for your account. Turning it on needs administrator rights once."
      ui_menu_single "Keep the daemon running after you log out?" "+no|No, only while I am logged in" "yes|Yes, enable lingering|runs: sudo loginctl enable-linger $SK_FACT_USER"
      if [ "$UI_REPLY" = yes ]; then
        DM_WANT_LINGER=1
      fi
    fi
  fi
}

# dm_old_layout_stop: an installation from before the state moved into $SK_HOME/state kept the daemon's files
# in the home itself, owned by the service user. Root does not act on such a home (it would run and restore
# what the service user could have written there); the person moves the state first.
dm_old_layout_stop() {
  if [ "$DM_MODE" = system ] && { [ -f "$SK_HOME/identity.key" ] || [ -d "$SK_HOME/hosts" ]; }; then
    sk_fail 1 SK-E711 "The daemon's state is in $SK_HOME itself (an installation from before the state moved to $DM_STATE)." "Stop the daemon, then as root: mkdir -m 0700 $DM_STATE; mv $SK_HOME/identity.key $SK_HOME/hostkeys.json $SK_HOME/hosts $SK_HOME/validation $SK_HOME/pending-registration.json $SK_HOME/preflight.secret $DM_STATE/ 2>/dev/null; chown -R $DM_SVC_USER:$DM_SVC_USER $DM_STATE; chown root:root $SK_HOME $SK_HOME/install.json; chmod 0711 $SK_HOME; add state_dir = \"$DM_STATE\" to $DM_CONF; start the daemon; run this again."
  fi
}

recipe_check() {
  dm_old_layout_stop
  if command -v curl >/dev/null 2>&1; then
    DM_DL=curl
  elif command -v wget >/dev/null 2>&1; then
    DM_DL=wget
  else
    sk_fail 1 SK-E702 "Neither curl nor wget is installed." "Install one of them (for example: apt install curl) and run this again."
  fi
  if ! command -v openssl >/dev/null 2>&1; then
    sk_fail 1 SK-E702 "openssl is not installed; it is needed to verify the release signature." "Install the openssl package and run this again."
  fi
  if ! openssl pkeyutl -help 2>&1 | grep -q rawin; then
    sk_fail 1 SK-E702 "This openssl is too old (OpenSSL 3 or newer is needed to verify the Ed25519 release signature)." "Install a newer OpenSSL and run this again."
  fi
  for dm_t in awk grep mktemp uname id chmod cmp sed; do
    command -v "$dm_t" >/dev/null 2>&1 || sk_fail 1 SK-E702 "A required tool is not installed: $dm_t" "Install it with your package manager and run this again."
  done
  if [ "$DM_MODE" = system ]; then
    command -v install >/dev/null 2>&1 || sk_fail 1 SK-E702 "A required tool is not installed: install" "Install coreutils and run this again."
  fi
  for dm_p in "$SK_HOME" "$DM_BIN" "$DM_CONF"; do
    dm_valid_abs_path "$dm_p" || sk_fail 2 SK-E704 "The path $dm_p has characters this installer does not put into a service file." "Use only letters, digits and / . _ - in the path (for example --home /home/me/shakekee)."
  done
  case ${DM_METHOD:-daemon} in
    daemon | file | meta | dns) ;;
    *) dm_bad "--method must be daemon, file, meta or dns" ;;
  esac
  if [ -n "$DM_WEB_ROOT" ]; then
    dm_valid_abs_path "$DM_WEB_ROOT" || dm_bad "The document root must be an absolute path of letters, digits and / . _ - only."
    if [ "$DM_REGISTER" = 1 ] && [ ! -d "$DM_WEB_ROOT" ]; then
      dm_bad "The document root $DM_WEB_ROOT is not a directory."
    fi
  fi
  if [ -n "$DM_META_FILE" ]; then
    dm_valid_abs_path "$DM_META_FILE" || dm_bad "--meta-file must be an absolute path of letters, digits and / . _ - only."
    [ -f "$DM_META_FILE" ] || dm_bad "The page $DM_META_FILE does not exist."
  fi
  case $DM_SERVER in
    '' | nginx | apache | caddy | none) ;;
    *) dm_bad "--server must be nginx, apache, caddy or none" ;;
  esac
}

recipe_plan() {
  sk_plan_add "Download the $DM_VERSION release of shakekeed and check its signature and checksum"
  sk_plan_add "Install it as $DM_BIN"
  if [ "$DM_MODE" = system ]; then
    sk_plan_add "Create the system user $DM_SVC_USER and the directories $DM_STATE and ${DM_CONF%/*}"
  fi
  sk_plan_add "Write $DM_CONF if there is none"
  if [ "$DM_SERVICE_WANTED" = 0 ]; then
    sk_plan_add "Set up no service (--no-service)"
  elif [ "$DM_SVC" = none ]; then
    sk_plan_add "Start the daemon in the background and show the command that starts it (there is no service manager here)"
  else
    sk_plan_add "Write $DM_UNIT and start the service ($DM_SVC)"
  fi
  if [ "$DM_WANT_LINGER" = 1 ]; then
    sk_plan_add "Turn on lingering for $SK_FACT_USER (sudo loginctl enable-linger)"
  fi
  sk_plan_add "Show the lines that route the daemon through ${DM_SERVER:-your web server}"
  if [ "$DM_REGISTER" = 1 ]; then
    if [ "$DM_RESUME" = 1 ]; then
      sk_plan_add "Continue the registration that was saved earlier"
    else
      sk_plan_add "Register ${DM_HOSTS:-your site}: confirmation mail to $DM_EMAIL, proof of the domain by ${DM_METHOD:-daemon}"
    fi
  else
    sk_plan_add "Skip the registration${DM_NOREG_WHY:+ ($DM_NOREG_WHY)}"
  fi
  sk_plan_add "Check that it works and write the example into $SK_HOME/examples/daemon"
}

recipe_apply() {
  dm_download
  if [ "$DM_MODE" = system ]; then
    dm_create_user
    dm_create_dirs
  else
    dm_create_dirs
  fi
  dm_install_binary
  sk_record daemon "$DM_BIN"
  sk_record daemon.mode "$DM_MODE"
  sk_record daemon.conf "$DM_CONF"
  sk_record daemon.version "$DM_NEW_VERSION"
  SK_RECIPE_VERSION=$DM_NEW_VERSION
  if [ "$SK_DRY" != 1 ]; then
    sk_integration_add daemon "$DM_NEW_VERSION"
  fi
  dm_write_config
  dm_service
  dm_linger
  dm_server_step
  dm_register
  if [ "$DM_REG_DONE" = 1 ] && [ "$DM_MODE" = system ] && [ "$SK_DRY" != 1 ]; then
    # register ran as root; the daemon runs as its own user and must own the state (only the state:
    # install.json, backups and examples next to it stay root's)
    chown -R "$DM_SVC_USER:$DM_SVC_USER" "$DM_STATE" 2>/dev/null || ui_warn "Could not give $DM_STATE to $DM_SVC_USER."
  fi
  if [ "$DM_REG_DONE" = 1 ]; then
    dm_restart
  fi
  if command -v sk_example_list_daemon >/dev/null 2>&1; then
    sk_example_install daemon "$SK_HOME/examples/daemon"
  fi
}

recipe_verify() {
  ui_title "Checking the installation"
  DM_PROBLEMS=0
  ui_step_start live "The daemon answers"
  if dm_reachable; then
    ui_step_ok
  else
    ui_step_warn "it does not answer on 127.0.0.1:$DM_PREFLIGHT_PORT yet${UI_NL}Fix: start it (see above) and look at its log."
    DM_PROBLEMS=$((DM_PROBLEMS + 1))
  fi
  if [ -n "$DM_HOSTS" ] && [ "$DM_REG_DONE" = 1 ] || dm_all_registered; then
    for dm_h in $DM_HOSTS; do
      ui_step_start route "$dm_h reaches the daemon through the web server"
      dm_code=$(curl --silent --max-time 10 --output /dev/null --write-out '%{http_code}' "https://$dm_h/.shakekee/api/v1/pubkey" 2>/dev/null) || dm_code=
      if [ "$dm_code" = 200 ]; then
        ui_step_ok "https://$dm_h/.shakekee/ answers"
      else
        ui_step_warn "https://$dm_h/.shakekee/api/v1/pubkey answered '${dm_code:-no answer}'; the web server does not pass /.shakekee/ to the daemon yet${UI_NL}Fix: add the lines shown above and reload the web server."
        DM_PROBLEMS=$((DM_PROBLEMS + 1))
      fi
      dm_hf=$SK_TASK/headers.$dm_h
      if curl --silent --max-time 10 --dump-header "$dm_hf" --output /dev/null "https://$dm_h/" 2>/dev/null && [ -f "$dm_hf" ]; then
        if grep -iE '^content-security-policy:' "$dm_hf" | grep -iqE 'script-src|default-src'; then
          ui_warn "$dm_h sends a Content-Security-Policy with script-src or default-src. The wall loads its UI bundle from the ShakeKee CDN (with an integrity hash) and has one small inline script: allow the CDN origin in script-src and give the inline script a nonce, or the wall will stay blank."
        fi
        if grep -iqE '^(x-cache|x-cache-status|cf-cache-status|x-varnish|x-fastcgi-cache|x-proxy-cache|x-litespeed-cache|x-sucuri-cache|via|age):|^server: *cloudflare' "$dm_hf"; then
          ui_warn "A cache or CDN sits in front of $dm_h. The wall is served at the protected URL with Cache-Control: no-store; make sure the cache honours it (or excludes the protected paths), otherwise visitors can be served the wall, or the real page, from the cache."
        fi
      fi
    done
  fi
  if [ -f "$SK_HOME/examples/daemon/verify.sh" ] && [ "$DM_REG_DONE" = 1 ] && [ "$DM_SVC" = none ] && [ "$DM_MODE" = system ]; then
    # the daemon that runs now was started by hand before the registration and does not know the host yet
    ui_note "Restart the daemon you started by hand so that it loads the new host, then run the example check: sh $SK_HOME/examples/daemon/verify.sh --home $SK_HOME --daemon-bin $DM_BIN"
  elif [ -f "$SK_HOME/examples/daemon/verify.sh" ] && [ "$DM_REG_DONE" = 1 ]; then
    ui_title "The example check"
    dm_vrc=0
    dm_vo=$(sh "$SK_HOME/examples/daemon/verify.sh" --home "$SK_HOME" --daemon-bin "$DM_BIN" 2>&1 </dev/null) || dm_vrc=$?
    while IFS= read -r dm_vl; do
      ui_say "$dm_vl"
    done <<DM_VERIFY
$dm_vo
DM_VERIFY
    if [ "$dm_vrc" != 0 ]; then
      ui_note "The example found something to fix; the lines above say what."
    fi
  fi
}

recipe_handover() {
  if [ "$SK_DRY" = 1 ]; then
    ui_note "Dry run: nothing was changed."
    return 0
  fi
  dm_cli_hint
  if [ -n "$DM_FOR" ]; then
    ui_title "The daemon is installed; now $DM_FOR"
  else
    ui_title "What is left"
  fi
  ui_say "  Status of the daemon:   $DM_HINT status"
  ui_say "  Check it again later:   curl -fsSL $SK_ORIGIN | sh -s -- --doctor" # get.shakekee.com
  ui_say "  The shared secret of your integration is in $DM_STATE/preflight.secret (print it with: $DM_HINT print-config --preflight-secret)"
  if [ "$DM_REG_DONE" = 0 ] && [ "$DM_REGISTER" = 0 ]; then
    ui_say "  Register a site:        $DM_HINT register --hostname example.com --email you@example.com"
  fi
  if [ -z "$DM_FOR" ]; then
    ui_say "  Connect your application: curl -fsSL $SK_ORIGIN | sh  (it asks what to protect)" # get.shakekee.com
  fi
  if [ "$DM_SVC" = none ] && [ "$DM_SERVICE_WANTED" = 1 ]; then
    dm_run_command
    ui_say "  The daemon does not restart by itself here. Start it again with: $DM_RUN_CMD"
  fi
}

# ---- lifecycle hooks ----

recipe_status() {
  dm_layout
  dm_have_service
  DM_PROBLEMS=0
  ui_step_start dm-binary "The daemon program $DM_BIN"
  if [ -x "$DM_BIN" ]; then
    ui_step_ok "$("$DM_BIN" version 2>/dev/null </dev/null)"
  else
    ui_step_fail "it is missing${UI_NL}Fix: run the installer again."
    return 1
  fi
  if sk_rec_get daemon.service && [ "$SK_RV" != process ] && [ "$SK_RV" != none ]; then
    ui_step_start dm-service "The service shakekeed is running"
    if dm_systemctl is-active --quiet shakekeed 2>/dev/null; then
      ui_step_ok
    else
      dm_ctl_hint
      ui_step_fail "it is not running${UI_NL}Fix: $DM_CTL start shakekeed (log: ${DM_CTL}-journal: journalctl$( [ "$DM_MODE" = user ] && printf ' --user') -u shakekeed)"
      DM_PROBLEMS=$((DM_PROBLEMS + 1))
    fi
  fi
  ui_step_start dm-live "The daemon answers on 127.0.0.1:$DM_PREFLIGHT_PORT"
  if dm_reachable; then
    ui_step_ok
  else
    ui_step_fail "it does not answer${UI_NL}Fix: start it again (see above)."
    DM_PROBLEMS=$((DM_PROBLEMS + 1))
  fi
  dm_sj=$(dm_cli status --json 2>/dev/null </dev/null) || dm_sj=
  if [ -n "$dm_sj" ]; then
    ui_step_start dm-hosts "Registered hosts"
    case $dm_sj in
      *'"state":"active"'*) ui_step_ok "$(printf '%s' "$dm_sj" | sed -n 's/.*"hostnames":\[\([^]]*\)\].*/\1/p' | head -n 1)" ;;
      *'"state":"pending"'*) ui_step_warn "a registration is pending${UI_NL}Fix: finish it with: $DM_HINT register --resume" ;;
      *) ui_step_warn "no host is registered on this machine${UI_NL}Fix: $DM_HINT register --hostname example.com" ;;
    esac
  fi
  [ "$DM_PROBLEMS" = 0 ]
}

recipe_doctor() {
  dm_layout
  dm_old_layout_stop
  dm_have_service
  DM_PROBLEMS=0
  if sk_rec_get daemon.service && [ "$SK_RV" != process ] && [ "$SK_RV" != none ] && [ -f "$DM_UNIT" ]; then
    ui_step_start dm-unit "The service file is the one this installer writes"
    dm_unit_text >"$SK_TASK/expected.service" 2>/dev/null || :
    if cmp -s "$SK_TASK/expected.service" "$DM_UNIT"; then
      ui_step_ok
    else
      ui_step_warn "$DM_UNIT differs (edited by hand?)${UI_NL}Fix: run this installer again to write it back, or keep your version."
    fi
  fi
  if [ "$DM_MODE" = user ] && [ "$DM_SVC" = systemd-user ] && command -v loginctl >/dev/null 2>&1; then
    ui_step_start dm-linger "The daemon keeps running after you log out"
    if [ "$(loginctl show-user "$SK_FACT_USER" --property=Linger 2>/dev/null)" = Linger=yes ]; then
      ui_step_ok
    else
      ui_step_warn "lingering is off: the daemon stops when you log out${UI_NL}Fix: sudo loginctl enable-linger $(id -un)"
    fi
  fi
  ui_step_start dm-config "The configuration loads"
  if [ -x "$DM_BIN" ] && dm_cli print-config >/dev/null 2>&1 </dev/null; then
    ui_step_ok
  else
    ui_step_fail "\"$DM_BIN print-config\" fails${UI_NL}Fix: look at $DM_CONF (the [[host]] blocks, the quotes) and run the command by hand to see the message."
    DM_PROBLEMS=$((DM_PROBLEMS + 1))
  fi
  if dm_pending; then
    ui_step_start dm-pending "No registration is waiting"
    ui_step_warn "a registration is saved and not finished${UI_NL}Fix: $DM_HINT register --resume"
  fi
  [ "$DM_PROBLEMS" = 0 ]
}

recipe_update() {
  dm_layout
  dm_have_service
  if command -v curl >/dev/null 2>&1; then DM_DL=curl; elif command -v wget >/dev/null 2>&1; then DM_DL=wget; else sk_fail 1 SK-E702 "Neither curl nor wget is installed." "Install one of them and run this again."; fi
  command -v openssl >/dev/null 2>&1 || sk_fail 1 SK-E702 "openssl is not installed; it is needed to verify the release signature." "Install the openssl package and run this again."
  if command -v sk_detect_run >/dev/null 2>&1; then sk_detect_run; fi
  DM_ARCH=${SK_FACT_ARCH:-$(uname -m)}
  dm_download
  dm_install_binary
  sk_record daemon.version "$DM_NEW_VERSION"
  SK_RECIPE_VERSION=$DM_NEW_VERSION
  sk_integration_add daemon "$DM_NEW_VERSION"
  DM_SERVICE_WANTED=1
  dm_restart
  ui_say "The daemon is up to date ($DM_NEW_VERSION)."
}

recipe_uninstall() {
  dm_layout
  dm_have_service
  dm_cli_hint
  if [ -x "$DM_BIN" ] && { [ -d "$DM_STATE/hosts" ] && [ -n "$(ls -A "$DM_STATE/hosts" 2>/dev/null || true)" ]; }; then
    if [ "$SK_DRY" = 1 ]; then
      ui_say "[dry-run] would ask whether to give up the registrations at ShakeKee ($DM_HINT release)"
    else
      ui_note "The registrations of this machine can be given up at ShakeKee: the registry then removes the DNS records it published for your host names, and the licence ends."
      ui_menu_single "Give up the registrations at ShakeKee?" "+release|Yes, release them|recommended when you remove the daemon for good" "keep|No, keep them registered|you can run the daemon again later"
      if [ "$UI_REPLY" = release ]; then
        ui_step_start release "Releasing the registrations"
        dm_rrc=0
        dm_ro=$(dm_cli release --json 2>&1 </dev/null) || dm_rrc=$?
        case $dm_rrc:$dm_ro in
          0:*'"event":"release_failed"'*) ui_step_fail "the registry refused:${UI_NL}$dm_ro"; sk_fail 1 SK-E710 "The registrations could not be released." "Run \"$DM_HINT release\" when the registry can be reached, then uninstall again. Nothing was removed." ;;
          0:*) ui_step_ok ;;
          *) ui_step_fail "exit status $dm_rrc:${UI_NL}$dm_ro"; sk_fail 1 SK-E710 "The registrations could not be released." "Run \"$DM_HINT release\" when the registry can be reached, then uninstall again. Nothing was removed." ;;
        esac
      fi
    fi
  fi
  if [ "$SK_DRY" = 1 ]; then
    ui_say "[dry-run] stop and remove the service"
    return 0
  fi
  if sk_rec_get daemon.service; then
    dm_us=$SK_RV
    case $dm_us in
      systemd | systemd-user)
        dm_ctl_hint
        sk_step_run stop "Stopping and disabling the service" dm_systemctl disable --now shakekeed || ui_warn "Could not stop the service; do it yourself: $DM_CTL disable --now shakekeed"
        ;;
      process)
        dm_process_stop
        ;;
    esac
  elif dm_process_alive; then
    dm_process_stop
  fi
  # the web server lines this installer added with the person's consent (sk_server_rule) are taken back: the person's
  # file is put back byte for byte and ShakeKee's own file removed (the generic undo does not touch them)
  dm_unwire_bad=0
  if command -v sk_server_recorded >/dev/null 2>&1; then
    for dm_uws in nginx apache caddy; do
      for dm_uwsite in $(sk_server_recorded "$dm_uws"); do
        sk_server_remove "$dm_uws" "$dm_uwsite" || dm_unwire_bad=1
      done
    done
  fi
  # the binary goes here, not with the undo of the recorded changes: the daemon updates itself (auto-update), so the
  # file is no longer the one that was recorded, and the undo would leave it as "changed by you"
  if [ -f "$DM_BIN" ] && [ ! -L "$DM_BIN" ]; then
    if ! rm -f "$DM_BIN"; then
      ui_warn "Could not remove $DM_BIN; remove it yourself."
    fi
  fi
  sk_unrecord daemon.service
  sk_unrecord daemon.mode
  sk_unrecord daemon.conf
  sk_unrecord daemon.version
  sk_unrecord daemon.hosts
  sk_unrecord daemon.method
  sk_unrecord daemon.registration
  sk_unrecord daemon
  # the unit file is removed by the undo of the recorded changes; systemd has to be told afterwards
  if [ "$DM_SVC" = systemd-user ] || [ "$DM_SVC" = systemd ]; then
    dm_systemctl daemon-reload >/dev/null 2>&1 || true
  fi
  ui_say "The daemon is stopped. Its identity key and registrations stay in $DM_STATE until you remove them (the question at the end)."
  [ "$dm_unwire_bad" = 0 ]
}

while [ $# -gt 0 ]; do
  case $1 in
    --no-register) DM_REGISTER=0 ;;
    --no-service) DM_SERVICE_WANTED=0 ;;
    --for)
      # an application recipe runs this one first (apps.sh, sk_app_daemon_install): the hand-over says so
      sk_value "$1" $#
      DM_FOR=$2
      shift
      ;;
    --edit-server) SK_SERVER_EDIT=yes ;;
    --no-edit-server) SK_SERVER_EDIT=no ;;
    --help | -h)
      dm_usage
      exit 0
      ;;
    --version | --hostname | --email | --method | --web-root | --meta-file | --protect | --server)
      sk_value "$1" $#
      dm_opt=$1
      dm_val=$2
      shift
      case $dm_opt in
        --version)
          dm_valid_version "$dm_val" || dm_bad "invalid version '$dm_val'"
          DM_VERSION=$dm_val
          ;;
        --hostname) dm_add_host "$dm_val" ;;
        --email)
          dm_valid_email "$dm_val" || dm_bad "'$dm_val' is not an email address"
          DM_EMAIL=$dm_val
          ;;
        --method)
          case $dm_val in
            daemon | file | meta | dns) DM_METHOD=$dm_val ;;
            *) dm_bad "--method must be daemon, file, meta or dns" ;;
          esac
          ;;
        --web-root)
          dm_valid_abs_path "$dm_val" || dm_bad "--web-root must be an absolute path of letters, digits and / . _ - only"
          DM_WEB_ROOT=$dm_val
          ;;
        --meta-file)
          dm_valid_abs_path "$dm_val" || dm_bad "--meta-file must be an absolute path of letters, digits and / . _ - only"
          DM_META_FILE=$dm_val
          ;;
        --protect)
          dm_valid_rule "$dm_val" || dm_bad "invalid protect rule '$dm_val' (a path like /login or /account/*)"
          DM_PROTECT="$DM_PROTECT${DM_PROTECT:+ }$dm_val"
          ;;
        --server)
          case $dm_val in
            nginx | apache | caddy | none) DM_SERVER=$dm_val ;;
            *) dm_bad "--server must be nginx, apache, caddy or none" ;;
          esac
          ;;
      esac
      ;;
    *) dm_bad "unknown option $1" ;;
  esac
  shift
done
if [ "$DM_METHOD" = file ] && [ -z "$DM_WEB_ROOT" ] && [ -n "$DM_HOSTS" ] && [ "$DM_REGISTER" = 1 ] && [ "$SK_YES" = 1 ]; then
  dm_bad "--method file needs --web-root (the document root)"
fi
if [ -n "$DM_META_FILE" ]; then
  if [ -z "$DM_METHOD" ]; then
    DM_METHOD=meta
  elif [ "$DM_METHOD" != meta ]; then
    dm_bad "--meta-file goes with --method meta, not --method $DM_METHOD"
  fi
fi

sk_phases
}
# ---- hub.sh: what `curl -fsSL https://get.shakekee.com | sh` runs (POSIX sh; defines functions only) ----
# The served hub script is: header (SK_ORIGIN, the digest of every artifact as sk_fetch_<id>, SK_DASHBOARD_API,
# SK_DASHBOARD_ORIGIN, SK_DAEMON_SHA256), ui.sh, core.sh, common.sh, detect.sh, account.sh (when that file exists),
# examples.sh with the example files, every recipe wrapped as sk_recipe_<slug>() { ... }, the daemon recipe
# (sk_recipe_daemon, when the server has a release; docs/installer-daemon.md), this file, and the one
# last line that calls sk_boot. The hub looks at the machine, asks what to protect, shows the plan, and runs the
# chosen recipes one after the other, each in a shell of its own (a recipe that calls exit or die ends only itself).
# The recipes are the ones /<slug> serves; they have not been ported to the phase contract yet and run as they are.

# The order the chosen integrations run in: the daemon first, web server snippets last.
SK_HUB_ORDER='daemon wordpress woocommerce joomla drupal prestashop laravel django nodejs cloudflare shopify go rust php csharp nginx apache caddy'

# sk_hub_label SLUG: what the person is told.
sk_hub_label() {
  case $1 in
    daemon) printf '%s\n' 'ShakeKee daemon (shakekeed)' ;;
    wordpress) printf '%s\n' 'WordPress' ;;
    woocommerce) printf '%s\n' 'WooCommerce' ;;
    joomla) printf '%s\n' 'Joomla' ;;
    drupal) printf '%s\n' 'Drupal' ;;
    prestashop) printf '%s\n' 'PrestaShop' ;;
    laravel) printf '%s\n' 'Laravel' ;;
    django) printf '%s\n' 'Django' ;;
    nodejs) printf '%s\n' 'Node.js' ;;
    cloudflare) printf '%s\n' 'Cloudflare Worker' ;;
    shopify) printf '%s\n' 'Shopify app' ;;
    go) printf '%s\n' 'Go' ;;
    rust) printf '%s\n' 'Rust' ;;
    php) printf '%s\n' 'PHP' ;;
    csharp) printf '%s\n' 'C#' ;;
    nginx) printf '%s\n' 'nginx' ;;
    apache) printf '%s\n' 'Apache' ;;
    caddy) printf '%s\n' 'Caddy' ;;
    *) printf '%s\n' "$1" ;;
  esac
}

# sk_hub_kind SLUG: how a directory is handed to the recipe.
#   path    the recipe takes --path DIR
#   cwd     the recipe works in the current directory: the hub changes into DIR first
#   none    the recipe takes no directory
#   daemon  the daemon installer (assets/installers/daemon.sh, run as sk_recipe_daemon when the server has a release)
sk_hub_kind() {
  case $1 in
    wordpress | woocommerce | joomla | drupal | prestashop | laravel | nodejs | php) printf '%s\n' path ;;
    django | cloudflare | shopify | go | rust | csharp) printf '%s\n' cwd ;;
    nginx | apache | caddy) printf '%s\n' none ;;
    daemon) printf '%s\n' daemon ;;
    *) printf '%s\n' unknown ;;
  esac
}

# sk_hub_usage: the text of --help.
sk_hub_usage() {
  printf '%s\n' \
    "Usage: curl -fsSL $SK_ORIGIN | sh -s -- [options]" \
    '  (no option)        look at this machine, ask what to protect, show the plan, install' \
    '  --NAME [DIR]       install NAME without the menu; DIR is the site or project (default: the' \
    '                     current directory). NAME is one of:' \
    '                       daemon wordpress woocommerce joomla drupal prestashop laravel django' \
    '                       nodejs cloudflare shopify go rust php csharp nginx apache caddy' \
    '                     Several may be given. With --yes and no NAME, what was found is installed.' \
    '  --detect           only show what was found on this machine' \
    '  --force            replace example files that differ from the example' \
    '  --help             this text'
  sk_usage_common
}

# sk_hub_add SLUG PATH: adds one choice (no duplicates).
sk_hub_add() {
  case "$UI_NL$SK_HUB_SEL$UI_NL" in
    *"$UI_NL$1|$2$UI_NL"*) return 0 ;;
  esac
  SK_HUB_SEL="${SK_HUB_SEL:+$SK_HUB_SEL$UI_NL}$1|$2"
}

# sk_hub_main ARGS...: the hub (the framework's options are already taken out).
sk_hub_main() {
  SK_HUB_SEL=
  sk_hm_detect=0
  while [ $# -gt 0 ]; do
    case $1 in
      --help | -h)
        sk_hub_usage
        exit 0
        ;;
      --detect) sk_hm_detect=1 ;;
      --force) SK_FORCE=1 ;;
      --daemon | --wordpress | --woocommerce | --joomla | --drupal | --prestashop | --laravel | --django | --nodejs | --cloudflare | --shopify | --go | --rust | --php | --csharp | --nginx | --apache | --caddy)
        sk_hm_slug=${1#--}
        sk_hm_path=
        # an optional directory follows, unless the next word is another option
        if [ $# -ge 2 ]; then
          case $2 in
            -*) ;;
            *)
              sk_hm_path=$2
              shift
              ;;
          esac
        fi
        if [ -n "$sk_hm_path" ]; then
          case $(sk_hub_kind "$sk_hm_slug") in
            path | cwd) ;;
            *) sk_fail 2 SK-E110 "--$sk_hm_slug takes no directory: $sk_hm_path" "Use --$sk_hm_slug alone." ;;
          esac
          if [ ! -d "$sk_hm_path" ]; then
            sk_fail 2 SK-E110 "$sk_hm_path (for --$sk_hm_slug) is not a directory." "Name the directory of the site or project."
          fi
          sk_hm_path=$(cd "$sk_hm_path" && pwd -P) || sk_fail 2 SK-E110 "Cannot enter $sk_hm_path." "Check the permissions."
        fi
        sk_hub_add "$sk_hm_slug" "$sk_hm_path"
        ;;
      *) sk_fail 2 SK-E110 "Unknown option: $1" "Run it with --help to see the options." ;;
    esac
    shift
  done
  if [ "$sk_hm_detect" = 1 ]; then
    sk_hub_detect_report
    exit 0
  fi
  if [ -z "$SK_HUB_SEL" ]; then
    sk_phase detect
    sk_hub_choose
  else
    sk_phase choose
  fi
  sk_hub_plan
  sk_phase plan
  SK_PLAN=
  sk_phase apply
  sk_hub_apply
  sk_phase handover
  sk_hub_handover
}

# sk_hub_detect_report: --detect, the facts and the findings.
sk_hub_detect_report() {
  sk_detect_run
  sk_hd_items=$(sk_detect_items)
  if [ "$UI_MODE" = json ]; then
    ui__ev facts os "$SK_FACT_OS" arch "$SK_FACT_ARCH" distro "$SK_FACT_DISTRO" init "$SK_FACT_INIT" user "$SK_FACT_USER" sudo "$SK_FACT_SUDO" webservers "$SK_FACT_WEBSERVERS"
    while IFS='|' read -r sk_hs sk_hp sk_hl sk_hh; do
      case $sk_hs in
        '') ;;
        installed:*) ui__ev installed slug "${sk_hs#installed:}" path "$sk_hp" label "$sk_hl" hint "$sk_hh" ;;
        *) ui__ev found slug "$sk_hs" path "$sk_hp" label "$sk_hl" hint "$sk_hh" ;;
      esac
    done <<HUB_ITEMS
$sk_hd_items
HUB_ITEMS
    return 0
  fi
  ui_title "This machine"
  ui_table 'Fact|Value' "system|$SK_FACT_OS $SK_FACT_ARCH, $SK_FACT_DISTRO" "user|$SK_FACT_USER (sudo: $SK_FACT_SUDO)" "init|$SK_FACT_INIT" "web servers running|${SK_FACT_WEBSERVERS:-none}"
  if [ -z "$sk_hd_items" ]; then
    ui_say "Nothing to protect was found here."
    return 0
  fi
  ui_title "Found"
  set -- 'What|Where|Notes'
  while IFS='|' read -r sk_hs sk_hp sk_hl sk_hh; do
    case $sk_hs in
      '') ;;
      installed:*) set -- "$@" "$sk_hl|$sk_hp|$sk_hh" ;;
      *) set -- "$@" "$sk_hl|$sk_hp|$sk_hh" ;;
    esac
  done <<HUB_ITEMS
$sk_hd_items
HUB_ITEMS
  ui_table "$@"
}

# sk_hub_choose: detect, show the menu, fill SK_HUB_SEL.
sk_hub_choose() {
  sk_detect_run
  ui_say "This machine: $SK_FACT_DISTRO ($SK_FACT_OS $SK_FACT_ARCH), user $SK_FACT_USER, init $SK_FACT_INIT${SK_FACT_WEBSERVERS:+, web servers running: $SK_FACT_WEBSERVERS}"
  sk_hc_items=$(sk_detect_items)
  sk_phase choose
  SK_HUB_FOUND=
  sk_hc_n=0
  set --
  while IFS='|' read -r sk_hs sk_hp sk_hl sk_hh; do
    case $sk_hs in
      '' | installed:*) continue ;;
    esac
    sk_hc_n=$((sk_hc_n + 1))
    SK_HUB_FOUND="${SK_HUB_FOUND:+$SK_HUB_FOUND$UI_NL}$sk_hs|$sk_hp"
    sk_hc_pre='+'
    sk_hc_note=$sk_hh
    case "$UI_NL$sk_hc_items$UI_NL" in
      *"${UI_NL}installed:$sk_hs|$sk_hp|"*)
        sk_hc_pre=
        sk_hc_note='already installed; running it again updates it'
        ;;
    esac
    case $sk_hs in
      nginx | apache | caddy)
        if [ "$sk_hh" != running ]; then
          sk_hc_pre=
        fi
        ;;
      wordpress)
        # WooCommerce installs the WordPress plugin too
        case "$UI_NL$sk_hc_items$UI_NL" in
          *"${UI_NL}woocommerce|$sk_hp|"*)
            sk_hc_pre=
            sk_hc_note='the WooCommerce installer includes it'
            ;;
        esac
        ;;
    esac
    sk_hc_text=$sk_hl
    if [ "$sk_hp" != - ]; then
      sk_hc_text="$sk_hl: $sk_hp"
    fi
    set -- "$@" "$sk_hc_pre$sk_hc_n|$sk_hc_text|$sk_hc_note"
  done <<HUB_ITEMS
$sk_hc_items
HUB_ITEMS
  if [ "$sk_hc_n" -eq 0 ]; then
    ui_say "Nothing to protect was found in this directory or in the usual places for sites."
    if [ "${SK_YES:-0}" = 1 ]; then
      sk_fail 2 SK-E111 "Nothing was found to install and nothing was named." "Name what to install, for example --wordpress /var/www/site or --daemon (see --help)."
    fi
    sk_hc_other=1
  else
    set -- "$@" "other|Something else|choose from all the integrations"
    ui_menu_multi "What do you want to protect?" "$@"
    sk_hc_reply=$UI_REPLY
    sk_hc_other=0
    for sk_hc_v in $sk_hc_reply; do
      case $sk_hc_v in
        other) sk_hc_other=1 ;;
        '' | *[!0-9]*) ;;
        *)
          sk_hc_line=$(printf '%s\n' "$SK_HUB_FOUND" | sed -n "${sk_hc_v}p")
          sk_hub_add "${sk_hc_line%%|*}" "${sk_hc_line#*|}"
          ;;
      esac
    done
    if [ "${SK_YES:-0}" = 1 ] && [ -z "$SK_HUB_SEL" ]; then
      sk_fail 2 SK-E111 "Nothing was found that is not installed yet, and nothing was named." "Name what to install, for example --wordpress /var/www/site or --daemon (see --help)."
    fi
  fi
  if [ "$sk_hc_other" = 1 ]; then
    set --
    for sk_hc_s in $SK_HUB_ORDER; do
      if [ "$sk_hc_s" = daemon ] && [ -z "${SK_DAEMON_SHA256:-}" ]; then
        continue
      fi
      if ! command -v "sk_recipe_$sk_hc_s" >/dev/null 2>&1; then
        continue
      fi
      case $(sk_hub_kind "$sk_hc_s") in
        path | cwd) sk_hc_where='uses the current directory' ;;
        daemon) sk_hc_where='runs on this machine' ;;
        *) sk_hc_where= ;;
      esac
      set -- "$@" "$sk_hc_s|$(sk_hub_label "$sk_hc_s")|$sk_hc_where"
    done
    ui_menu_multi "Which integrations do you want to install?" "$@"
    for sk_hc_v in $UI_REPLY; do
      sk_hub_add "$sk_hc_v" ""
    done
  fi
  if [ -z "$SK_HUB_SEL" ]; then
    ui_say "Nothing was chosen. Nothing was changed."
    exit 3
  fi
  sk_hub_need_daemon
}

# sk_hub_need_daemon: a chosen integration that talks to the daemon, and no daemon on this machine: the daemon is added
# (it runs first, see SK_HUB_ORDER) when this server offers it. After the menu, so detection has looked for the daemon.
# The SDKs (nodejs go rust php csharp) are left out when a person can be asked: their recipes run the daemon recipe
# themselves, for the host they were given, so the host is asked for once and the daemon registers it (apps.sh,
# sk_app_mode). Unattended (--yes) nobody can answer the recipe's questions, so the daemon is added here, as before.
sk_hub_need_daemon() {
  case "$UI_NL$SK_HUB_SEL$UI_NL" in
    *"${UI_NL}daemon|"*) return 0 ;;
  esac
  case "$UI_NL$sk_hc_items$UI_NL" in
    *"${UI_NL}installed:daemon|"*) return 0 ;;
  esac
  if [ -z "${SK_DAEMON_SHA256:-}" ] || ! command -v sk_recipe_daemon >/dev/null 2>&1; then
    return 0
  fi
  sk_hn_for=
  while IFS='|' read -r sk_hn_slug sk_hn_path; do
    case $sk_hn_slug in
      drupal | prestashop) sk_hn_for="${sk_hn_for:+$sk_hn_for, }$(sk_hub_label "$sk_hn_slug")" ;;
      nodejs | go | rust | php | csharp)
        if [ "${SK_YES:-0}" = 1 ]; then
          sk_hn_for="${sk_hn_for:+$sk_hn_for, }$(sk_hub_label "$sk_hn_slug")"
        fi
        ;;
    esac
  done <<HUB_SEL
$SK_HUB_SEL
HUB_SEL
  if [ -n "$sk_hn_for" ]; then
    ui_say "  The ShakeKee daemon is added: $sk_hn_for talks to it, and it is not installed here."
    sk_hub_add daemon ""
  fi
}

# sk_hub_plan: the plan, one line per chosen integration, in running order.
sk_hub_plan() {
  for sk_hp_s in $SK_HUB_ORDER; do
    while IFS='|' read -r sk_hp_slug sk_hp_path; do
      if [ "$sk_hp_slug" = "$sk_hp_s" ]; then
        sk_plan_add "Install $(sk_hub_label "$sk_hp_slug")${sk_hp_path:+ in $sk_hp_path}"
      fi
    done <<HUB_SEL
$SK_HUB_SEL
HUB_SEL
  done
}

# sk_hub_apply: runs the chosen recipes in order. The first one that fails stops the rest.
sk_hub_apply() {
  if [ -z "$SK_TASK" ]; then
    SK_TASK=$(mktemp -d "${TMPDIR:-/tmp}/shakekee-install.XXXXXX") || sk_fail 1 SK-E006 "Cannot create a temporary directory." "Check that ${TMPDIR:-/tmp} is writable."
  fi
  SK_HUB_DONE=
  for sk_ha_s in $SK_HUB_ORDER; do
    while IFS='|' read -r sk_ha_slug sk_ha_path; do
      if [ "$sk_ha_slug" != "$sk_ha_s" ]; then
        continue
      fi
      sk_hub_run_one "$sk_ha_slug" "$sk_ha_path"
      SK_HUB_DONE="${SK_HUB_DONE:+$SK_HUB_DONE, }$(sk_hub_label "$sk_ha_slug")"
    done <<HUB_SEL
$SK_HUB_SEL
HUB_SEL
  done
}

# sk_hub_run_one SLUG PATH: one recipe, in a subshell of its own and without the script on its input.
sk_hub_run_one() {
  sk_hr_label=$(sk_hub_label "$1")
  ui_title "$sk_hr_label${2:+ in $2}"
  sk_log "hub: $1 ${2:--}"
  # The status is taken with set +e around the subshell, not with `|| rc=$?`: in the condition of a || list
  # the shell ignores set -e also inside the subshell, and the recipes rely on it to stop at the first failure.
  set +e
  (
    set -e
    trap - EXIT
    SK_SLUG=$1
    case $(sk_hub_kind "$1") in
      daemon)
        if ! command -v sk_recipe_daemon >/dev/null 2>&1; then
          sk_fail 1 SK-E113 "This server does not offer the daemon installer." "Use the server that your instructions name, or ask whoever runs this one."
        fi
        sk_run_recipe daemon
        ;;
      path)
        if [ -n "$2" ]; then
          sk_run_recipe "$1" --path "$2"
        else
          sk_run_recipe "$1"
        fi
        ;;
      cwd)
        if [ -n "$2" ]; then
          cd "$2" || exit 1
        fi
        sk_run_recipe "$1"
        ;;
      *) sk_run_recipe "$1" ;;
    esac
  ) </dev/null
  sk_hr_rc=$?
  set -e
  case $sk_hr_rc in
    0) ;;
    129 | 130 | 143) exit "$sk_hr_rc" ;;
    *)
      sk_fail 1 SK-E114 "The installer for $sk_hr_label stopped with exit status $sk_hr_rc.${SK_HUB_DONE:+ Done before: $SK_HUB_DONE.} The ones after it were not run." "The messages above say why. Fix that and run the command again; installers that are done are safe to run again."
      ;;
  esac
}

# sk_hub_handover: what is left.
sk_hub_handover() {
  if [ "$SK_DRY" = 1 ]; then
    ui_note "Dry run: nothing was changed."
  else
    ui_title "Done"
    ui_say "Installed: ${SK_HUB_DONE:-nothing}."
    ui_note "Each installer above says what is left to do for it. See what is installed any time with: curl -fsSL $SK_ORIGIN | sh -s -- --status" # get.shakekee.com
  fi
}

sk_recipe() {
  sk_hub_main "$@"
}
if :; then sk_boot "$@"; fi
