Ctrl K
ComunidadGuías…

deploy/install.sh

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
#!/usr/bin/env bash

# Provenance: vendored from https://forgejo.home.lan/authelia/bun-templates commit 89be318daf8adab5ddca957162b3b7df8429e725
# Locally extended: piped-form default raw base (DEPLOY_RAW_URL) for the one-liner below.
# ════════════════════════════════════════════════════════════════════════════
#  install.sh — install (or reinstall) one app from app.env
#
#  Local run (deploy/ copied into your app repo):
#      sudo bash deploy/install.sh [OPTIONS]
#
#  One-liner (published SynaptoMind deploy/; no environment variables needed):
#      curl -fsSL https://raw.githubusercontent.com/zumik3-del/synaptomind/main/deploy/install.sh | bash
#
#  Forks/mirrors can point elsewhere; DEPLOY_RAW_URL moves the base used for
#  lib/common.sh and app.env, and an explicit LIB_RAW_URL/APP_ENV_URL wins:
#      DEPLOY_RAW_URL=https://HOST/deploy curl -fsSL https://HOST/deploy/install.sh | bash
#
#  Options:
#      --dir DIR       Install directory        (default: INSTALL_DIR in app.env)
#      --port PORT     Port for the seeded config + health check (default: PORT)
#      --version TAG   Pin a version instead of resolving the latest
#      --force         Reinstall even when the same version is already present
#      --no-service    Skip systemd unit installation and start
#      --help, -h      Show this help
#
#  Seeded ports: a FRESH config.json gets server.port = PORT and
#  mcp.httpPort = MCP_PORT (app.env, optional — defaults to PORT + 1). An
#  existing config.json is preserved verbatim, so an installed host keeps its
#  own MCP port. See resolve_mcp_port() in lib/common.sh.
#
#  Pipeline (see the phase banners in main):
#      config -> platform + OS deps -> fetch code/binary -> data dir + symlink
#      -> seed state -> helper scripts -> systemd unit -> health check
# ════════════════════════════════════════════════════════════════════════════
set -euo pipefail

# Directory this script was loaded from. Empty for `curl ... | bash` (stdin),
# where BASH_SOURCE is unset — see load_common() below. Falling back to $0 would
# resolve to the caller's cwd and misdetect a piped run as a local one, so the
# fallback is deliberately empty.
SCRIPT_DIR=""
if [ -n "${BASH_SOURCE[0]:-}" ]; then
  SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" 2>/dev/null && pwd -P)" || SCRIPT_DIR=""
fi

# ── Raw base for the piped form (`curl … | bash`) ──────────────────────────
# A piped run has no SCRIPT_DIR, so lib/common.sh and app.env must be fetched.
# Default to the published SynaptoMind deploy/ base so the one-liner needs no
# environment; an explicit LIB_RAW_URL/APP_ENV_URL wins over the derived value,
# and DEPLOY_RAW_URL relocates the base (forks, mirrors). Local/checkout runs
# (SCRIPT_DIR non-empty) keep using the sibling files, so the defaults below
# are never applied there.
if [ -z "$SCRIPT_DIR" ]; then
  DEPLOY_RAW_URL="${DEPLOY_RAW_URL:-https://raw.githubusercontent.com/zumik3-del/synaptomind/main/deploy}"
  LIB_RAW_URL="${LIB_RAW_URL:-${DEPLOY_RAW_URL}/lib/common.sh}"
  APP_ENV_URL="${APP_ENV_URL:-${DEPLOY_RAW_URL}/app.env}"
fi

# ── Argument state (overrides app.env after it is loaded) ──────────────────
ARG_DIR=""
ARG_PORT=""
ARG_VERSION=""
FORCE=false
NO_SERVICE=false

# ── Load lib/common.sh ─────────────────────────────────────────────────────
# Normally a sibling file. Under `curl | bash` there is none, so fetch it from
# LIB_RAW_URL (defaulted above for the published base, since app.env is not
# readable yet).
load_common() {
  local cand tmp
  if [ -n "$SCRIPT_DIR" ]; then
    for cand in "${SCRIPT_DIR}/lib/common.sh" "${SCRIPT_DIR}/common.sh"; do
      if [ -f "$cand" ]; then
        # shellcheck source=lib/common.sh
        . "$cand"
        return 0
      fi
    done
  fi
  if [ -n "${LIB_RAW_URL:-}" ]; then
    # NOT mktemp_owned, and the reason is load order: common.sh is the file this
    # function has not downloaded yet, so cleanup_add does not exist at this
    # point and neither does the EXIT trap — both arrive with the sourced file,
    # and the trap is installed on the line after this function returns. The two
    # explicit `rm -f` below are the only cleanup this path can have, which is
    # why they are on BOTH branches: a failed download must not leave a
    # half-written common.sh in TMPDIR either.
    tmp="$(mktemp)" || { echo "[app] ERROR: cannot create a temporary file" >&2; exit 1; }
    if curl -fsSL "$LIB_RAW_URL" -o "$tmp" 2>/dev/null; then
      # shellcheck source=/dev/null
      . "$tmp"
      rm -f "$tmp"
      return 0
    fi
    rm -f "$tmp"
  fi
  echo "[app] ERROR: cannot load lib/common.sh (set LIB_RAW_URL when using curl|bash)" >&2
  exit 1
}

load_common

# Best-effort cleanup of temp files registered by the helpers.
trap cleanup_run EXIT

