Newer
Older
ops-toolkit / lib / lib-install.sh
# shellcheck shell=bash
# lib/lib-install.sh -- shared install hardening for ops-toolkit
# (sourced, never executed -- hence no shebang; the directive above declares
# the dialect for linting)
#
# Sourced (never executed) by bootstrap.sh and every pack installer.
# Mutates NOTHING by itself; provides gated helpers the installers call.
# All mutating helpers respect TOOLKIT_DRY_RUN=1 (print, do not act).
#
# Exit-code contract for scripts USING this lib:
#   0 = PROCEED (all steps ok or correctly no-oped)
#   1 = HOLD    (a step failed)
#   2 = precondition missing (tool absent, version below floor)
#
# ASCII + LF.

# Guard against double-source
if [ -n "${_OPS_TOOLKIT_LIB_LOADED:-}" ]; then
    return 0 2>/dev/null || exit 0
fi
_OPS_TOOLKIT_LIB_LOADED=1

FENCE_BEGIN="# >>> ops-toolkit managed block >>>"
FENCE_END="# <<< ops-toolkit managed block <<<"

# ---------------------------------------------------------------- logging ---
info() { printf 'INFO: %s\n' "$*"; }
ok()   { printf 'OK:   %s\n' "$*"; }
warn() { printf 'WARN: %s\n' "$*"; }
fail() { printf 'FAIL: %s\n' "$*" >&2; }

# ------------------------------------------------------- dry-run plumbing ---
# run_mut CMD [ARGS...] -- execute a mutating command, or print it in dry-run.
# Returns the command's status (0 in dry-run).
run_mut() {
    if [ "${TOOLKIT_DRY_RUN:-0}" = "1" ]; then
        # NB: not "$*" -- callers set IFS to newline+tab, which would join
        # the args with newlines and truncate the preview to one token.
        printf 'DRY-RUN:'
        printf ' %s' "$@"
        printf '\n'
        return 0
    fi
    "$@"
}

# -------------------------------------------------------- prerequisites -----
# require_cmd CMD [HINT] -- exit 2 with a hint if CMD is not on PATH.
require_cmd() {
    local cmd=$1 hint=${2:-}
    if ! command -v "$cmd" >/dev/null 2>&1; then
        fail "required command not found: $cmd${hint:+ ($hint)}"
        exit 2
    fi
    ok "found: $cmd"
}

# version_ge INSTALLED MINIMUM -- true if INSTALLED >= MINIMUM (sort -V).
version_ge() {
    [ "$(printf '%s\n%s\n' "$2" "$1" | sort -V | head -n1)" = "$2" ]
}

# require_version NAME INSTALLED MINIMUM -- exit 2 if below the floor.
# Floors, not pins: we check a minimum, we do not demand an exact version.
require_version() {
    local name=$1 installed=$2 minimum=$3
    if [ -z "$installed" ]; then
        fail "$name: could not determine installed version"
        exit 2
    fi
    if version_ge "$installed" "$minimum"; then
        ok "$name $installed (floor $minimum)"
    else
        fail "$name $installed is below the required floor $minimum"
        exit 2
    fi
}

# ------------------------------------------------------------ apt helper ----
# apt_install PKG... -- install packages, with sudo when not root.
apt_install() {
    local sudo_cmd=""
    if [ "$(id -u)" -ne 0 ]; then
        require_cmd sudo "needed to apt-get install as non-root"
        sudo_cmd="sudo"
    fi
    run_mut $sudo_cmd apt-get install -y "$@"
}

# ------------------------------------------------------- symlink installs ---
# backup_then_link SRC DST
#   - SRC must exist (repo file). DST is the live location (e.g. ~/.tmux.conf).
#   - If DST already symlinks to SRC: no-op, say so (idempotency made visible).
#   - If DST exists as anything else: move to DST.bak.<timestamp>, then link.
backup_then_link() {
    local src=$1 dst=$2
    if [ ! -e "$src" ]; then
        fail "link source missing: $src"
        return 2
    fi
    local src_real
    src_real=$(readlink -f "$src" || true)
    if [ -L "$dst" ]; then
        local dst_target
        dst_target=$(readlink -f "$dst" || true)
        if [ "$dst_target" = "$src_real" ]; then
            ok "already linked: $dst -> $src_real"
            return 0
        fi
    fi
    if [ -e "$dst" ] || [ -L "$dst" ]; then
        local bak
        bak="${dst}.bak.$(date +%Y%m%d-%H%M%S)"
        run_mut mv "$dst" "$bak" || { fail "could not back up $dst"; return 1; }
        warn "existing $dst backed up to $bak"
    fi
    run_mut ln -s "$src_real" "$dst" || { fail "could not link $dst"; return 1; }
    ok "linked: $dst -> $src_real"
}

# copy_if_absent SRC DST -- for user-editable files (never clobber user edits).
copy_if_absent() {
    local src=$1 dst=$2
    if [ ! -e "$src" ]; then
        fail "copy source missing: $src"
        return 2
    fi
    if [ -e "$dst" ]; then
        ok "exists, left untouched (user-owned): $dst"
        return 0
    fi
    run_mut cp "$src" "$dst" || { fail "could not copy to $dst"; return 1; }
    ok "installed: $dst (copy; edit freely, re-install never overwrites)"
}

# ensure_dir DIR
ensure_dir() {
    local dir=$1
    if [ -d "$dir" ]; then
        return 0
    fi
    run_mut mkdir -p "$dir" || { fail "could not create $dir"; return 1; }
    ok "created: $dir"
}

# ------------------------------------------------------------ rc fencing ----
# bashrc_fence TARGET  (desired block content on stdin, WITHOUT markers)
#   Replace-or-insert the single fenced ops-toolkit block in TARGET.
#   - Identical content already fenced: no-op, say so.
#   - Otherwise: back up TARGET once, strip any existing fence, append the
#     new fenced block at the end. Never append-duplicates.
bashrc_fence() {
    local target=$1
    local content current tmp
    content=$(cat)

    if [ -f "$target" ]; then
        current=$(awk -v m1="$FENCE_BEGIN" -v m2="$FENCE_END" '
            $0 == m1 { inblk = 1; next }
            $0 == m2 { inblk = 0; next }
            inblk    { print }
        ' "$target")
        if [ "$current" = "$content" ]; then
            ok "fence up to date in $target"
            return 0
        fi
    fi

    if [ "${TOOLKIT_DRY_RUN:-0}" = "1" ]; then
        printf 'DRY-RUN: would write ops-toolkit fence into %s\n' "$target"
        return 0
    fi

    if [ -f "$target" ]; then
        local bak
        bak="${target}.bak.$(date +%Y%m%d-%H%M%S)"
        cp "$target" "$bak" || { fail "could not back up $target"; return 1; }
        warn "backed up $target to $bak"
    else
        : > "$target" || { fail "could not create $target"; return 1; }
    fi

    tmp=$(mktemp) || { fail "mktemp failed"; return 1; }
    awk -v m1="$FENCE_BEGIN" -v m2="$FENCE_END" '
        $0 == m1 { inblk = 1; next }
        $0 == m2 { inblk = 0; next }
        !inblk   { print }
    ' "$target" > "$tmp"
    {
        printf '%s\n' "$FENCE_BEGIN"
        printf '%s\n' "$content"
        printf '%s\n' "$FENCE_END"
    } >> "$tmp"
    mv "$tmp" "$target" || { fail "could not update $target"; return 1; }
    ok "fence written to $target"
}