#!/usr/bin/env bash set -euo pipefail APP_NAME="SyncBank" INSTALL_DIR="${SYNCBANK_INSTALL_DIR:-$HOME/.syncbank}" BACKUP_DIR="${INSTALL_DIR}-backup-last" MANIFEST_URL="${SYNCBANK_MANIFEST_URL:-https://syncbank.app/releases/latest.json}" START_URL="${SYNCBANK_START_URL:-http://localhost:3030}" TMP_DIR="$(mktemp -d)" CONFIG_DIR="${SYNCBANK_CONFIG_DIR:-}" cleanup() { rm -rf "$TMP_DIR" } trap cleanup EXIT log() { printf '%s\n' "$*" } fail() { log "Error: $*" exit 1 } require_command() { command -v "$1" >/dev/null 2>&1 || fail "Missing required command: $1" } json_field() { local json="$1" local key="$2" printf '%s' "$json" | sed -n -E "s/.*\"${key}\"[[:space:]]*:[[:space:]]*\"([^\"]+)\".*/\\1/p" | head -n 1 } json_bool_field() { local json="$1" local key="$2" printf '%s' "$json" | sed -n -E "s/.*\"${key}\"[[:space:]]*:[[:space:]]*(true|false).*/\\1/p" | head -n 1 } read_local_version() { local version_file="$1/version.json" [ -f "$version_file" ] || return 0 sed -n -E 's/.*"version"[[:space:]]*:[[:space:]]*"([^"]+)".*/\1/p' "$version_file" | head -n 1 } sha256_file() { local path="$1" if command -v shasum >/dev/null 2>&1; then shasum -a 256 "$path" | awk '{print $1}' return fi if command -v sha256sum >/dev/null 2>&1; then sha256sum "$path" | awk '{print $1}' return fi fail "Missing checksum tool (shasum or sha256sum)." } verify_checksum() { local file_path="$1" local expected="$2" [ -n "$expected" ] || return 0 expected="${expected#sha256:}" local actual actual="$(sha256_file "$file_path")" [ "$actual" = "$expected" ] || fail "Checksum mismatch for downloaded package." } open_browser() { local url="$1" if command -v open >/dev/null 2>&1; then open "$url" >/dev/null 2>&1 || true return fi if command -v xdg-open >/dev/null 2>&1; then xdg-open "$url" >/dev/null 2>&1 || true fi } detect_compose() { if docker compose version >/dev/null 2>&1; then COMPOSE_CMD=(docker compose) return fi if command -v docker-compose >/dev/null 2>&1; then COMPOSE_CMD=(docker-compose) return fi fail "Docker Compose not found." } compose_run() { local workdir="$1" shift local output_file="$TMP_DIR/compose-output.log" if (cd "$workdir" && "${COMPOSE_CMD[@]}" "$@" >"$output_file" 2>&1); then grep -v 'already exists but was not created by Docker Compose' "$output_file" || true return 0 fi grep -v 'already exists but was not created by Docker Compose' "$output_file" || true return 1 } detect_config_dir() { if [ -n "$CONFIG_DIR" ]; then return fi case "$(uname -s)" in Darwin) CONFIG_DIR="$HOME/Library/Application Support/SyncBank" ;; Linux) if [ -n "${XDG_DATA_HOME:-}" ]; then CONFIG_DIR="$XDG_DATA_HOME/SyncBank" else CONFIG_DIR="$HOME/.local/share/SyncBank" fi ;; *) CONFIG_DIR="$HOME/.local/share/SyncBank" ;; esac } quote_env_value() { local raw="$1" raw="${raw//\\/\\\\}" raw="${raw//\"/\\\"}" printf '"%s"\n' "$raw" } write_install_env() { local install_root="$1" local env_file="$install_root/.env" local tmp_env="$TMP_DIR/install.env" local quoted_config quoted_config="$(quote_env_value "$CONFIG_DIR")" if [ -f "$env_file" ]; then grep -v '^SYNCBANK_CONFIG_DIR_HOST=' "$env_file" > "$tmp_env" || true else : > "$tmp_env" fi printf 'SYNCBANK_CONFIG_DIR_HOST=%s\n' "$quoted_config" >> "$tmp_env" mv "$tmp_env" "$env_file" } copy_preserved_item() { local source="$1" local target="$2" [ -e "$source" ] || return 0 if [ -d "$source" ]; then mkdir -p "$target" rsync -a "$source"/ "$target"/ else mkdir -p "$(dirname "$target")" cp -p "$source" "$target" fi } restore_preserved_runtime() { local backup_root="$1" local install_root="$2" [ -d "$backup_root" ] || return 0 copy_preserved_item "$backup_root/Data" "$install_root/Data" copy_preserved_item "$backup_root/actual-data" "$install_root/actual-data" copy_preserved_item "$backup_root/data" "$install_root/data" if [ -f "$backup_root/.env" ]; then copy_preserved_item "$backup_root/.env" "$install_root/.env" fi local env_file for env_file in "$backup_root"/.env.bank-*; do [ -e "$env_file" ] || continue copy_preserved_item "$env_file" "$install_root/$(basename "$env_file")" done if [ -f "$backup_root/syncbank-renewals.ics" ]; then copy_preserved_item "$backup_root/syncbank-renewals.ics" "$install_root/syncbank-renewals.ics" fi } regenerate_compose() { local install_root="$1" local backup_root="$2" local config_file="$CONFIG_DIR/config.json" if [ ! -f "$config_file" ]; then if [ -f "$backup_root/docker-compose.yml" ]; then cp -p "$backup_root/docker-compose.yml" "$install_root/docker-compose.yml" log "No persisted config yet. Restored previous docker-compose.yml." else log "No persisted config yet. Keeping bundled docker-compose.yml." fi return 0 fi if compose_run "$install_root" run --rm syncbank-wizard sh -lc "python -u app/setup_wizard.py --write-compose-only --no-browser"; then return 0 fi if [ -f "$backup_root/docker-compose.yml" ]; then cp -p "$backup_root/docker-compose.yml" "$install_root/docker-compose.yml" log "Compose regeneration failed. Restored previous docker-compose.yml." return 0 fi fail "Compose regeneration failed and no previous docker-compose.yml was available." } extract_zip() { local zip_path="$1" local output_root="$2" unzip -q "$zip_path" -d "$output_root" local entries entries="$(find "$output_root" -mindepth 1 -maxdepth 1 ! -name '__MACOSX' -print)" local first_entry first_entry="$(printf '%s\n' "$entries" | head -n 1)" local entry_count entry_count="$(printf '%s\n' "$entries" | sed '/^$/d' | wc -l | tr -d ' ')" if [ "$entry_count" = "1" ] && [ -d "$first_entry" ]; then printf '%s\n' "$first_entry" return fi printf '%s\n' "$output_root" } require_command curl require_command unzip require_command rsync require_command docker detect_compose detect_config_dir mkdir -p "$CONFIG_DIR" MANIFEST_JSON="$(curl -fsSL "$MANIFEST_URL")" PUBLIC_INSTALLER_ENABLED="$(json_bool_field "$MANIFEST_JSON" "public_installer_enabled")" PUBLIC_MESSAGE="$(json_field "$MANIFEST_JSON" "public_message")" LATEST_VERSION="$(json_field "$MANIFEST_JSON" "version")" ZIP_URL="$(json_field "$MANIFEST_JSON" "zip_url")" CHECKSUM="$(json_field "$MANIFEST_JSON" "checksum")" [ "$PUBLIC_INSTALLER_ENABLED" != "false" ] || fail "${PUBLIC_MESSAGE:-SyncBank installer is not publicly available yet. Join waitlist at https://syncbank.app/waitlist}" [ -n "$LATEST_VERSION" ] || fail "Could not read latest version from manifest." [ -n "$ZIP_URL" ] || fail "Could not read zip_url from manifest." CURRENT_VERSION="" if [ -d "$INSTALL_DIR" ]; then CURRENT_VERSION="$(read_local_version "$INSTALL_DIR")" fi if [ "$CURRENT_VERSION" = "$LATEST_VERSION" ] && [ -n "$CURRENT_VERSION" ]; then log "Installed: $CURRENT_VERSION" log "Latest: $LATEST_VERSION" log "Already up to date." compose_run "$INSTALL_DIR" up -d open_browser "$START_URL" exit 0 fi PACKAGE_PATH="$TMP_DIR/syncbank.zip" EXTRACT_ROOT="$TMP_DIR/extracted" mkdir -p "$EXTRACT_ROOT" log "Downloading SyncBank $LATEST_VERSION..." curl -fsSL "$ZIP_URL" -o "$PACKAGE_PATH" verify_checksum "$PACKAGE_PATH" "$CHECKSUM" if [ -d "$INSTALL_DIR" ]; then log "Stopping existing SyncBank containers..." compose_run "$INSTALL_DIR" down --remove-orphans || true fi rm -rf "$BACKUP_DIR" if [ -d "$INSTALL_DIR" ]; then mv "$INSTALL_DIR" "$BACKUP_DIR" fi SRC_DIR="$(extract_zip "$PACKAGE_PATH" "$EXTRACT_ROOT")" mkdir -p "$INSTALL_DIR" rsync -a "$SRC_DIR"/ "$INSTALL_DIR"/ restore_preserved_runtime "$BACKUP_DIR" "$INSTALL_DIR" write_install_env "$INSTALL_DIR" regenerate_compose "$INSTALL_DIR" "$BACKUP_DIR" log "Starting SyncBank..." compose_run "$INSTALL_DIR" up -d log "Installed: ${CURRENT_VERSION:-none}" log "Latest: $LATEST_VERSION" log "Path: $INSTALL_DIR" log "Config: $CONFIG_DIR" log "Opening: $START_URL" open_browser "$START_URL"