# ── Argument parsing ───────────────────────────────────────────────────────
parse_args() {
  while [ $# -gt 0 ]; do
    case "$1" in
      --dir)        ARG_DIR="$2";     shift 2 ;;
      --port)       ARG_PORT="$2";    shift 2 ;;
      --version)    ARG_VERSION="$2"; shift 2 ;;
      --force)      FORCE=true;       shift   ;;
      --no-service) NO_SERVICE=true;  shift   ;;
      --help|-h)
        sed -n '3,33p' "$0" 2>/dev/null || echo "See the comment header of install.sh"
        exit 0 ;;
      *) error "unknown option: $1 (try --help)" ;;
    esac
  done
}

# ── Config finalization ────────────────────────────────────────────────────
apply_overrides() {
  if [ -n "$ARG_DIR" ];  then INSTALL_DIR="$ARG_DIR"; fi
  if [ -n "$ARG_PORT" ]; then PORT="$ARG_PORT"; fi
  if [ -n "$ARG_VERSION" ]; then ARG_VERSION="$(normalize_v "$ARG_VERSION")"; fi
}

# Default start command when EXEC_START is left empty in app.env.
default_exec_start() {
  if [ "$DIST" = "binary" ]; then
    printf '%s' "${INSTALL_DIR}/${APP_NAME}"
  elif [ -n "${BUN_BIN:-}" ]; then
    if grep -q '"start"' "${INSTALL_DIR}/package.json" 2>/dev/null; then
      printf '%s' "${BUN_BIN} run start"
    else
      printf '%s' "${BUN_BIN} run src/index.ts"
    fi
  else
    printf '%s' "${INSTALL_DIR}/start.sh"
  fi
}

# ── Phase: platform & OS dependencies ──────────────────────────────────────
install_system_deps() {
  local pkgs="${SYSTEM_DEP_CMDS:-}" missing=() cmd
  if [ -z "$pkgs" ]; then info "No system dependencies configured"; return 0; fi
  for cmd in $pkgs; do
    if ! command -v "$cmd" >/dev/null 2>&1; then missing+=("$cmd"); fi
  done
  if [ ${#missing[@]} -eq 0 ]; then info "System dependencies OK"; return 0; fi

  info "Installing missing dependencies: ${missing[*]}"
  if command -v apt-get >/dev/null 2>&1; then
    run_root apt-get update -qq && run_root apt-get install -y -qq "${missing[@]}"
  elif command -v dnf >/dev/null 2>&1; then
    run_root dnf install -y -q "${missing[@]}"
  elif command -v yum >/dev/null 2>&1; then
    run_root yum install -y -q "${missing[@]}"
  elif command -v apk >/dev/null 2>&1; then
    run_root apk add --no-cache "${missing[@]}"
  elif command -v pacman >/dev/null 2>&1; then
    run_root pacman -S --noconfirm "${missing[@]}"
  else
    warn "No known package manager — install manually: ${missing[*]}"
  fi
}

# ── Phase: reach the target version ────────────────────────────────────────
require_source_config() {
  [ -n "${REPO_URL:-}" ] || error "REPO_URL is empty (required for DIST=source)"
  need_cmd git
}

require_binary_config() {
  [ -n "${RELEASES_BASE:-}" ]  || error "RELEASES_BASE is empty (required for DIST=binary)"
  [ -n "${ASSET_PATTERN:-}" ]  || error "ASSET_PATTERN is empty (required for DIST=binary)"
  [ -n "${APP_VERSION_CMD:-}" ] || error "APP_VERSION_CMD is empty (required for DIST=binary)"
  command -v curl >/dev/null 2>&1 || command -v wget >/dev/null 2>&1 || error "need curl or wget"
  # A host with no published asset must be told so by name, not by a 404 from
  # url_get a few lines later (ADR 0001 §2.7).
  require_binary_platform
  need_cmd tar
}

# Install Bun (source mode) when the app declares REQUIRES_BUN=yes.
install_bun_if_needed() {
  if [ "${REQUIRES_BUN:-}" != "yes" ]; then
    info "REQUIRES_BUN is not 'yes' — skipping Bun"
    return 0
  fi
  if BUN_BIN="$(locate_bun)"; then info "Bun: ${BUN_BIN}"; return 0; fi

  need_cmd curl
  info "Installing Bun for ${TARGET_USER}..."
  HOME="$TARGET_HOME" curl -fsSL https://bun.sh/install | HOME="$TARGET_HOME" bash
  if BUN_BIN="$(locate_bun)"; then
    info "Bun installed: $("$BUN_BIN" --version)"
  else
    error "Bun installation failed — binary not found"
  fi
}

# Fetch source and check out the selected ref. Exits early when up to date.
source_prepare() {
  local cur head want fresh=false
  if [ -d "${INSTALL_DIR}/.git" ]; then
    info "Updating existing checkout in ${INSTALL_DIR}..."
    git -C "$INSTALL_DIR" fetch --tags --force origin
  else
    info "Cloning ${REPO_URL}..."
    mkdir -p "$(dirname "$INSTALL_DIR")"
    git clone --depth=1 "$REPO_URL" "$INSTALL_DIR"
    git -C "$INSTALL_DIR" fetch --tags --force origin
    fresh=true
  fi

  if [ -n "$ARG_VERSION" ]; then
    TARGET_REF="$ARG_VERSION"; TARGET_KIND="tag"
  else
    resolve_source_ref "$INSTALL_DIR"
  fi

  if [ "$FORCE" != true ] && [ "$fresh" != true ]; then
    if [ "$TARGET_KIND" = "tag" ]; then
      cur="$(read_package_version "${INSTALL_DIR}/package.json")"
      case "$(ver_cmp "${cur:-0}" "${TARGET_REF#v}")" in
        same)  up_to_date_exit "${cur:-${TARGET_REF#v}}" ;;
        newer) error "newer version ${cur} is already installed; use --force to override" ;;
      esac
    else
      head="$(git -C "$INSTALL_DIR" rev-parse HEAD 2>/dev/null || true)"
      want="$(git -C "$INSTALL_DIR" rev-parse "origin/${TARGET_REF}" 2>/dev/null || true)"
      if [ -n "$head" ] && [ "$head" = "$want" ]; then
        up_to_date_exit "$(read_package_version "${INSTALL_DIR}/package.json" || echo unknown)"
      fi
    fi
  fi

  if [ "$TARGET_KIND" = "tag" ]; then
    git -C "$INSTALL_DIR" checkout --force "$TARGET_REF"
  else
    git -C "$INSTALL_DIR" checkout --force -B "$TARGET_REF" --track "origin/${TARGET_REF}"
  fi
  info "Checked out ${TARGET_REF}"
}

# install_deps / run_build live in lib/common.sh, so install.sh and update.sh
# take the identical path: see the "Source mode: dependencies & build" section
# there. Before this they were hardcoded on each side (`bun install
# --frozen-lockfile --production` in install.sh and again in update.sh, and no
# build step anywhere), and two copies of one command line is how an update ends
# up reproducing the bug an install had already been fixed for.

# Binary mode: resolve the tag to install.
resolve_binary_version() {
  if [ -n "$ARG_VERSION" ]; then
    TAG="$ARG_VERSION"
  else
    release_resolve_tag
    TAG="$RESOLVED_TAG"
  fi
  info "Target version: ${TAG#v}"
}

# Binary mode: exit early when the installed binary is current.
binary_up_to_date_check() {
  local bin="${INSTALL_DIR}/${APP_NAME}" cur
  [ -f "$bin" ] || return 0
  [ "$FORCE" = true ] && return 0
  cur="$(app_version "$bin")"
  # BOTH operands normalised: app_version keeps the leading "v" (common.sh
  # strips only the app name) and the tag is v-prefixed too, while
  # `ver_cmp "v0.8.0" "0.8.0"` answers "newer" — a correct, already-installed
  # payload used to abort with "use --force" (ADR 0001 §2.6).
  case "$(ver_cmp "$(normalize_v "$cur")" "$(normalize_v "$TAG")")" in
    same)  up_to_date_exit "${cur#v}" ;;
    newer) error "newer version ${cur} is already installed; use --force to override" ;;
  esac
}

