#!/bin/bash # ============================================================================ # Deploy todo everywhere it runs, then prove each one is live. # # scripts/deploy.sh # all targets: mcp desktop phone # scripts/deploy.sh mcp desktop # only the named targets # # Targets (each has its own health check; any failure makes the exit non-zero, # but the remaining targets still deploy so one unplugged phone does not leave # the desktop on old code): # mcp frozen binary + todo-mcp.service (mcp/install.sh), restart, then a # real initialize + list_tasks against :8735 (mcp/verify_mcp.py). # desktop web build + pacman package todo-flutter (install_arch.sh); health # = installed package and bundled web version.json match pubspec. # phone release APK via phone_deploy.sh (adb install -r only; it has no # uninstall / pm clear path); health = versionName on the Pixel # matches pubspec and the app process is running. # # Idempotent: desktop and phone skip the build when the installed version # already equals pubspec's (every commit bumps the patch version, so an equal # version means equal code); the MCP binary is cheap and always rebuilt. # # Nothing live runs from ~/src/todo (the MCP is a frozen binary, the desktop # app lives in /opt/todo), so this deploys the checkout it sits in and never # pulls kuhy's working tree. # ============================================================================ set -euo pipefail ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" readonly ROOT readonly SERIAL="${ADB_SERIAL:-23181JEGR08034}" readonly APP_ID="dev.kuhy.todo" readonly MCP_UNIT="todo-mcp.service" readonly MCP_URL="http://127.0.0.1:8735/mcp" readonly MCP_TOKEN="${HOME}/.config/todo-mcp-client/token" readonly PHONE_DEPLOY="${HOME}/.claude/scripts/phone_deploy.sh" readonly CAPPED="${HOME}/.claude/scripts/capped.sh" readonly WEB_VERSION_JSON="/opt/todo/web/version.json" FAILED=() say() { echo "[deploy:$1] $2"; } fail() { echo "[deploy:$1] FAIL: $2" >&2 FAILED+=("$1") } # pubspec `version: 1.0.77+1` -> `1.0.77` (the +build part is CI's). pubspec_version() { sed -n 's/^version:[[:space:]]*\([^+[:space:]]*\).*/\1/p' "$ROOT/pubspec.yaml" } # Builds run under the shared resource cap when it exists on this machine. heavy() { if [[ -x "$CAPPED" ]]; then CAP_MEM=4G CAP_CPU_PCT=20 "$CAPPED" "$@" else "$@" fi } mcp_pid() { systemctl --user show -p MainPID --value "$MCP_UNIT" 2>/dev/null || echo 0; } deploy_mcp() { local old_pid new_pid out old_pid="$(mcp_pid)" heavy "$ROOT/mcp/install.sh" || { fail mcp "mcp/install.sh failed"; return; } # install.sh only restarts a running unit; make sure it is enabled + up. systemctl --user enable --now "$MCP_UNIT" >/dev/null 2>&1 || { fail mcp "cannot start $MCP_UNIT"; return; } systemctl --user is-active --quiet "$MCP_UNIT" || { fail mcp "$MCP_UNIT not active"; return; } new_pid="$(mcp_pid)" if [[ "$new_pid" == 0 || "$new_pid" == "$old_pid" ]]; then fail mcp "$MCP_UNIT was not restarted (pid $old_pid -> $new_pid)" return fi [[ -s "$MCP_TOKEN" ]] || { fail mcp "no client token at $MCP_TOKEN"; return; } out="$(python3 "$ROOT/mcp/verify_mcp.py" "$MCP_URL" \ --token-file "$MCP_TOKEN" --wait 20 2>&1)" || { fail mcp "live check: $out"; return; } say mcp "ok: pid $new_pid, ${out//$'\n'/, }" } desktop_versions_match() { local want="$1" pkg web pkg="$(pacman -Q todo-flutter 2>/dev/null | awk '{print $2}')" web="$(sed -n 's/.*"version":"\([^"]*\)".*/\1/p' "$WEB_VERSION_JSON" 2>/dev/null)" [[ "${pkg%-*}" == "$want" && "$web" == "$want" ]] } deploy_desktop() { local want want="$(pubspec_version)" if desktop_versions_match "$want"; then say desktop "ok: todo-flutter $want already installed" return fi sudo -n true 2>/dev/null || { fail desktop "sudo needs a password; run: ! $ROOT/install_arch.sh"; return; } heavy "$ROOT/install_arch.sh" || { fail desktop "install_arch.sh failed"; return; } if desktop_versions_match "$want"; then say desktop "ok: todo-flutter $want installed" else fail desktop "installed version is not $want ($(pacman -Q todo-flutter 2>&1))" fi } phone_version() { adb -s "$SERIAL" shell dumpsys package "$APP_ID" 2>/dev/null | sed -n 's/^[[:space:]]*versionName=\([^[:space:]]*\).*/\1/p' | head -n 1 } phone_health() { local want="$1" got got="$(phone_version)" [[ "$got" == "$want" ]] || { fail phone "versionName on phone is '$got', want $want"; return; } adb -s "$SERIAL" shell pidof "$APP_ID" >/dev/null 2>&1 || adb -s "$SERIAL" shell monkey -p "$APP_ID" -c android.intent.category.LAUNCHER 1 >/dev/null 2>&1 || true sleep 2 adb -s "$SERIAL" shell pidof "$APP_ID" >/dev/null 2>&1 || { fail phone "$APP_ID is installed but not running"; return; } say phone "ok: $APP_ID $want running on $SERIAL" } deploy_phone() { local want rc=0 want="$(pubspec_version)" if [[ "$(adb -s "$SERIAL" get-state 2>/dev/null)" != device ]]; then fail phone "phone unreachable: $SERIAL not attached (plug it in, enable USB debugging)" return fi if [[ "$(phone_version)" == "$want" ]]; then say phone "already at $want, skipping build" else ADB_SERIAL="$SERIAL" heavy "$PHONE_DEPLOY" "$ROOT" --release || rc=$? ((rc == 0)) || { fail phone "phone_deploy.sh exited $rc (see its exit-code table)"; return; } fi phone_health "$want" } main() { local targets=("$@") t ((${#targets[@]})) || targets=(mcp desktop phone) for t in "${targets[@]}"; do case "$t" in mcp | desktop | phone) "deploy_$t" ;; *) echo "usage: $0 [mcp] [desktop] [phone]" >&2; exit 2 ;; esac done if ((${#FAILED[@]})); then echo "[deploy] FAILED: ${FAILED[*]}" >&2 exit 1 fi echo "[deploy] all healthy: ${targets[*]} at $(pubspec_version)" } main "$@"