#!/usr/bin/env bash set -Eeuo pipefail PATH=/usr/local/bin:/usr/bin:/usr/sbin:/sbin:/bin export PATH umask 077 # Manual updater for operators without using the web control. The same # verified TypeScript updater used by the systemd queue performs download, # extraction and atomic release switching. PREFIX=${TALLYNOTE_INSTALL_PREFIX:-${TALLYNOTE_PREFIX:-/opt/tallynote}} DATA_DIR=${TALLYNOTE_DATA_DIR:-/var/lib/tallynote} CONFIG_DIR=${TALLYNOTE_CONFIG_DIR:-/etc/tallynote} CONFIG_FILE="$CONFIG_DIR/tallynote.env" REQUEST_FILE=${TALLYNOTE_UPDATE_REQUEST_FILE:-$DATA_DIR/update-request.json} NODE=${TALLYNOTE_NODE:-} node_is_usable() { local candidate=$1 major [[ -n "$candidate" && -x "$candidate" ]] || return 1 major=$("$candidate" -p 'process.versions.node.split(".")[0]' 2>/dev/null || true) [[ "$major" =~ ^[0-9]+$ && "$major" -ge 24 ]] } if [[ -z "$NODE" && -f "$CONFIG_FILE" && ! -L "$CONFIG_FILE" ]]; then NODE=$(sed -n 's/^TALLYNOTE_NODE=//p' "$CONFIG_FILE" | head -n 1) fi die() { printf 'tallynote update: %s\n' "$*" >&2; exit 1; } version_sort_desc() { if sort -V /dev/null 2>&1; then sort -V -r return fi awk -F'[.-]' '{ printf "%020d.%020d.%020d.%s\t%s\n", $1, $2, $3, ($4 == "" ? "~" : $4), $0 }' \ | sort -r | cut -f2- } [[ ${EUID:-$(id -u)} -eq 0 ]] || die 'must run as root' if [[ "${1:-}" == "--rollback" ]]; then current="$PREFIX/current" [[ -L "$current" ]] || die 'current release is not a symlink' current_target=$(readlink -f -- "$current") current_name=$(basename -- "$current_target") [[ "$current_name" =~ ^[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.-]+)?(\+[0-9A-Za-z.-]+)?$ ]] || die 'current release version is invalid' mapfile -t releases < <( find "$PREFIX/releases" -mindepth 1 -maxdepth 1 -type d -printf '%p\n' \ | awk -F/ '$NF ~ /^[0-9]+\.[0-9]+\.[0-9]+([.-][0-9A-Za-z.-]+)?$/' \ | version_sort_desc ) previous='' found_current=0 for release in "${releases[@]}"; do release_target=$(readlink -f -- "$release") if [[ "$release_target" == "$current_target" ]]; then found_current=1 continue fi if (( found_current )); then previous=$release break fi done [[ -n "$previous" && -d "$previous" ]] || die 'no previous release available' was_active=0 if systemctl is-active --quiet tallynote.service; then was_active=1; fi if (( was_active )); then systemctl stop tallynote.service; fi tmp="$PREFIX/.current-rollback-$$-${RANDOM}" [[ ! -e "$tmp" && ! -L "$tmp" ]] || die 'rollback temporary path already exists' ln -s -- "$previous" "$tmp" mv -Tf -- "$tmp" "$current" if (( was_active )); then systemctl start tallynote.service; fi printf 'rolled back to %s\n' "$(basename -- "$previous")" exit 0 fi [[ -f "$REQUEST_FILE" ]] || die "no queued update request at $REQUEST_FILE" [[ -L "$PREFIX/current" ]] || die 'current release is not a symlink' # Prefer the systemd runner, which performs the post-switch health check and # rollback. The fallback remains useful in development installations where the # privileged helper has not been installed yet. if [[ -x /usr/local/libexec/tallynote-update-runner ]]; then exec /usr/local/libexec/tallynote-update-runner fi if [[ -z "$NODE" ]]; then NODE=$(command -v node || true) fi node_is_usable "$NODE" || die 'Node.js 24+ not found' CLI="$PREFIX/current/dist/server/cli/update.js" [[ -f "$CLI" ]] || die 'update CLI not found' was_active=0 if systemctl is-active --quiet tallynote.service; then was_active=1; fi if (( was_active )); then systemctl stop tallynote.service; fi set +e "$NODE" "$CLI" --request-file "$REQUEST_FILE" result=$? set -e if (( result != 0 )); then rm -f -- "$REQUEST_FILE" if (( was_active )); then systemctl start tallynote.service || true; fi die 'update failed; the previous release remains active' fi if (( was_active )); then systemctl start tallynote.service || { rm -f -- "$REQUEST_FILE"; die 'updated service failed to start'; }; fi rm -f -- "$REQUEST_FILE" printf 'update completed; inspect the update page for details\n'