# Binary mode: download the release tarball, extract it, verify it, swap it in.
# The whole sequence lives in lib/common.sh so update.sh takes the identical
# path (ADR 0001 §2.9).
install_binary() {
  binary_install_payload
}

# ── Phase: seed state (config files, secret, data dir) ─────────────────────
SEEDED_SECRET_FILE=false

# align_config_port FILE KEY VALUE — rewrite `"KEY": <n>` in FILE to
# `"KEY": VALUE`. KEY is matched literally INCLUDING its quotes, which is what
# keeps '"port"' from touching '"httpPort"'. A missing key is a no-op, so a
# payload that ships neither key seeds unchanged.
align_config_port() {
  local file="$1" key="$2" value="$3"
  [ -f "$file" ] || return 0
  sed -i "s/${key}[[:space:]]*:[[:space:]]*[0-9][0-9]*/${key}: ${value}/" "$file"
}

# seed_copy SRC DST BASE — copy one example into place as DST.
#
# BASE is DST's basename, passed in by the caller so the same value can drive the
# config.json port alignment without a second basename call. It lives here, and
# not inline in each seeding pass, because both invariants below are the ones a
# secret-bearing .env depends on: the copy runs under umask 077, and a dotfile is
# chmod-ed 600. Two copies of that rule in two passes is how one of them drifts
# into leaving a secret world-readable.
#
# The parent is created rather than assumed. R1's DATA_DIR is a home an app
# declares for its config, so on a host whose only DATA_DIR content is that
# config the directory may not exist when the seeding pass runs.
seed_copy() {
  local src="$1" dst="$2" base="$3"
  mkdir -p "$(dirname "$dst")"
  ( umask 077; cp "$src" "$dst" )
  case "$base" in .*) chmod 600 "$dst" ;; esac
}

seed_files() {
  local pair src dest base
  SEEDED_SECRET_FILE=false
  for pair in ${SEED_FILES:-}; do
    src="${pair%%:*}"; dest="${pair#*:}"
    if [ -e "${INSTALL_DIR}/${dest}" ]; then
      info "Preserved existing ${dest}"
      continue
    fi
    if [ ! -f "${INSTALL_DIR}/${src}" ]; then
      warn "seed source not found: ${src} (skipping ${dest})"
      continue
    fi
    base="$(basename "$dest")"
    seed_copy "${INSTALL_DIR}/${src}" "${INSTALL_DIR}/${dest}" "${base}"
    # A seeded config.json is the port source: align BOTH listeners with the
    # ports this install resolved. mcp.httpPort is rewritten for the same
    # reason server.port is — the example ships 3006, and a host that already
    # owns 3006 would otherwise install a service that can never start
    # (resolve_mcp_port in lib/common.sh for the rule; task #1077).
    if [ "$base" = "config.json" ]; then
      align_config_port "${INSTALL_DIR}/${dest}" '"port"' "$PORT"
      align_config_port "${INSTALL_DIR}/${dest}" '"httpPort"' "$MCP_PORT"
    fi
    if [ -n "${GENERATE_SECRET_IN:-}" ] && [ "$dest" = "$GENERATE_SECRET_IN" ]; then
      SEEDED_SECRET_FILE=true
    fi
    info "Seeded ${dest} from ${src}"
  done
  seed_files_data
}

