#!/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:-}" RELEASE_CHECKSUM_FILE=".release-checksum" cleanup() { rm -rf "$TMP_DIR" } trap cleanup EXIT log() { printf '%s\n' "$*" } repeat_char() { local count="$1" local char="$2" local output="" while [ "$count" -gt 0 ]; do output="${output}${char}" count=$((count - 1)) done printf '%s' "$output" } progress_step() { local percent="$1" shift local message="$*" if [ "$percent" -lt 0 ]; then percent=0; fi if [ "$percent" -gt 100 ]; then percent=100; fi local filled=$((percent / 5)) if [ "$filled" -gt 20 ]; then filled=20; fi local empty=$((20 - filled)) log "[$(repeat_char "$filled" "#")$(repeat_char "$empty" "-")] ${percent}% ${message}" } fail() { log "Error: $*" exit 1 } move_dir_with_retry() { local src="$1" local dst="$2" local attempts="${3:-5}" local delay_seconds="${4:-2}" local try=1 while [ "$try" -le "$attempts" ]; do if mv "$src" "$dst" 2>/dev/null; then return 0 fi if [ "$try" -lt "$attempts" ]; then log "Install directory busy, retrying move (${try}/${attempts})..." sleep "$delay_seconds" fi try=$((try + 1)) done return 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 } read_local_release_checksum() { local checksum_file="$1/$RELEASE_CHECKSUM_FILE" [ -f "$checksum_file" ] || return 0 head -n 1 "$checksum_file" | tr -d '\r\n' } 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" case "$url" in http://localhost:3030*|http://127.0.0.1:3030*) ;; *) log "Open browser skipped. Only localhost:3030 is allowed." return ;; esac if command -v open >/dev/null 2>&1; then open "$url" >/dev/null 2>&1 && return fi if command -v xdg-open >/dev/null 2>&1; then xdg-open "$url" >/dev/null 2>&1 && return fi log "Open browser skipped. Open manually: $url" } wait_for_url_ready() { local url="$1" local timeout_seconds="${2:-60}" local start_percent="${3:-95}" local end_percent="${4:-99}" local label="${5:-Waiting for wizard on localhost:3030...}" local elapsed=0 local last_percent=-1 if ! command -v curl >/dev/null 2>&1; then sleep 5 return 0 fi while [ "$elapsed" -lt "$timeout_seconds" ]; do local current_percent="$start_percent" if [ "$timeout_seconds" -gt 0 ] && [ "$end_percent" -gt "$start_percent" ]; then current_percent=$((start_percent + ((end_percent - start_percent) * elapsed / timeout_seconds))) fi if [ "$current_percent" -ne "$last_percent" ]; then progress_step "$current_percent" "$label" last_percent="$current_percent" fi if curl -fsS -m 2 "$url" >/dev/null 2>&1; then return 0 fi sleep 1 elapsed=$((elapsed + 1)) done sleep 5 return 0 } fresh_install_start_url() { local url="$1" if [[ "$url" == *\?* ]]; then printf '%s&fresh_install=1\n' "$url" else printf '%s?fresh_install=1\n' "$url" 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 } perform_fresh_reset() { if [ -d "$INSTALL_DIR" ]; then (cd "$INSTALL_DIR" && "${COMPOSE_CMD[@]}" down --remove-orphans >/dev/null 2>&1) || true fi if command -v docker >/dev/null 2>&1; then docker rm -f SyncBank-wizard syncbank-wizard syncbank-actual-budget >/dev/null 2>&1 || true local bank_containers bank_containers="$(docker ps -a --format '{{.Names}}' | grep -E '^(syncbank-bank-|bank-)' || true)" if [ -n "$bank_containers" ]; then printf '%s\n' "$bank_containers" | xargs -r docker rm -f >/dev/null 2>&1 || true fi local known_volumes=( "syncbank-actual-data" "sync-bank_syncbank-actual-data" "actual-data" "actual-budget-data" ) local vol for vol in "${known_volumes[@]}"; do docker volume rm "$vol" >/dev/null 2>&1 || true done local bank_volumes bank_volumes="$(docker volume ls --format '{{.Name}}' | grep -E '^(syncbank-bank-|sync-bank_syncbank-bank-).*' || true)" if [ -n "$bank_volumes" ]; then printf '%s\n' "$bank_volumes" | xargs -r docker volume rm >/dev/null 2>&1 || true fi fi if ! rm -rf "$CONFIG_DIR" 2>/dev/null; then if [ "$(uname -s)" = "Linux" ] && command -v sudo >/dev/null 2>&1; then log "Config dir requires elevated cleanup, retrying with sudo..." sudo rm -rf "$CONFIG_DIR" || fail "Could not remove $CONFIG_DIR. Run: sudo rm -rf \"$CONFIG_DIR\"" else fail "Could not remove $CONFIG_DIR (permission denied)." fi fi mkdir -p "$CONFIG_DIR" 2>/dev/null || { if [ "$(uname -s)" = "Linux" ] && command -v sudo >/dev/null 2>&1; then sudo mkdir -p "$CONFIG_DIR" || fail "Could not create $CONFIG_DIR." sudo chown -R "$(id -u):$(id -g)" "$CONFIG_DIR" || true else fail "Could not create $CONFIG_DIR." fi } # Mirror reset.sh behavior for legacy local folders when present. if ! rm -rf "$INSTALL_DIR/data" "$INSTALL_DIR/Data" "$INSTALL_DIR/actual-data" "$INSTALL_DIR"/data-bank-* 2>/dev/null; then if [ "$(uname -s)" = "Linux" ] && command -v sudo >/dev/null 2>&1; then sudo rm -rf "$INSTALL_DIR/data" "$INSTALL_DIR/Data" "$INSTALL_DIR/actual-data" "$INSTALL_DIR"/data-bank-* 2>/dev/null || true fi fi rm -f "$INSTALL_DIR/syncbank-renewals.ics" "$CONFIG_DIR/syncbank-renewals.ics" 2>/dev/null || true } 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 local quoted_app quoted_config="$(quote_env_value "$CONFIG_DIR")" quoted_app="$(quote_env_value "$install_root/app")" if [ -f "$env_file" ]; then grep -v -e '^SYNCBANK_CONFIG_DIR_HOST=' -e '^SYNCBANK_APP_HOST_PATH=' "$env_file" > "$tmp_env" || true else : > "$tmp_env" fi printf 'SYNCBANK_CONFIG_DIR_HOST=%s\n' "$quoted_config" >> "$tmp_env" printf 'SYNCBANK_APP_HOST_PATH=%s\n' "$quoted_app" >> "$tmp_env" mv "$tmp_env" "$env_file" } regenerate_compose() { local install_root="$1" local _backup_root="$2" local config_file="$CONFIG_DIR/config.json" if [ ! -f "$config_file" ]; then log "No persisted config found. Keeping bundled docker-compose.yml." return 0 fi if compose_run "$install_root" run --build --rm syncbank-wizard sh -lc "python -u app/setup_wizard.py --write-compose-only --no-browser"; then return 0 fi fail "Compose regeneration failed." } 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" } progress_step 5 "Checking system requirements..." require_command curl require_command unzip require_command rsync require_command docker detect_compose detect_config_dir mkdir -p "$CONFIG_DIR" progress_step 10 "Reading latest release manifest..." 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 CURRENT_CHECKSUM="" if [ -d "$INSTALL_DIR" ]; then CURRENT_CHECKSUM="$(read_local_release_checksum "$INSTALL_DIR")" fi if [ "$CURRENT_VERSION" = "$LATEST_VERSION" ] && [ -n "$CURRENT_VERSION" ]; then log "Installed version matches latest ($CURRENT_VERSION). Running fresh reinstall as requested." fi perform_fresh_reset PACKAGE_PATH="$TMP_DIR/syncbank.zip" EXTRACT_ROOT="$TMP_DIR/extracted" mkdir -p "$EXTRACT_ROOT" progress_step 20 "Downloading release package..." log "Downloading SyncBank $LATEST_VERSION..." curl -fsSL "$ZIP_URL" -o "$PACKAGE_PATH" progress_step 35 "Verifying package checksum..." verify_checksum "$PACKAGE_PATH" "$CHECKSUM" if [ -d "$INSTALL_DIR" ]; then progress_step 45 "Stopping existing containers..." log "Stopping existing SyncBank containers..." compose_run "$INSTALL_DIR" down --remove-orphans || true fi rm -rf "$BACKUP_DIR" if [ -d "$INSTALL_DIR" ]; then move_dir_with_retry "$INSTALL_DIR" "$BACKUP_DIR" 5 2 || fail "Could not move $INSTALL_DIR to backup. Close apps using this folder and retry." fi progress_step 55 "Extracting files..." SRC_DIR="$(extract_zip "$PACKAGE_PATH" "$EXTRACT_ROOT")" mkdir -p "$INSTALL_DIR" rsync -a "$SRC_DIR"/ "$INSTALL_DIR"/ progress_step 70 "Preparing clean install..." write_install_env "$INSTALL_DIR" progress_step 80 "Regenerating docker-compose..." regenerate_compose "$INSTALL_DIR" "$BACKUP_DIR" progress_step 90 "Starting containers..." log "Starting SyncBank..." compose_run "$INSTALL_DIR" up -d OPEN_URL="$(fresh_install_start_url "$START_URL")" log "Installed: ${CURRENT_VERSION:-none}" log "Latest: $LATEST_VERSION" log "Path: $INSTALL_DIR" log "Config: $CONFIG_DIR" log "Opening: $OPEN_URL" printf '%s\n' "${CHECKSUM#sha256:}" > "$INSTALL_DIR/$RELEASE_CHECKSUM_FILE" wait_for_url_ready "$START_URL" 60 95 99 "Waiting for wizard on localhost:3030..." progress_step 100 "Done. SyncBank is ready." open_browser "$OPEN_URL"