From 0e56cddd47523b83f1fc105a25b9d7eb4c209b94 Mon Sep 17 00:00:00 2001 From: Krzysztof kuhy Rudnicki Date: Wed, 11 Nov 2026 15:15:15 +0100 Subject: [PATCH] Add scripts/deploy.sh and the deploy command Deploys the MCP service, the desktop pacman package and the phone release build, each behind its own health check; exits non-zero unless all pass. Co-Authored-By: Claude Opus 5.5 --- AGENTS.md | 1 + pubspec.yaml | 2 +- scripts/deploy.sh | 160 ++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 162 insertions(+), 1 deletion(-) create mode 100755 scripts/deploy.sh diff --git a/AGENTS.md b/AGENTS.md index 3bfd58f..d69b7c6 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -50,6 +50,7 @@ and sync peer-to-peer through a GitHub repo used as dumb storage. - lint: `flutter analyze` - coverage: `flutter test --coverage --reporter=compact` - coverage-gaps: `coverage-gaps coverage/lcov.info` +- deploy: `scripts/deploy.sh` (MCP service, desktop pacman package, phone release; exits non-zero unless every health check passes) ## Architecture diff --git a/pubspec.yaml b/pubspec.yaml index 4b58b40..ea6d948 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -16,7 +16,7 @@ publish_to: 'none' # Remove this line if you wish to publish to pub.dev # https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html # In Windows, build-name is used as the major, minor, and patch parts # of the product and file versions while build-number is used as the build suffix. -version: 1.0.77+1 +version: 1.0.79+1 environment: sdk: ^3.12.2 diff --git a/scripts/deploy.sh b/scripts/deploy.sh new file mode 100755 index 0000000..4c38e8e --- /dev/null +++ b/scripts/deploy.sh @@ -0,0 +1,160 @@ +#!/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 "$@"