# seed_files_data — seed SEED_FILES_DATA into DATA_DIR, reusing the rules
# seed_files() applies to INSTALL_DIR.
#
# The sibling key rather than a second destination for SEED_FILES: an app whose
# config home is DATA_DIR (R1) must be able to seed THERE, and the ADR names the
# home that way deliberately — the payload tree is swapped by an update, so a
# config seeded into it competes with the operator's own file for one pathname.
# Empty (the default) means the second pass does not run at all, so an app.env
# that does not set it keeps today's behaviour byte for byte.
#
# WHY A SECOND PASS AND NOT ONE LOOP OVER BOTH LISTS. Both lists resolve their
# source against INSTALL_DIR — the payload ships the examples, and DATA_DIR never
# holds one — and differ only in where the destination lands, so one loop over a
# (root, list) pair would do. That refactor is deliberately NOT taken here: the
# two lines below are pinned VERBATIM as strings by install.sh.test.ts's
# non-vacuity mutations (`align_config_port "${INSTALL_DIR}/${dest}" …` and
# `if [ -e "${INSTALL_DIR}/${dest}" ]`), and replacing them with a `$root`
# variable silently breaks those anchors. The duplication is confined to the
# guard and the two alignment calls; the security-relevant copy is shared through
# seed_copy() above. Extract the shared body when those anchors move with it.
#
# SEEDED_SECRET_FILE is deliberately NOT set here. That flag means "this run
# created the secret file as a seeded copy, so generate_secret_into() may fill an
# empty placeholder", and generate_secret_into() resolves its target either as
# given (absolute) or under INSTALL_DIR — never under DATA_DIR. A file seeded
# here can therefore never be the secret file, and claiming the flag would
# authorise generate_secret_into() to write into it.
seed_files_data() {
  [ -n "${SEED_FILES_DATA:-}" ] || return 0
  local pair src dest base
  # DATA_DIR empty while SEED_FILES_DATA is set is a contradiction, not a
  # default: seeding into it would resolve against INSTALL_DIR ("" → "dir/file")
  # and put a data-dir file in the payload tree, which is the exact confusion R1
  # exists to remove. Refuse rather than seed somewhere the operator did not ask.
  [ -n "${DATA_DIR:-}" ] || error "SEED_FILES_DATA is set but DATA_DIR is empty"
  for pair in ${SEED_FILES_DATA}; do
    src="${pair%%:*}"; dest="${pair#*:}"
    if [ -e "${DATA_DIR}/${dest}" ]; then
      info "Preserved existing ${dest}"
      continue
    fi
    if [ ! -f "${INSTALL_DIR}/${src}" ]; then
      warn "seed source not found: ${src} (skipping ${dest})"
      continue
    fi
    base="$(basename "$dest")"
    seed_copy "${INSTALL_DIR}/${src}" "${DATA_DIR}/${dest}" "${base}"
    # Port alignment, and it is what makes this pass safe rather than merely
    # available: a config.json seeded into DATA_DIR is the file the app READS,
    # so an unaligned copy leaves the app on the DEFAULTS in its source
    # (server.port 3005 — ziptask's port, and a collision on this host) instead
    # of the ports this install resolved. Same rule, same two listeners, as the
    # INSTALL_DIR pass.
    if [ "$base" = "config.json" ]; then
      align_config_port "${DATA_DIR}/${dest}" '"port"' "$PORT"
      align_config_port "${DATA_DIR}/${dest}" '"httpPort"' "$MCP_PORT"
    fi
    info "Seeded ${DATA_DIR}/${dest} from ${src}"
  done
}

