Add pre-built image installer and release tarball system

New install method: curl one-liner downloads a lightweight release
tarball (~9 MB) and runs the config wizard. No git clone needed,
no TypeScript compilation — pulls pre-built images from Gitea registry.

- docker-compose.prod.yml: production compose without build blocks or
  source code volume mounts; IMAGE_TAG defaults to latest
- scripts/install.sh: curl-friendly installer (downloads tarball,
  extracts, runs config.sh)
- scripts/build-release.sh: creates release tarball from dev repo
  with only runtime files (configs, scripts, docs, empty data dirs)
- config.sh: release-mode detection (VERSION file + no .git dir),
  auto-sets IMAGE_TAG=latest and NODE_ENV=production
- upgrade.sh: release-mode upgrade path (downloads new tarball from
  Gitea Releases API instead of git pull, always uses registry mode)
- upgrade-check.sh: release-mode version check via Gitea API
- .gitignore: exclude releases/ and api/dist/
- Docs: updated getting-started with pre-built install instructions

Bunker Admin
This commit is contained in:
2026-03-22 20:34:49 -06:00
parent f550423c3f
commit 8e6f0996de
10 changed files with 1996 additions and 32 deletions

235
scripts/build-release.sh Executable file
View File

@@ -0,0 +1,235 @@
#!/usr/bin/env bash
# =============================================================================
# Changemaker Lite V2 — Build Release Tarball
#
# Creates a lightweight release tarball (~1-2 MB) containing only runtime files
# needed to deploy with pre-built Docker images. No source code included.
#
# Usage:
# ./scripts/build-release.sh [OPTIONS]
#
# Options:
# --tag TAG Version tag (default: git describe or commit SHA)
# --output DIR Output directory (default: ./releases/)
# --upload Upload to Gitea Releases API after building
# --dry-run Show what would be included without creating tarball
# --help Show this help
#
# Prerequisites:
# Run ./scripts/build-and-push.sh first to push Docker images to registry.
# =============================================================================
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PROJECT_DIR="$(dirname "$SCRIPT_DIR")"
# --- Defaults ---
TAG=""
OUTPUT_DIR="${PROJECT_DIR}/releases"
UPLOAD=false
DRY_RUN=false
# --- Colors ---
if [[ -t 1 ]] && [[ -z "${NO_COLOR:-}" ]]; then
RED='\033[0;31m' GREEN='\033[0;32m' YELLOW='\033[0;33m'
BLUE='\033[0;34m' CYAN='\033[0;36m' BOLD='\033[1m' NC='\033[0m'
else
RED='' GREEN='' YELLOW='' BLUE='' CYAN='' BOLD='' NC=''
fi
info() { echo -e "${BLUE}[INFO]${NC} $*"; }
success() { echo -e "${GREEN}[OK]${NC} $*"; }
warn() { echo -e "${YELLOW}[WARN]${NC} $*"; }
error() { echo -e "${RED}[ERROR]${NC} $*" >&2; }
# --- Arg parser ---
while [[ $# -gt 0 ]]; do
case "$1" in
--tag) TAG="$2"; shift 2 ;;
--output) OUTPUT_DIR="$2"; shift 2 ;;
--upload) UPLOAD=true; shift ;;
--dry-run) DRY_RUN=true; shift ;;
--help|-h)
sed -n '2,20p' "$0" | grep '^#' | sed 's/^# \?//'
exit 0 ;;
*) error "Unknown option: $1"; exit 1 ;;
esac
done
# --- Determine version ---
cd "$PROJECT_DIR"
if [[ -z "$TAG" ]]; then
TAG="$(git describe --tags --always 2>/dev/null || git rev-parse --short HEAD)"
fi
COMMIT_SHA="$(git rev-parse --short HEAD 2>/dev/null || echo "unknown")"
BUILD_DATE="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
echo -e "${BOLD}Changemaker Lite — Build Release${NC}"
echo " Tag: $TAG"
echo " Commit: $COMMIT_SHA"
echo " Date: $BUILD_DATE"
echo ""
# --- Create staging directory ---
STAGE_DIR="$(mktemp -d)/changemaker-lite"
mkdir -p "$STAGE_DIR"
# --- Write VERSION file ---
cat > "$STAGE_DIR/VERSION" << EOF
$TAG
$COMMIT_SHA
$BUILD_DATE
EOF
info "VERSION: $TAG ($COMMIT_SHA)"
# --- Copy production docker-compose ---
if [[ ! -f "$PROJECT_DIR/docker-compose.prod.yml" ]]; then
error "docker-compose.prod.yml not found. Generate it first."
exit 1
fi
cp "$PROJECT_DIR/docker-compose.prod.yml" "$STAGE_DIR/docker-compose.yml"
info "docker-compose.yml (production)"
# --- Copy config files ---
cp "$PROJECT_DIR/.env.example" "$STAGE_DIR/"
cp "$PROJECT_DIR/config.sh" "$STAGE_DIR/"
info "Config files (.env.example, config.sh)"
# --- Copy scripts ---
mkdir -p "$STAGE_DIR/scripts"
# Init scripts (from api/prisma/ to scripts/)
cp "$PROJECT_DIR/api/prisma/init-nocodb-db.sh" "$STAGE_DIR/scripts/"
cp "$PROJECT_DIR/api/prisma/init-gancio-db.sh" "$STAGE_DIR/scripts/"
# Runtime scripts
for script in nocodb-init.sh mkdocs-entrypoint.sh backup.sh \
upgrade.sh upgrade-check.sh upgrade-watcher.sh; do
if [[ -f "$PROJECT_DIR/scripts/$script" ]]; then
cp "$PROJECT_DIR/scripts/$script" "$STAGE_DIR/scripts/"
fi
done
# MkDocs build trigger
if [[ -f "$PROJECT_DIR/scripts/mkdocs-build-trigger.py" ]]; then
cp "$PROJECT_DIR/scripts/mkdocs-build-trigger.py" "$STAGE_DIR/scripts/"
fi
# Systemd units
if [[ -d "$PROJECT_DIR/scripts/systemd" ]]; then
cp -r "$PROJECT_DIR/scripts/systemd" "$STAGE_DIR/scripts/"
fi
# Install script (for reference)
cp "$PROJECT_DIR/scripts/install.sh" "$STAGE_DIR/scripts/"
chmod +x "$STAGE_DIR/scripts/"*.sh 2>/dev/null || true
info "Scripts ($(ls "$STAGE_DIR/scripts/" | wc -l) files)"
# --- Copy configs ---
if [[ -d "$PROJECT_DIR/configs" ]]; then
cp -r "$PROJECT_DIR/configs" "$STAGE_DIR/"
# Ensure code-server skeleton dirs exist
mkdir -p "$STAGE_DIR/configs/code-server/.config"
mkdir -p "$STAGE_DIR/configs/code-server/.local"
info "Configs (homepage, prometheus, grafana, alertmanager, pangolin)"
fi
# --- Copy nginx templates (for reference) ---
mkdir -p "$STAGE_DIR/nginx/conf.d"
cp "$PROJECT_DIR"/nginx/conf.d/*.template "$STAGE_DIR/nginx/conf.d/" 2>/dev/null || true
info "Nginx templates (for reference)"
# --- Copy MkDocs starter docs ---
if [[ -d "$PROJECT_DIR/mkdocs" ]]; then
mkdir -p "$STAGE_DIR/mkdocs"
cp "$PROJECT_DIR/mkdocs/mkdocs.yml" "$STAGE_DIR/mkdocs/" 2>/dev/null || true
cp -r "$PROJECT_DIR/mkdocs/docs" "$STAGE_DIR/mkdocs/" 2>/dev/null || true
cp -r "$PROJECT_DIR/mkdocs/overrides" "$STAGE_DIR/mkdocs/" 2>/dev/null || true
mkdir -p "$STAGE_DIR/mkdocs/.cache"
mkdir -p "$STAGE_DIR/mkdocs/site"
info "MkDocs (starter documentation)"
fi
# --- Copy public-web ---
if [[ -d "$PROJECT_DIR/public-web" ]]; then
cp -r "$PROJECT_DIR/public-web" "$STAGE_DIR/"
else
mkdir -p "$STAGE_DIR/public-web"
fi
info "Public web assets"
# --- Create empty data directories ---
mkdir -p "$STAGE_DIR/assets/uploads"
mkdir -p "$STAGE_DIR/assets/icons"
mkdir -p "$STAGE_DIR/assets/images"
mkdir -p "$STAGE_DIR/data/upgrade"
mkdir -p "$STAGE_DIR/media/local/inbox"
mkdir -p "$STAGE_DIR/media/local/thumbnails"
mkdir -p "$STAGE_DIR/media/local/photos"
mkdir -p "$STAGE_DIR/media/public"
mkdir -p "$STAGE_DIR/local-files"
info "Data directories (empty)"
# --- Summary ---
echo ""
STAGE_SIZE=$(du -sh "$STAGE_DIR" | cut -f1)
FILE_COUNT=$(find "$STAGE_DIR" -type f | wc -l)
info "Staging: ${FILE_COUNT} files, ${STAGE_SIZE}"
if [[ "$DRY_RUN" == "true" ]]; then
echo ""
info "[DRY RUN] Would create: changemaker-lite-${TAG}.tar.gz"
find "$STAGE_DIR" -type f | sed "s|$STAGE_DIR/||" | sort
rm -rf "$(dirname "$STAGE_DIR")"
exit 0
fi
# --- Create tarball ---
mkdir -p "$OUTPUT_DIR"
TARBALL="${OUTPUT_DIR}/changemaker-lite-${TAG}.tar.gz"
tar czf "$TARBALL" -C "$(dirname "$STAGE_DIR")" "changemaker-lite"
rm -rf "$(dirname "$STAGE_DIR")"
TARBALL_SIZE=$(du -h "$TARBALL" | cut -f1)
success "Created: $TARBALL (${TARBALL_SIZE})"
# --- Upload to Gitea (optional) ---
if [[ "$UPLOAD" == "true" ]]; then
source "$PROJECT_DIR/.env" 2>/dev/null || true
GITEA_TOKEN="${GITEA_API_TOKEN:-}"
GITEA_HOST="${GITEA_URL:-https://gitea.bnkops.com}"
if [[ -z "$GITEA_TOKEN" ]]; then
warn "GITEA_API_TOKEN not set — skipping upload"
warn "Set GITEA_API_TOKEN in .env and re-run with --upload"
else
info "Creating Gitea release ${TAG}..."
RELEASE_RESPONSE=$(curl -sf -X POST \
"${GITEA_HOST}/api/v1/repos/admin/changemaker.lite/releases" \
-H "Authorization: token ${GITEA_TOKEN}" \
-H "Content-Type: application/json" \
-d "{\"tag_name\":\"${TAG}\",\"name\":\"Changemaker Lite ${TAG}\",\"body\":\"Release ${TAG} (${COMMIT_SHA})\"}" \
2>/dev/null || true)
RELEASE_ID=$(echo "$RELEASE_RESPONSE" | python3 -c "import sys,json; print(json.load(sys.stdin).get('id',''))" 2>/dev/null || true)
if [[ -n "$RELEASE_ID" ]]; then
info "Uploading tarball to release ${RELEASE_ID}..."
curl -sf -X POST \
"${GITEA_HOST}/api/v1/repos/admin/changemaker.lite/releases/${RELEASE_ID}/assets" \
-H "Authorization: token ${GITEA_TOKEN}" \
-F "attachment=@${TARBALL}" \
>/dev/null 2>&1
success "Uploaded to Gitea release ${TAG}"
else
warn "Failed to create release — upload manually at ${GITEA_HOST}/admin/changemaker.lite/releases"
fi
fi
fi
echo ""
success "Release ${TAG} ready."
echo " Tarball: $TARBALL"
echo " Install: tar xzf $(basename "$TARBALL") && cd changemaker-lite && bash config.sh"

177
scripts/install.sh Executable file
View File

@@ -0,0 +1,177 @@
#!/usr/bin/env bash
# =============================================================================
# Changemaker Lite — One-Line Installer
#
# Downloads the latest release tarball and runs the configuration wizard.
#
# Usage:
# curl -fsSL https://gitea.bnkops.com/admin/changemaker.lite/raw/branch/v2/scripts/install.sh | bash
# bash install.sh [OPTIONS]
#
# Options:
# --dir DIR Install directory (default: ~/changemaker.lite)
# --version TAG Specific version tag (default: latest release)
# --tarball FILE Use a local tarball instead of downloading
# --help Show this help
# =============================================================================
set -euo pipefail
GITEA_URL="https://gitea.bnkops.com"
REPO="admin/changemaker.lite"
INSTALL_DIR="${HOME}/changemaker.lite"
VERSION=""
LOCAL_TARBALL=""
# --- Colors ---
if [[ -t 1 ]] && [[ -z "${NO_COLOR:-}" ]]; then
RED='\033[0;31m' GREEN='\033[0;32m' YELLOW='\033[0;33m'
BLUE='\033[0;34m' BOLD='\033[1m' NC='\033[0m'
else
RED='' GREEN='' YELLOW='' BLUE='' BOLD='' NC=''
fi
info() { echo -e "${BLUE}[INFO]${NC} $*"; }
success() { echo -e "${GREEN}[OK]${NC} $*"; }
warn() { echo -e "${YELLOW}[WARN]${NC} $*"; }
error() { echo -e "${RED}[ERROR]${NC} $*" >&2; }
# --- Arg parser ---
while [[ $# -gt 0 ]]; do
case "$1" in
--dir) INSTALL_DIR="$2"; shift 2 ;;
--version) VERSION="$2"; shift 2 ;;
--tarball) LOCAL_TARBALL="$2"; shift 2 ;;
--help|-h)
sed -n '2,20p' "$0" | grep '^#' | sed 's/^# \?//'
exit 0 ;;
*) shift ;;
esac
done
echo -e "${BOLD}Changemaker Lite — Installer${NC}"
echo ""
# --- Step 1: Check prerequisites ---
info "Checking prerequisites..."
MISSING=()
command -v docker >/dev/null 2>&1 || MISSING+=("docker")
docker compose version >/dev/null 2>&1 || MISSING+=("docker-compose-v2")
command -v openssl >/dev/null 2>&1 || MISSING+=("openssl")
command -v curl >/dev/null 2>&1 || MISSING+=("curl")
if [[ ${#MISSING[@]} -gt 0 ]]; then
error "Missing required tools: ${MISSING[*]}"
echo ""
echo "Install Docker: https://docs.docker.com/engine/install/"
echo "Install OpenSSL: apt install openssl (or equivalent)"
exit 1
fi
success "Prerequisites OK (Docker $(docker --version | grep -oP '\d+\.\d+\.\d+'), OpenSSL available)"
# --- Step 2: Check install directory ---
if [[ -d "$INSTALL_DIR" ]]; then
if [[ -f "$INSTALL_DIR/docker-compose.yml" ]]; then
error "Changemaker Lite is already installed at $INSTALL_DIR"
echo " To upgrade: cd $INSTALL_DIR && ./scripts/upgrade.sh"
echo " To reinstall: rm -rf $INSTALL_DIR && re-run this script"
exit 1
fi
fi
# --- Step 3: Get tarball ---
TARBALL_PATH=""
if [[ -n "$LOCAL_TARBALL" ]]; then
if [[ ! -f "$LOCAL_TARBALL" ]]; then
error "Tarball not found: $LOCAL_TARBALL"
exit 1
fi
TARBALL_PATH="$LOCAL_TARBALL"
info "Using local tarball: $LOCAL_TARBALL"
else
# Determine download URL
if [[ -n "$VERSION" ]]; then
RELEASE_URL="${GITEA_URL}/api/v1/repos/${REPO}/releases/tags/${VERSION}"
else
RELEASE_URL="${GITEA_URL}/api/v1/repos/${REPO}/releases/latest"
fi
info "Fetching release info from Gitea..."
RELEASE_JSON=$(curl -sf "$RELEASE_URL" 2>/dev/null || true)
if [[ -z "$RELEASE_JSON" ]]; then
error "Could not fetch release info from ${GITEA_URL}"
echo ""
echo "If the registry requires authentication:"
echo " 1. Download the tarball manually from ${GITEA_URL}/${REPO}/releases"
echo " 2. Run: bash install.sh --tarball /path/to/changemaker-lite-*.tar.gz"
exit 1
fi
TARBALL_URL=$(echo "$RELEASE_JSON" | python3 -c "
import sys, json
data = json.load(sys.stdin)
assets = data.get('assets', [])
for a in assets:
if a['name'].endswith('.tar.gz'):
print(a['browser_download_url'])
break
" 2>/dev/null || true)
if [[ -z "$TARBALL_URL" ]]; then
error "No tarball found in the release. Check ${GITEA_URL}/${REPO}/releases"
exit 1
fi
RELEASE_TAG=$(echo "$RELEASE_JSON" | python3 -c "import sys,json; print(json.load(sys.stdin).get('tag_name','unknown'))" 2>/dev/null)
info "Downloading Changemaker Lite ${RELEASE_TAG}..."
TARBALL_PATH="/tmp/changemaker-lite-install.tar.gz"
curl -fSL "$TARBALL_URL" -o "$TARBALL_PATH"
success "Downloaded $(du -h "$TARBALL_PATH" | cut -f1)"
fi
# --- Step 4: Extract ---
info "Extracting to ${INSTALL_DIR}..."
mkdir -p "$(dirname "$INSTALL_DIR")"
# Extract to temp, then move (handles tarball root directory naming)
EXTRACT_DIR=$(mktemp -d)
tar xzf "$TARBALL_PATH" -C "$EXTRACT_DIR"
# Find the extracted directory (tarball might have any root name)
EXTRACTED=$(find "$EXTRACT_DIR" -maxdepth 1 -mindepth 1 -type d | head -1)
if [[ -z "$EXTRACTED" ]]; then
error "Tarball extraction failed — no directory found"
rm -rf "$EXTRACT_DIR"
exit 1
fi
mv "$EXTRACTED" "$INSTALL_DIR"
rm -rf "$EXTRACT_DIR"
# Clean up downloaded tarball
if [[ -z "$LOCAL_TARBALL" ]] && [[ -f "$TARBALL_PATH" ]]; then
rm -f "$TARBALL_PATH"
fi
success "Extracted to ${INSTALL_DIR}"
# --- Step 5: Run config wizard ---
echo ""
echo -e "${BOLD}Starting configuration wizard...${NC}"
echo ""
cd "$INSTALL_DIR"
bash config.sh
# --- Done ---
echo ""
echo -e "${BOLD}${GREEN}Installation complete!${NC}"
echo ""
echo " Start all services:"
echo " cd ${INSTALL_DIR} && docker compose up -d"
echo ""
echo " Check status:"
echo " docker compose ps"
echo ""
echo " View API logs:"
echo " docker compose logs -f api --tail 20"
echo ""

View File

@@ -24,6 +24,70 @@ done
cd "$PROJECT_DIR"
mkdir -p "$UPGRADE_DIR"
# --- Detect install mode ---
if [[ -f "$PROJECT_DIR/VERSION" ]] && [[ ! -d "$PROJECT_DIR/.git" ]]; then
INSTALL_MODE="release"
else
INSTALL_MODE="source"
fi
# --- Release mode: check Gitea Releases API ---
if [[ "$INSTALL_MODE" == "release" ]]; then
GITEA_API="https://gitea.bnkops.com/api/v1"
CURRENT_VERSION=$(head -1 "$PROJECT_DIR/VERSION" 2>/dev/null || echo "unknown")
CURRENT_SHA=$(sed -n '2p' "$PROJECT_DIR/VERSION" 2>/dev/null || echo "unknown")
CURRENT_DATE=$(sed -n '3p' "$PROJECT_DIR/VERSION" 2>/dev/null || echo "")
RELEASE_JSON=$(curl -sf "${GITEA_API}/repos/admin/changemaker.lite/releases/latest" 2>/dev/null || true)
if [[ -z "$RELEASE_JSON" ]]; then
cat > "$STATUS_FILE" <<EOF
{
"branch": "release",
"currentCommit": "${CURRENT_SHA}",
"currentCommitFull": "${CURRENT_SHA}",
"currentMessage": "Release ${CURRENT_VERSION}",
"currentDate": "${CURRENT_DATE}",
"remoteCommit": null,
"commitsBehind": 0,
"changelog": [],
"checkedAt": "$(date -u +%Y-%m-%dT%H:%M:%SZ)",
"error": "Failed to reach Gitea API"
}
EOF
exit 1
fi
LATEST_TAG=$(echo "$RELEASE_JSON" | python3 -c "import sys,json; print(json.load(sys.stdin).get('tag_name',''))" 2>/dev/null)
LATEST_DATE=$(echo "$RELEASE_JSON" | python3 -c "import sys,json; print(json.load(sys.stdin).get('created_at',''))" 2>/dev/null)
LATEST_BODY=$(echo "$RELEASE_JSON" | python3 -c "import sys,json; print(json.load(sys.stdin).get('body','').replace('\"','\\\\\"')[:200])" 2>/dev/null)
if [[ "$CURRENT_VERSION" == "$LATEST_TAG" ]]; then
COMMITS_BEHIND=0
else
COMMITS_BEHIND=1
fi
cat > "$STATUS_FILE" <<EOF
{
"branch": "release",
"currentCommit": "${CURRENT_SHA}",
"currentCommitFull": "${CURRENT_SHA}",
"currentMessage": "Release ${CURRENT_VERSION}",
"currentDate": "${CURRENT_DATE}",
"remoteCommit": "${LATEST_TAG}",
"remoteCommitFull": "${LATEST_TAG}",
"commitsBehind": ${COMMITS_BEHIND},
"changelog": [{"hash":"${LATEST_TAG}","message":"${LATEST_BODY}","date":"${LATEST_DATE}","author":"release"}],
"checkedAt": "$(date -u +%Y-%m-%dT%H:%M:%SZ)",
"error": null
}
EOF
echo "Update check complete (release mode): ${CURRENT_VERSION}${LATEST_TAG} (${COMMITS_BEHIND} update(s) available)"
exit 0
fi
# --- Source mode: git-based check ---
# Determine branch
if [[ -z "$BRANCH" ]]; then
BRANCH="$(git rev-parse --abbrev-ref HEAD)"

View File

@@ -39,6 +39,13 @@ USER_PATHS=(
"nginx/conf.d/services.conf"
)
# --- Detect install mode ---
if [[ -f "$PROJECT_DIR/VERSION" ]] && [[ ! -d "$PROJECT_DIR/.git" ]]; then
INSTALL_MODE="release"
else
INSTALL_MODE="source"
fi
# --- Defaults ---
SKIP_BACKUP=false
PULL_SERVICES=false
@@ -49,6 +56,11 @@ ROLLBACK=false
API_MODE=false
USE_REGISTRY=false
# Release installs always use registry mode
if [[ "$INSTALL_MODE" == "release" ]]; then
USE_REGISTRY=true
fi
# --- Colors (respects NO_COLOR convention) ---
if [[ -t 1 ]] && [[ -z "${NO_COLOR:-}" ]]; then
RED='\033[0;31m' GREEN='\033[0;32m' YELLOW='\033[0;33m'
@@ -582,7 +594,67 @@ fi
phase "3" "Code Update"
write_progress 3 "Code Update" 30 "Pulling latest code..."
if [[ "$DRY_RUN" == "true" ]]; then
# --- Release mode: download tarball instead of git pull ---
if [[ "$INSTALL_MODE" == "release" ]]; then
GITEA_API="${GITEA_REGISTRY_URL:-https://gitea.bnkops.com}/api/v1"
CURRENT_VERSION=$(head -1 "$PROJECT_DIR/VERSION" 2>/dev/null || echo "unknown")
info "Release mode — checking for updates (current: ${CURRENT_VERSION})..."
RELEASE_JSON=$(curl -sf "${GITEA_API}/repos/admin/changemaker.lite/releases/latest" 2>/dev/null || true)
if [[ -z "$RELEASE_JSON" ]]; then
error "Could not reach Gitea API. Check network or GITEA_REGISTRY_URL."
exit 1
fi
LATEST_TAG=$(echo "$RELEASE_JSON" | python3 -c "import sys,json; print(json.load(sys.stdin).get('tag_name',''))" 2>/dev/null)
TARBALL_URL=$(echo "$RELEASE_JSON" | python3 -c "
import sys, json
for a in json.load(sys.stdin).get('assets', []):
if a['name'].endswith('.tar.gz'):
print(a['browser_download_url']); break
" 2>/dev/null || true)
if [[ "$CURRENT_VERSION" == "$LATEST_TAG" ]] && [[ "$FORCE" != "true" ]]; then
info "Already at latest version: ${CURRENT_VERSION}"
write_progress 3 "Code Update" 45 "Already up to date"
elif [[ -z "$TARBALL_URL" ]]; then
error "No tarball found in release ${LATEST_TAG}"
exit 1
else
info "Updating ${CURRENT_VERSION}${LATEST_TAG}..."
write_progress 3 "Code Update" 35 "Downloading ${LATEST_TAG}..."
# Download
DOWNLOAD_DIR=$(mktemp -d)
curl -fSL "$TARBALL_URL" -o "${DOWNLOAD_DIR}/update.tar.gz"
tar xzf "${DOWNLOAD_DIR}/update.tar.gz" -C "$DOWNLOAD_DIR"
UPDATE_SRC=$(find "$DOWNLOAD_DIR" -maxdepth 1 -mindepth 1 -type d | head -1)
# Save user paths
save_user_paths
# Sync new files, preserving .env
write_progress 3 "Code Update" 40 "Applying update..."
rsync -a --exclude='.env' "$UPDATE_SRC/" "$PROJECT_DIR/"
# Restore user paths
restore_user_paths
# Restore tracked files that may have been overwritten
DELETED_TRACKED="$(git ls-files --deleted 2>/dev/null || true)"
if [[ -n "$DELETED_TRACKED" ]]; then
echo "$DELETED_TRACKED" | xargs git checkout HEAD -- 2>/dev/null || true
fi
rm -rf "$DOWNLOAD_DIR"
success "Updated to ${LATEST_TAG}"
fi
# Skip the git-based update flow below
POST_PULL_COMMIT="$(head -2 "$PROJECT_DIR/VERSION" | tail -1 2>/dev/null || echo "release")"
elif [[ "$DRY_RUN" == "true" ]]; then
info "[DRY RUN] Would fetch and show incoming changes:"
git fetch origin "$BRANCH" 2>/dev/null || true
INCOMING="$(git log --oneline HEAD..origin/"$BRANCH" 2>/dev/null || echo "(unable to preview)")"