#!/usr/bin/env bash # # upload-mobile-update.sh # ----------------------- # Uploads the LogShip Mobile Android update artifacts to the web server so the # in-app updater picks up the new release: # # android/update-files/logship-mobile.apk -> /logship-mobile.apk # android/update-files/version.json -> /version.json # # The Nuxt app proxies https://www.logyou.de/logship/version.json (via # /api/mobile/app-version) and downloads https://www.logyou.de/logship/logship-mobile.apk. # Both files must live in the SAME web directory served at that URL # (e.g. web root /var/www/logyou.de/web -> remote dir /var/www/logyou.de/web/logship). # # Auth (SFTP): # * key (default) — passwordless SSH key / agent auth. No password is asked. # Optionally point SFTP_IDENTITY at a specific private key. # * password — used automatically if SFTP_PASS is set, or force with # SFTP_AUTH=password. Uses lftp > sshpass > expect (whichever # is installed; sftp+expect are preinstalled on macOS). # # Settings come from (in order): environment vars, the gitignored config file # android/update-files/.deploy.env, then interactive prompts. Recognised vars: # SFTP_HOST SFTP_PORT SFTP_USER SFTP_REMOTE_DIR SFTP_AUTH SFTP_IDENTITY SFTP_PASS # # Usage: # scripts/upload-mobile-update.sh # set -euo pipefail # -------------------------------------------------------------------------- # Locate repo + artifacts # -------------------------------------------------------------------------- SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" UPDATE_DIR="$REPO_ROOT/android/update-files" APK_LOCAL="$UPDATE_DIR/logship-mobile.apk" VERSION_LOCAL="$UPDATE_DIR/version.json" # Public base URL used only for the optional post-upload verification. PUBLIC_BASE_URL="${PUBLIC_BASE_URL:-https://www.logyou.de/logship}" bold() { printf '\033[1m%s\033[0m\n' "$*"; } err() { printf '\033[31mERROR:\033[0m %s\n' "$*" >&2; } ok() { printf '\033[32m✔\033[0m %s\n' "$*"; } # -------------------------------------------------------------------------- # Load optional gitignored config file # -------------------------------------------------------------------------- CONFIG_FILE="${MOBILE_DEPLOY_ENV:-$UPDATE_DIR/.deploy.env}" if [ -f "$CONFIG_FILE" ]; then bold "Loading config from $CONFIG_FILE" set -a; # shellcheck disable=SC1090 . "$CONFIG_FILE"; set +a fi # -------------------------------------------------------------------------- # Resolve settings (env / config -> interactive prompt) # -------------------------------------------------------------------------- SFTP_HOST="${SFTP_HOST:-}" SFTP_PORT="${SFTP_PORT:-22}" SFTP_USER="${SFTP_USER:-}" SFTP_PASS="${SFTP_PASS:-}" SFTP_REMOTE_DIR="${SFTP_REMOTE_DIR:-}" SFTP_IDENTITY="${SFTP_IDENTITY:-}" SFTP_AUTH="${SFTP_AUTH:-key}" # key | password [ -n "$SFTP_HOST" ] || read -rp "Server IP / host: " SFTP_HOST [ -n "$SFTP_USER" ] || read -rp "Username: " SFTP_USER if [ -z "$SFTP_REMOTE_DIR" ]; then read -rp "Remote directory (folder served at ${PUBLIC_BASE_URL}/) e.g. /var/www/logyou.de/web/logship: " SFTP_REMOTE_DIR fi [ -n "${SFTP_PORT:-}" ] || SFTP_PORT=22 # A non-empty SFTP_PASS implies password mode. if [ -n "$SFTP_PASS" ]; then SFTP_AUTH="password"; fi if [ "$SFTP_AUTH" = "password" ] && [ -z "$SFTP_PASS" ]; then read -rsp "Password for ${SFTP_USER}@${SFTP_HOST}: " SFTP_PASS; echo fi # Validate required values for v in SFTP_HOST SFTP_USER SFTP_REMOTE_DIR; do if [ -z "${!v}" ]; then err "$v is required"; exit 1; fi done if [ "$SFTP_AUTH" = "password" ] && [ -z "$SFTP_PASS" ]; then err "password auth selected but no password provided"; exit 1 fi # -------------------------------------------------------------------------- # Validate local artifacts exist # -------------------------------------------------------------------------- for f in "$APK_LOCAL" "$VERSION_LOCAL"; do if [ ! -f "$f" ]; then err "Missing $f" err "Build & stage the APK first (cd android && ./gradlew assembleDebug, then copy to update-files/)." exit 1 fi done # -------------------------------------------------------------------------- # Sanity check: version.json must match the APK (the updater keys off version.json) # -------------------------------------------------------------------------- find_aapt() { local base cand for base in "${ANDROID_HOME:-}" "${ANDROID_SDK_ROOT:-}" "$HOME/Library/Android/sdk" "$HOME/Android/Sdk"; do [ -n "$base" ] || continue cand="$(ls -1 "$base"/build-tools/*/aapt 2>/dev/null | sort -V | tail -1 || true)" [ -n "$cand" ] && { printf '%s' "$cand"; return 0; } done command -v aapt >/dev/null 2>&1 && { command -v aapt; return 0; } return 1 } json_vn=""; json_vc="" AAPT="$(find_aapt || true)" if [ -n "${AAPT:-}" ]; then badging="$("$AAPT" dump badging "$APK_LOCAL" 2>/dev/null | grep '^package:' || true)" apk_vn="$(printf '%s' "$badging" | sed -n "s/.*versionName='\([^']*\)'.*/\1/p")" apk_vc="$(printf '%s' "$badging" | sed -n "s/.*versionCode='\([^']*\)'.*/\1/p")" json_vn="$(grep -o '"versionName"[^,]*' "$VERSION_LOCAL" | head -1 | sed 's/.*"versionName"[^"]*"\([^"]*\)".*/\1/')" json_vc="$(grep -o '"versionCode"[^,]*' "$VERSION_LOCAL" | head -1 | sed 's/[^0-9]//g')" bold "Version check" printf ' APK : versionName=%s versionCode=%s\n' "$apk_vn" "$apk_vc" printf ' version.json: versionName=%s versionCode=%s\n' "$json_vn" "$json_vc" if [ "$apk_vn" != "$json_vn" ] || [ "$apk_vc" != "$json_vc" ]; then err "version.json does NOT match the APK. Devices compare version.json — a mismatch breaks updates." read -rp "Upload anyway? [y/N] " ans case "$ans" in y|Y) ;; *) exit 1;; esac else ok "version.json matches the APK" fi else printf 'aapt not found — skipping APK/version.json match check.\n' fi # -------------------------------------------------------------------------- # Archive prep: discover the version we're about to REPLACE so the current # remote APK can be moved (server-side rename) into history/ instead of being # deleted, and recorded in a root-level history.json manifest that the in-app # (admin-only) version browser reads. First release / no node → archiving is # simply skipped and the upload behaves exactly as before. # -------------------------------------------------------------------------- ARCHIVE_SUBDIR="history" HISTORY_LOCAL="$UPDATE_DIR/history.json" ARCHIVE_NAME="" rm -f "$HISTORY_LOCAL" # New version (the version.json we're about to upload); fall back to grep if aapt was missing. new_vn="${json_vn:-}"; new_vc="${json_vc:-}" [ -n "$new_vn" ] || new_vn="$(grep -o '"versionName"[^,]*' "$VERSION_LOCAL" | head -1 | sed 's/.*"versionName"[^"]*"\([^"]*\)".*/\1/')" [ -n "$new_vc" ] || new_vc="$(grep -o '"versionCode"[^,]*' "$VERSION_LOCAL" | head -1 | sed 's/[^0-9]//g')" old_remote_json=""; old_history_json="" if command -v curl >/dev/null 2>&1; then old_remote_json="$(curl -fsS "${PUBLIC_BASE_URL}/version.json" 2>/dev/null || true)" old_history_json="$(curl -fsS "${PUBLIC_BASE_URL}/history.json" 2>/dev/null || true)" fi old_vn=""; old_vc="" if [ -n "$old_remote_json" ]; then old_vn="$(printf '%s' "$old_remote_json" | grep -o '"versionName"[^,]*' | head -1 | sed 's/.*"versionName"[^"]*"\([^"]*\)".*/\1/')" old_vc="$(printf '%s' "$old_remote_json" | grep -o '"versionCode"[^,]*' | head -1 | sed 's/[^0-9]//g')" fi if [ -n "$old_vn" ] && [ -n "$old_vc" ] && [ "$old_vc" != "$new_vc" ]; then ARCHIVE_NAME="logship-mobile-${old_vn}-${old_vc}.apk" if command -v node >/dev/null 2>&1; then # Merge the replaced version into history.json (newest first, dedup by versionCode). OLD_VERSION_JSON="$old_remote_json" OLD_HISTORY_JSON="$old_history_json" \ ARCHIVE_URL="${ARCHIVE_SUBDIR}/${ARCHIVE_NAME}" \ node -e ' const oldV = JSON.parse(process.env.OLD_VERSION_JSON || "{}"); let hist = []; try { hist = JSON.parse(process.env.OLD_HISTORY_JSON || "[]"); if (!Array.isArray(hist)) hist = []; } catch { hist = []; } const entry = { versionName: oldV.versionName || "", versionCode: Number(oldV.versionCode) || 0, releaseDate: oldV.releaseDate || "", releaseNotes: oldV.releaseNotes || "", apkUrl: process.env.ARCHIVE_URL, }; hist = hist.filter(h => Number(h.versionCode) !== entry.versionCode); hist.unshift(entry); hist.sort((a, b) => (Number(b.versionCode) || 0) - (Number(a.versionCode) || 0)); process.stdout.write(JSON.stringify(hist, null, 2) + "\n"); ' > "$HISTORY_LOCAL" || { err "Could not build history.json (node failed) — archiving APK without manifest update"; rm -f "$HISTORY_LOCAL"; } else err "node not found — archiving the old APK but NOT updating history.json (in-app history won't list it)" fi fi # -------------------------------------------------------------------------- # Confirm # -------------------------------------------------------------------------- echo bold "About to upload" printf ' from : %s\n %s\n' "$APK_LOCAL" "$VERSION_LOCAL" printf ' to : %s@%s:%s (port %s, auth=%s)\n' "$SFTP_USER" "$SFTP_HOST" "$SFTP_REMOTE_DIR" "$SFTP_PORT" "$SFTP_AUTH" if [ -n "$ARCHIVE_NAME" ]; then printf ' archive: current remote APK (v%s, code %s) -> %s/%s\n' "$old_vn" "$old_vc" "$ARCHIVE_SUBDIR" "$ARCHIVE_NAME" [ -f "$HISTORY_LOCAL" ] && printf ' history: history.json updated (%s entries)\n' "$(grep -c '"versionCode"' "$HISTORY_LOCAL" 2>/dev/null || echo '?')" else printf ' archive: (none — first release or same version)\n' fi read -rp "Proceed? [y/N] " go case "$go" in y|Y) ;; *) echo "Aborted."; exit 0;; esac # -------------------------------------------------------------------------- # Build the sftp batch: # - create the remote dir if missing (ignored if it already exists) # - upload APK to a .part temp, then atomically rename to the final name # - upload version.json LAST so the updater never points at a half-uploaded APK # -------------------------------------------------------------------------- # - when replacing an existing version, MOVE the current APK into history/ # (server-side rename, then promote the new APK) and upload the refreshed history.json BATCH="$(mktemp -t logship-sftp.XXXXXX)" cleanup() { rm -f "$BATCH"; } trap cleanup EXIT { printf '%s\n' "-mkdir \"$SFTP_REMOTE_DIR\"" printf '%s\n' "cd \"$SFTP_REMOTE_DIR\"" printf '%s\n' "put \"$APK_LOCAL\" logship-mobile.apk.part" if [ -n "$ARCHIVE_NAME" ]; then printf '%s\n' "-mkdir $ARCHIVE_SUBDIR" printf '%s\n' "-rm $ARCHIVE_SUBDIR/$ARCHIVE_NAME" printf '%s\n' "-rename logship-mobile.apk $ARCHIVE_SUBDIR/$ARCHIVE_NAME" else printf '%s\n' "-rm logship-mobile.apk" fi printf '%s\n' "rename logship-mobile.apk.part logship-mobile.apk" printf '%s\n' "put \"$VERSION_LOCAL\" version.json" [ -f "$HISTORY_LOCAL" ] && printf '%s\n' "put \"$HISTORY_LOCAL\" history.json" printf '%s\n' "ls -l logship-mobile.apk version.json" printf '%s\n' "bye" } >"$BATCH" SFTP_COMMON_OPTS=(-oStrictHostKeyChecking=accept-new -oConnectTimeout=20 -P "$SFTP_PORT") [ -n "$SFTP_IDENTITY" ] && SFTP_COMMON_OPTS+=(-i "$SFTP_IDENTITY") # --- key / agent auth (default), with interactive password fallback ------- # IMPORTANT: commands are fed on stdin (NOT via `sftp -b`). `-b` silently forces # the SSH option BatchMode=yes, which disables the password prompt — so if the # key is not accepted, `sftp -b` hangs/fails with no way to type a password. # Feeding commands on stdin keeps the connection interactive: the key is used if # available, otherwise sftp prompts for the server password on the terminal. # -oConnectTimeout bounds an unreachable host instead of hanging forever. upload_key() { local idopt=(); [ -n "$SFTP_IDENTITY" ] && idopt=(-i "$SFTP_IDENTITY") # ${idopt[@]+"${idopt[@]}"} is the empty-array-safe expansion: under `set -u` # macOS bash 3.2 errors on "${idopt[@]}" when the array is empty. # Feed the shared $BATCH on stdin (NOT via `-b`, which forces BatchMode=yes and # kills the password prompt). ssh still reads any password from /dev/tty. sftp -oStrictHostKeyChecking=accept-new -oConnectTimeout=20 ${idopt[@]+"${idopt[@]}"} \ -P "$SFTP_PORT" "$SFTP_USER@$SFTP_HOST" < "$BATCH" } # --- password auth back-ends ---------------------------------------------- upload_lftp() { local idline="" [ -n "$SFTP_IDENTITY" ] && idline="set sftp:connect-program \"ssh -a -x -i $SFTP_IDENTITY\"" local archive_cmds="rm -f logship-mobile.apk" if [ -n "$ARCHIVE_NAME" ]; then archive_cmds=$'mkdir -f '"$ARCHIVE_SUBDIR"$'\nrm -f '"$ARCHIVE_SUBDIR/$ARCHIVE_NAME"$'\nmv logship-mobile.apk '"$ARCHIVE_SUBDIR/$ARCHIVE_NAME" fi local history_cmd="" [ -f "$HISTORY_LOCAL" ] && history_cmd="put \"$HISTORY_LOCAL\" -o history.json" lftp -u "$SFTP_USER,$SFTP_PASS" "sftp://$SFTP_HOST:$SFTP_PORT" </dev/null 2>&1; then echo "(password auth via lftp)"; upload_lftp elif command -v sshpass >/dev/null 2>&1; then echo "(password auth via sshpass)"; upload_sshpass elif command -v expect >/dev/null 2>&1; then echo "(password auth via expect)"; upload_expect else err "Password auth needs lftp / sshpass / expect. Install one, e.g.: brew install lftp" exit 1 fi fi ok "Upload finished" # -------------------------------------------------------------------------- # Optional: verify the public endpoint now serves the new version # -------------------------------------------------------------------------- if command -v curl >/dev/null 2>&1; then echo bold "Verifying ${PUBLIC_BASE_URL}/version.json" remote_json="$(curl -fsS "${PUBLIC_BASE_URL}/version.json" 2>/dev/null || true)" if [ -n "$remote_json" ]; then remote_vn="$(printf '%s' "$remote_json" | grep -o '"versionName"[^,]*' | head -1 | sed 's/.*"versionName"[^"]*"\([^"]*\)".*/\1/')" printf ' served versionName = %s\n' "$remote_vn" if [ -n "${json_vn:-}" ] && [ "$remote_vn" = "$json_vn" ]; then ok "Public endpoint serves the uploaded version" else printf ' (note: served value may lag behind due to CDN/proxy caching)\n' fi else printf ' (could not fetch — check the URL / server config manually)\n' fi fi echo bold "Done. Devices will see the update on the next check." printf ' %s/version.json\n %s/logship-mobile.apk\n' "$PUBLIC_BASE_URL" "$PUBLIC_BASE_URL"