# Insert/refresh `<APP_NAME>_SECRET` in GENERATE_SECRET_IN. A pre-existing
# secret is never rotated; an empty placeholder is filled in.
#
# B1: an ABSOLUTE GENERATE_SECRET_IN is honoured as given, so R2's
# /etc/<app>/<app>.env no longer becomes /opt/<app>//etc/<app>/<app>.env. The
# INSTALL_DIR-relative form is unchanged and remains the default. Parent
# directories are created, because an absolute path points at a directory the
# installer has otherwise never touched.
generate_secret_into() {
  [ -n "${GENERATE_SECRET_IN:-}" ] || return 0
  local file key secret
  case "${GENERATE_SECRET_IN}" in
    /*) file="${GENERATE_SECRET_IN}" ;;
    *)  file="${INSTALL_DIR}/${GENERATE_SECRET_IN}" ;;
  esac
  key="$(printf '%s' "$APP_NAME" | tr '[:lower:]-' '[:upper:]_')_SECRET"

  # mkdir -p is a no-op once the parent exists, and the file's own mode stays
  # whatever the write below sets (umask 077) — this creates the DIRECTORY, not
  # the secret.
  mkdir -p "$(dirname "$file")"

  if [ ! -e "$file" ]; then
    secret="$(generate_secret)"
    ( umask 077; printf '%s=%s\n' "$key" "$secret" > "$file" )
    info "Generated ${key} in ${GENERATE_SECRET_IN}"
    return 0
  fi
  if [ "$SEEDED_SECRET_FILE" != true ]; then return 0; fi

  if ! grep -q "^${key}=" "$file" 2>/dev/null; then
    secret="$(generate_secret)"
    printf '%s=%s\n' "$key" "$secret" >> "$file"
    info "Added ${key} to ${GENERATE_SECRET_IN}"
  elif ! grep -q "^${key}=.\+" "$file" 2>/dev/null; then
    secret="$(generate_secret)"
    ( umask 077; awk -v k="$key" -v v="$secret" '{ if ($0 == (k"=")) print k"="v; else print }' "$file" > "$file.tmp" && mv "$file.tmp" "$file" )
    info "Generated ${key} in ${GENERATE_SECRET_IN}"
  else
    info "Preserved existing ${key} in ${GENERATE_SECRET_IN}"
  fi
}

setup_data() {
  if [ -z "${DATA_DIR:-}" ]; then info "DATA_DIR is empty — no data symlink"; return 0; fi
  mkdir -p "$DATA_DIR"
  if [ ! -e "${INSTALL_DIR}/data" ]; then
    ln -s "$DATA_DIR" "${INSTALL_DIR}/data"
    info "Linked ${INSTALL_DIR}/data -> ${DATA_DIR}"
  fi
}

# setup_config_link — point ${INSTALL_DIR}/${CONFIG_LINK_NAME} at
# ${DATA_DIR}/${CONFIG_LINK_NAME}, so an app whose config home is DATA_DIR (R1,
# ADR addendum #1122) is still found through the service's fixed
# WorkingDirectory=/opt/<app>.
#
# WHY A KEY AND NOT A DERIVED LINK. The filename is the app's own business
# (config.json, settings.json) and is resolved by code the framework cannot see
# (synaptomind: join(process.cwd(), 'config.json')). Guessing it would produce a
# link that looks right and is dead — the app reading its DEFAULTS while the
# operator stares at a correct-looking link. Empty (the default) means this
# function does not run at all, so an app.env that does not set it behaves
# exactly as before.
#
# CALLED AFTER SEEDING, so that every cp in the seeding passes targets a path that
# is either absent or a real file — never a symlink. GNU cp REFUSES to write
# through a dangling symlink ("cp: not writing through dangling symlink", exit
# 1), which under `set -e` is an aborted install; through a live one it silently
# writes into the link's target. Both outcomes are reachable only while the link
# exists and the passes have not run yet, so the link is created afterwards. The
# order also keeps the dangling-target warning below honest: a link is announced
# only when nothing in this same run is about to write its target.
#
# NEVER REPLACES A REAL FILE. A regular file or directory at the link path is
# reported and left alone: an operator's hand-edited config is data, and a deploy
# script that unlinks it to win an argument about which tree is canonical is a
# script that can lose a config. The link is created once the path is free, and
# the warning names the move that finishes R1 by hand.
setup_config_link() {
  [ -n "${CONFIG_LINK_NAME:-}" ] || return 0
  local name="${CONFIG_LINK_NAME}" link target
  # DATA_DIR empty while CONFIG_LINK_NAME is set is a contradiction, not a
  # default: the target would resolve against INSTALL_DIR and point the app at a
  # file in the very tree the link exists to leave. Same refusal as
  # seed_files_data().
  [ -n "${DATA_DIR:-}" ] || error "CONFIG_LINK_NAME is set but DATA_DIR is empty"
  # A bare file name, never a path. A name carrying a separator means something
  # other than what it says, and "../../etc/x" would write outside INSTALL_DIR.
  case "$name" in
    */*|.|..) error "CONFIG_LINK_NAME must be a bare file name (got '${name}')" ;;
  esac
  target="${DATA_DIR}/${name}"
  link="${INSTALL_DIR}/${name}"
  # Idempotent by design: a re-install over a host that already has the link says
  # so and changes nothing, which is what makes this safe on every run.
  if [ -L "$link" ]; then
    if [ "$(readlink "$link")" = "$target" ]; then
      info "Preserved ${link} -> ${target}"
      return 0
    fi
    # A symlink is a pointer, not data: re-pointing one that names some other
    # tree is the "refresh" half of the contract, and whatever it pointed at
    # keeps its contents either way.
    rm -f "$link"
  elif [ -e "$link" ]; then
    warn "${link} is a real file, not a link — left exactly as it is."
    warn "the app reads ${link}; R1 wants that config at ${target}."
    warn "move it there — choosing which file wins if ${target} already exists — and re-run this installer."
    return 0
  fi
  mkdir -p "$DATA_DIR"
  ln -s "$target" "$link"
  info "Linked ${link} -> ${target}"
  # A link to nothing is legitimate — an app that writes its own config on first
  # run needs exactly that — but it is also how an app ends up on its DEFAULTS,
  # so the state is stated rather than assumed.
  if [ ! -e "$target" ]; then
    warn "${target} does not exist yet — the app uses its DEFAULTS until something writes it there."
  fi
}

# apply_ownership, chown_target, ownership_mismatch and apply_run_dir_ownership
# live in lib/common.sh, so install.sh and update.sh apply ownership through one
# implementation: an update that swaps a payload creates root-owned files exactly
# as an install does (#1113), and only the install side owned this logic.

# ── Phase: helper scripts in RUN_DIR/scripts ───────────────────────────────
# update.sh / updater.sh / uninstall.sh / common.sh / app.env are installed together so the
# app can be managed later without the original deploy/ directory.
install_helper_scripts() {
  local dest="${RUN_DIR}/scripts" base f src cand
  mkdir -p "$dest"
  # Directory of the published deploy/, derived from LIB_RAW_URL (…/deploy/lib/common.sh).
  base="${LIB_RAW_URL:-}"; base="${base%/*}"; base="${base%/*}"

  for f in update.sh updater.sh uninstall.sh common.sh app.env; do
    src=""
    for cand in "${SCRIPT_DIR}/${f}" "${SCRIPT_DIR}/lib/${f}" "${SCRIPT_DIR}/app.env"; do
      if [ "$f" = "app.env" ] && [ -n "${APP_ENV_FILE:-}" ] && [ -f "${APP_ENV_FILE}" ]; then
        src="$APP_ENV_FILE"; break
      fi
      if [ -f "$cand" ] && [ "$(basename "$cand")" = "$f" ]; then src="$cand"; break; fi
    done
    if [ -n "$src" ]; then
      cp -f "$src" "${dest}/${f}"
    elif [ -n "${LIB_RAW_URL:-}" ] && url_get "${base}/${f}" "${dest}/${f}" 2>/dev/null; then
      :
    else
      warn "could not install helper: ${f}"
      continue
    fi
    if [ "$f" = "app.env" ]; then chmod 600 "${dest}/${f}"; else chmod +x "${dest}/${f}"; fi
  done
  info "Helpers installed in ${dest}"
}

# ── Phase: update hooks in RUN_DIR/hooks ───────────────────────────────────
# install_hook_scripts installs pre-update / post-update hooks next to the
# helper scripts so update.sh finds them via its default HOOKS_DIR
# (${RUN_DIR}/hooks).  update.sh calls run_hook {pre,post}-update before and
# after the code swap; the hooks are executable scripts sourced from this
# directory.  See deploy/update.sh:run_hook for the contract.
install_hook_scripts() {
  local dest="${RUN_DIR}/hooks" base f src cand
  mkdir -p "$dest"
  # Directory of the published deploy/, derived from LIB_RAW_URL.
  base="${LIB_RAW_URL:-}"; base="${base%/*}"; base="${base%/*}"

  for f in pre-update post-update; do
    src=""
    for cand in "${SCRIPT_DIR}/hooks/${f}" "${SCRIPT_DIR}/${f}"; do
      if [ -f "$cand" ] && [ "$(basename "$cand")" = "$f" ]; then src="$cand"; break; fi
    done
    if [ -n "$src" ]; then
      cp -f "$src" "${dest}/${f}"
    elif [ -n "${LIB_RAW_URL:-}" ] && url_get "${base}/hooks/${f}" "${dest}/${f}" 2>/dev/null; then
      :
    else
      warn "could not install hook: ${f}"
      continue
    fi
    chmod +x "${dest}/${f}"
  done
  info "Hooks installed in ${dest}"
}

# ── Phase: systemd unit ────────────────────────────────────────────────────
SERVICE_INSTALLED=false
install_service() {
  if [ "$NO_SERVICE" = true ]; then info "Skipping systemd service (--no-service)"; return 0; fi
  if [ ! -d /etc/systemd/system ] || ! command -v systemctl >/dev/null 2>&1; then
    info "systemd not found — skipping service installation"
    return 0
  fi
  if ! systemd_running; then
    warn "systemd not running — skipping service installation"
    return 0
  fi
  if [ "$(id -u)" -ne 0 ] && ! command -v sudo >/dev/null 2>&1; then
    warn "sudo not available — skipping service installation"
    return 0
  fi

  # UNIT_FILE is overridable so a non-standard unit path (and the deploy tests)
  # need not write to /etc/systemd/system. Mirrors update.sh's refresh_unit.
  local unit="${UNIT_FILE:-/etc/systemd/system/${APP_NAME}.service}" tmpdir tmp
  # Render under a valid unit name: systemd-analyze verify rejects other suffixes.
  # The DIRECTORY is what gets registered, not the file inside it: registering
  # only "$tmp" left the mktemp -d itself behind on every path that returns
  # before the render is written, and cleanup_run's `rm -rf` removes a directory
  # and its contents in one entry, so owning the directory is both sufficient
  # and simpler. (Measured: 3 empty tmp.XXXXXXXXXX dirs per deploy-suite run,
  # from the write-failure and no-unit-on-disk paths below.)
  mktemp_owned tmpdir -d
  tmp="${tmpdir}/${APP_NAME}.service"
  # The renderer REFUSES a value that would not survive systemd's parser and
  # returns 1 instead of exiting, so the reason it printed reaches the operator
  # together with the decision to stop. Nothing has been written at this point,
  # so aborting the install is fail-closed: the alternative is shipping a unit
  # whose hardening a value in app.env silently removed.
  if ! render_systemd_unit "$EXEC_START" > "$tmp"; then
    error "cannot render a unit for ${unit} — fix the value named above in app.env and re-run"
  fi

  # Best-effort: systemd-analyze can complain about paths that only exist post-boot.
  if command -v systemd-analyze >/dev/null 2>&1; then
    if systemd-analyze verify "$tmp" >/dev/null 2>&1; then info "Unit verified"; else warn "systemd-analyze verify reported issues"; fi
  fi

  # Keep the body being replaced, so a re-install over a hand-edited unit is
  # recoverable. A fresh install has no unit to lose, and then there is no .bak.
  if [ -e "$unit" ] && ! write_file_atomically "${unit}.bak" "$unit"; then
    warn "  proceeding WITHOUT a recovery copy of the unit this install replaces."
  fi

  # Atomic replacement, staged beside the unit and swapped with rename(2) — the
  # same mechanism update.sh uses, and the same reason: `cp -f` opens the LIVE
  # unit O_TRUNC, so a copy that dies partway (ENOSPC, EIO, killed) truncates an
  # existing hand-edited production unit while this run reports "cannot write".
  # / on this host reached 100% with zero bytes free during the 0.9.0 review, so
  # the trigger is demonstrated rather than theoretical — and this is the path a
  # FRESH install takes, i.e. the 0.9.0 cutover. The mode is preserved rather
  # than forced to 644, so a unit carrying Environment= secrets is not widened.
  if write_file_atomically "$unit" "$tmp"; then
    rm -f "$tmp"
  else
    warn "cannot write ${unit} — skipping service installation"
    return 0
  fi
  run_root systemctl daemon-reload || warn "systemctl daemon-reload failed"
  if run_root systemctl enable "$APP_NAME" >/dev/null 2>&1; then info "Enabled ${APP_NAME}"; else warn "could not enable ${APP_NAME}"; fi
  SERVICE_INSTALLED=true
}

# ── Phase: start & verify ──────────────────────────────────────────────────
HEALTH_OK=true
start_and_verify() {
  if [ "$NO_SERVICE" = true ]; then info "Skipping service start (--no-service)"; return 0; fi
  if [ "$SERVICE_INSTALLED" != true ]; then return 0; fi
  # `restart`, NOT `start` (task #1103).
  #
  # `systemctl start` on an ALREADY ACTIVE unit is a documented no-op: the
  # running process is left exactly as it is. So `install.sh --force` over a
  # live service wrote the new payload and the new unit, then asked systemd to
  # start a unit that was already running — and the OLD process went on serving.
  # Measured on the real 0.9.0 cutover (2026-10-01): the install reported
  # `Installed /opt/synaptomind/synaptomind (0.9.0)`, rendered the new unit, and
  # then failed its own health gate with `/health reports version 0.8.0, expected
  # 0.9.0` — MainPID unchanged, NRestarts=0. The gate was RIGHT; the start was the
  # silent part, and the install only took effect once an operator ran
  # `systemctl restart` by hand.
  #
  # Why `restart` and not the alternatives:
  #   * `try-restart` is a no-op on an INACTIVE unit, so a FIRST install would
  #     never come up and the gate would time out on a service that was never
  #     asked to start. That is the wrong trade for a first install.
  #   * `stop` + `start` reaches the same end state through two privileged calls
  #     and leaves a window in which nothing is serving — during exactly the
  #     window in which the health gate starts polling. `restart` is one call,
  #     so systemd orders stop-then-start itself, and the unit comes up whether
  #     or not it was active: that is what makes it correct for both paths.
  if run_root systemctl restart "$APP_NAME"; then
    # Wording is deliberate: this line reports the systemctl call's outcome, NOT
    # that the service is healthy. The health gate below is the authority, and it
    # prints AFTER print_summary — so on the failure path the operator sees this
    # line, then the === … installed === block, then the gate error.
    info "Restarted ${APP_NAME} onto the installed payload (the health check below decides whether it came up)"
  else
    # Not fatal here: the gate below is the authority on whether the service came
    # up, and this message says which step to look at when it did not.
    warn "systemctl restart ${APP_NAME} failed — the health check below decides whether this install took effect"
  fi
  if ! wait_health "$HEALTH_URL" "$EXPECTED_VERSION" "$HEALTH_TIMEOUT"; then
    HEALTH_OK=false
    warn "check: journalctl -u ${APP_NAME} -n 100 --no-pager"
  fi
}

# ── Helpers: early exit & guidance ─────────────────────────────────────────
up_to_date_exit() {
  echo ""
  info "${APP_NAME} ${1} is already installed at ${INSTALL_DIR} — up to date."
  print_rerun_guide
  exit 0
}

print_rerun_guide() {
  echo ""
  echo "[${APP_NAME}] Update:    bash ${RUN_DIR}/scripts/updater.sh"
  echo "[${APP_NAME}] Uninstall: bash ${RUN_DIR}/scripts/uninstall.sh"
  echo "[${APP_NAME}] Health:    curl ${HEALTH_URL}"
}

print_summary() {
  local version="${EXPECTED_VERSION:-unknown}"
  echo ""
  echo "=== ${APP_NAME} installed ==="
  echo ""
  echo "  Version:    ${version}"
  echo "  Mode:       ${DIST}"
  echo "  Location:   ${INSTALL_DIR}"
  [ -n "${DATA_DIR:-}" ] && echo "  Data:       ${DATA_DIR}"
  echo "  State:      ${RUN_DIR}"
  # The two files an operator is looking for are BOTH printed, each naming which
  # one it is. This line used to say `Config:` and print ONLY
  # ${RUN_DIR}/scripts/app.env — the deploy configuration, which after R1 lives in
  # the framework's own state dir, not in the app's config home. So the label
  # pointed at the app's config while printing a file that is not it, and an
  # operator who went looking for `config.json` found an `app.env` instead
  # (ADR addendum open item 5). The deploy path keeps its `Config:` prefix — a
  # state-dir containment assertion pins that exact string, and it is true of
  # this line — and now says what kind of config it is; the app's own file, which
  # is what the label used to imply, is printed separately and only when the app
  # declares one, so an app without a separate config file gains no line naming a
  # file it does not have.
  echo "  Config:     ${RUN_DIR}/scripts/app.env (deploy settings)"
  [ -n "${CONFIG_FILE:-}" ] && echo "  App config: ${CONFIG_FILE}"
  echo ""
  if [ "$SERVICE_INSTALLED" = true ]; then
    echo "  Service:    ${UNIT_FILE:-/etc/systemd/system/${APP_NAME}.service}"
    echo "  Stop:       sudo systemctl stop ${APP_NAME}"
    echo "  Logs:       journalctl -u ${APP_NAME} -f"
  else
    echo "  Service:    skipped"
    echo "  Start:      ${EXEC_START}"
  fi
  echo "  Health:     curl ${HEALTH_URL}"
  echo "  Update:     bash ${RUN_DIR}/scripts/updater.sh"
  echo "  Uninstall:  bash ${RUN_DIR}/scripts/uninstall.sh"
  echo ""
}

# ── Main ───────────────────────────────────────────────────────────────────
main() {
  parse_args "$@"

  # -- config --
  load_app_env
  apply_overrides
  [ -n "${APP_NAME:-}" ] || error "APP_NAME is not set in app.env"
  detect_os
  detect_arch
  resolve_target_user

  INSTALL_DIR="${INSTALL_DIR:-/opt/${APP_NAME}}"
  PORT="${PORT:-3000}"
  # Seeding target for mcp.httpPort. Resolved before seed_files() and NOT from
  # an existing config.json: that file is preserved verbatim (see
  # resolve_mcp_port), so an already-installed host keeps its own MCP port.
  MCP_PORT="$(resolve_mcp_port)"
  # The one way the two listeners can collide is an explicit MCP_PORT equal to
  # PORT. Reject it here — before the payload is fetched — rather than install a
  # service whose only possible outcome is EADDRINUSE on the second listener.
  if [ "$MCP_PORT" = "$PORT" ]; then
    error "MCP_PORT (${MCP_PORT}) must differ from PORT (${PORT})"
  fi
  HEALTH_TIMEOUT="${HEALTH_TIMEOUT:-60}"
  HEALTH_URL="${HEALTH_URL:-http://127.0.0.1:$(resolve_port)/health}"
  HOOKS_DIR="${HOOKS_DIR:-${RUN_DIR}/hooks}"
  # Before the payload is fetched, so an unreadable contract is refused while
  # the host is still untouched.
  require_health_contract

  info "Installing ${APP_NAME} (dist=${DIST})..."

  # -- platform & OS deps --
  install_system_deps

  # -- reach the target version --
  if [ "$DIST" = "source" ]; then
    require_source_config
    install_bun_if_needed
    source_prepare
    # BEFORE the build, and this is the source-mode ordering that is
    # load-bearing: `git clone`/`checkout` above ran as root, so INSTALL_DIR and
    # everything in it are root-owned, while install_deps_and_build runs as
    # TARGET_USER and has to WRITE there (node_modules, build/, .svelte-kit).
    # Without this the build dies with an EACCES from inside a bundler that
    # names neither the directory nor this function (#1114). It is also the
    # reason INSTALL_DIR need not pre-exist owned by TARGET_USER.
    apply_ownership
    install_deps_and_build
    EXPECTED_VERSION="$(read_package_version "${INSTALL_DIR}/package.json")"
  elif [ "$DIST" = "binary" ]; then
    require_binary_config
    resolve_binary_version
    binary_up_to_date_check
    # install_binary → binary_swap_payload moves the payload in as root, so the
    # files that land in INSTALL_DIR are root-owned until ownership is applied
    # below. The binary path has no build step, so one apply_ownership after it
    # is the whole fix.
    install_binary
    EXPECTED_VERSION="${TAG#v}"
  else
    error "DIST must be 'source' or 'binary' (got '${DIST}')"
  fi
  EXPECTED_VERSION="${EXPECTED_VERSION:-unknown}"

  # -- data dir, seed state, helpers + hooks --
  # setup_data BEFORE seed_files: SEED_FILES_DATA writes into DATA_DIR (R1), and
  # both the destination directory and the ./data symlink must exist first — the
  # symlink so a relative default in the seeded config resolves to DATA_DIR, and
  # the directory so the copy has somewhere to land.
  setup_data
  seed_files
  # AFTER the seeding passes and BEFORE apply_ownership. After, so the passes never
  # hand `cp` a symlink to write through (see setup_config_link); before
  # apply_ownership, so the link and the file it points at are covered by the same
  # ownership pass as everything else this run wrote.
  setup_config_link
  generate_secret_into
  # AFTER the payload lands and the data dir exists, and BEFORE the service is
  # started: the two orderings are both load-bearing. Chowning earlier would miss
  # the seeded config and the generated secret (both are written after, as root);
  # starting earlier would let the service hold an open handle on files whose
  # ownership then changes underneath it. In source mode this is the SECOND call
  # — the first, before the build, covers what the build itself wrote.
  apply_ownership
  install_helper_scripts
  install_hook_scripts
  # LAST of the ownership work: install_helper_scripts()/install_hook_scripts()
  # write RUN_DIR as root, and RUN_DIR is read as TARGET_USER by update.sh,
  # updater.sh and the pre-update hook (app.env is mode 600). Chowning it before
  # them would chown nothing.
  apply_run_dir_ownership

  # -- service --
  EXEC_START="${EXEC_START:-$(default_exec_start)}"
  install_service
  start_and_verify

  print_summary
  if [ "$HEALTH_OK" != true ]; then
    error "installed but the service did not pass the health check"
  fi
  info "Done."
}

main "$@"