#!/usr/bin/env bash set -euo pipefail ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" APP="$ROOT/dist/PromptGarage.app" INSTALL_APP="/Applications/PromptGarage.app" SOURCE_DB="" INSTALL=false LAUNCH=false SOURCE_SNAPSHOT_BEFORE="" SOURCE_SNAPSHOT_AFTER="" INSTALL_STAGING_DIR="" INSTALL_PREVIOUS_APP="" INSTALL_REJECTED_APP="" INSTALL_HAD_PREVIOUS=false INSTALL_PREVIOUS_MOVED=false INSTALL_NEW_IN_PLACE=false INSTALL_TRANSACTION_ACTIVE=false INSTALL_COMMITTED=false CLEANUP_TRAP_ACTIVE=false usage() { cat <<'EOF' Usage: ./scripts/verify-mac.sh [options] Options: --source-db PATH Expose a real database read-only to integration tests via PROMPTGARAGE_SOURCE_DB and prove its files are unchanged. --launch Launch the verified dist/PromptGarage.app and prove that a new PromptGarage process remains alive. --install Replace /Applications/PromptGarage.app, then launch and verify it. This is the only mode that writes to /Applications. -h, --help Show this help. Without --launch or --install, no app is opened. Without --install, nothing is written to /Applications. This script never writes to --source-db. EOF } fail() { printf 'verify-mac: %s\n' "$*" >&2 exit 1 } require_command() { command -v "$1" >/dev/null 2>&1 || fail "required command not found: $1" } while [[ $# -gt 0 ]]; do case "$1" in --source-db) [[ $# -ge 2 ]] || fail "--source-db requires a path" [[ -n "$2" ]] || fail "--source-db requires a non-empty path" SOURCE_DB="$2" shift 2 ;; --launch) LAUNCH=true shift ;; --install) INSTALL=true LAUNCH=true shift ;; -h|--help) usage exit 0 ;; *) fail "unknown option: $1 (use --help)" ;; esac done assert_supported_host() { [[ "$(uname -s)" == "Darwin" ]] || fail "this verification must run on macOS" [[ "$(uname -m)" == "arm64" ]] || fail "an Apple Silicon (arm64) Mac is required" local macos_version macos_major xcode_version xcode_major swift_version macos_version="$(sw_vers -productVersion)" macos_major="${macos_version%%.*}" [[ "$macos_major" =~ ^[0-9]+$ ]] || fail "could not parse macOS version: $macos_version" (( macos_major >= 26 )) || fail "macOS 26 or newer is required; found $macos_version" xcode_version="$(xcodebuild -version | awk 'NR == 1 { print $2 }')" xcode_major="${xcode_version%%.*}" [[ "$xcode_major" =~ ^[0-9]+$ ]] || fail "could not parse Xcode version: $xcode_version" (( xcode_major >= 26 )) || fail "Xcode 26 or newer is required; found $xcode_version" swift_version="$(swift --version | sed -n '1s/.*Swift version \([0-9][0-9.]*\).*/\1/p')" [[ "$swift_version" == 6.2* ]] || fail "Swift 6.2 is required; found ${swift_version:-unknown}" } assert_exact_arm64() { local binary="$1" local architectures architectures="$(lipo -archs "$binary" | sed -e 's/^[[:space:]]*//' -e 's/[[:space:]]*$//')" [[ "$architectures" == "arm64" ]] || fail "expected exactly arm64, found: ${architectures:-no architecture}" } snapshot_source_database() { local output="$1" local suffix candidate digest : > "$output" for suffix in "" "-wal" "-shm" "-journal"; do candidate="${SOURCE_DB}${suffix}" if [[ -e "$candidate" ]]; then if [[ ! -f "$candidate" ]]; then printf 'source database family contains a non-file: %s\n' "$candidate" >&2 return 1 fi digest="$(shasum -a 256 "$candidate" | awk '{ print $1 }')" || return 1 printf 'present\t%s\t%s\n' "$suffix" "$digest" >> "$output" else printf 'absent\t%s\n' "$suffix" >> "$output" fi done } ensure_cleanup_trap() { if [[ "$CLEANUP_TRAP_ACTIVE" != true ]]; then trap cleanup_on_exit EXIT CLEANUP_TRAP_ACTIVE=true fi } rollback_install_transaction() { local rollback_ok=true [[ "$INSTALL_TRANSACTION_ACTIVE" == true ]] || return 0 [[ "$INSTALL_COMMITTED" != true ]] || return 0 if [[ "$INSTALL_NEW_IN_PLACE" == true ]]; then if [[ -e "$INSTALL_APP" || -L "$INSTALL_APP" ]]; then if mv "$INSTALL_APP" "$INSTALL_REJECTED_APP"; then INSTALL_NEW_IN_PLACE=false else printf 'verify-mac: automatic rollback could not move the replacement aside: %s\n' "$INSTALL_APP" >&2 rollback_ok=false fi else printf 'verify-mac: automatic rollback could not find the installed replacement: %s\n' "$INSTALL_APP" >&2 rollback_ok=false fi fi if [[ "$INSTALL_PREVIOUS_MOVED" == true ]]; then if [[ -e "$INSTALL_APP" || -L "$INSTALL_APP" ]]; then printf 'verify-mac: automatic rollback cannot restore over an occupied path: %s\n' "$INSTALL_APP" >&2 rollback_ok=false elif mv "$INSTALL_PREVIOUS_APP" "$INSTALL_APP"; then INSTALL_PREVIOUS_MOVED=false printf 'verify-mac: previous app restored after verification failure\n' >&2 else printf 'verify-mac: automatic rollback failed; previous app remains at: %s\n' "$INSTALL_PREVIOUS_APP" >&2 rollback_ok=false fi elif [[ "$INSTALL_HAD_PREVIOUS" != true && ( -e "$INSTALL_REJECTED_APP" || -L "$INSTALL_REJECTED_APP" ) ]]; then printf 'verify-mac: replacement app removed after verification failure\n' >&2 fi if [[ "$rollback_ok" == true ]]; then if [[ -n "$INSTALL_STAGING_DIR" ]] && ! rm -rf -- "$INSTALL_STAGING_DIR"; then printf 'verify-mac: rollback restored the install path, but could not clean transaction files: %s\n' "$INSTALL_STAGING_DIR" >&2 return 1 fi INSTALL_TRANSACTION_ACTIVE=false return 0 fi printf 'verify-mac: install rollback incomplete; preserving transaction files at: %s\n' "$INSTALL_STAGING_DIR" >&2 return 1 } cleanup_on_exit() { local status=$? trap - EXIT if [[ -n "$SOURCE_SNAPSHOT_BEFORE" && -f "$SOURCE_SNAPSHOT_BEFORE" ]]; then if ! snapshot_source_database "$SOURCE_SNAPSHOT_AFTER"; then printf 'verify-mac: could not re-check source database after failure\n' >&2 status=1 elif ! cmp -s "$SOURCE_SNAPSHOT_BEFORE" "$SOURCE_SNAPSHOT_AFTER"; then printf 'verify-mac: SOURCE DATABASE CHANGED; refusing a successful result\n' >&2 diff -u "$SOURCE_SNAPSHOT_BEFORE" "$SOURCE_SNAPSHOT_AFTER" >&2 || true status=1 fi fi if (( status != 0 )); then rollback_install_transaction || status=1 elif [[ "$INSTALL_TRANSACTION_ACTIVE" == true && "$INSTALL_COMMITTED" != true ]]; then printf 'verify-mac: install transaction reached EXIT before commit; rolling back\n' >&2 rollback_install_transaction || status=1 status=1 fi [[ -z "$SOURCE_SNAPSHOT_BEFORE" ]] || rm -f "$SOURCE_SNAPSHOT_BEFORE" [[ -z "$SOURCE_SNAPSHOT_AFTER" ]] || rm -f "$SOURCE_SNAPSHOT_AFTER" exit "$status" } finish_source_guard() { [[ -n "$SOURCE_SNAPSHOT_BEFORE" ]] || return 0 snapshot_source_database "$SOURCE_SNAPSHOT_AFTER" || fail "could not re-check source database" if ! cmp -s "$SOURCE_SNAPSHOT_BEFORE" "$SOURCE_SNAPSHOT_AFTER"; then diff -u "$SOURCE_SNAPSHOT_BEFORE" "$SOURCE_SNAPSHOT_AFTER" >&2 || true fail "source database or a SQLite sidecar changed during verification" fi rm -f "$SOURCE_SNAPSHOT_BEFORE" "$SOURCE_SNAPSHOT_AFTER" SOURCE_SNAPSHOT_BEFORE="" SOURCE_SNAPSHOT_AFTER="" } resolve_xcode_scheme() { local listing schemes candidate listing="$(xcodebuild -list 2>&1)" || fail "xcodebuild could not inspect this Swift package" schemes="$(printf '%s\n' "$listing" | awk ' /^[[:space:]]*Schemes:[[:space:]]*$/ { inside = 1; next } inside && /^[[:space:]]+[^[:space:]]/ { line = $0 sub(/^[[:space:]]+/, "", line) sub(/[[:space:]]+$/, "", line) print line next } inside && NF > 0 { exit } ')" if [[ -n "${PROMPTGARAGE_XCODE_SCHEME:-}" ]]; then if printf '%s\n' "$schemes" | grep -Fxq "$PROMPTGARAGE_XCODE_SCHEME"; then printf '%s\n' "$PROMPTGARAGE_XCODE_SCHEME" return 0 fi fail "PROMPTGARAGE_XCODE_SCHEME is not available: $PROMPTGARAGE_XCODE_SCHEME" fi for candidate in PromptGarage-Package PromptGarage; do if printf '%s\n' "$schemes" | grep -Fxq "$candidate"; then printf '%s\n' "$candidate" return 0 fi done printf 'Available Xcode schemes:\n%s\n' "${schemes:-}" >&2 fail "no PromptGarage package test scheme found; set PROMPTGARAGE_XCODE_SCHEME explicitly" } install_verified_app() { local install_parent staged_app [[ -d /Applications ]] || fail "/Applications does not exist" [[ -w /Applications ]] || fail "/Applications is not writable; install manually rather than rerunning with sudo" install_parent="$(dirname "$INSTALL_APP")" INSTALL_STAGING_DIR="$(mktemp -d "$install_parent/.PromptGarage.install.XXXXXX")" \ || fail "could not create same-filesystem install staging directory" ensure_cleanup_trap INSTALL_TRANSACTION_ACTIVE=true staged_app="$INSTALL_STAGING_DIR/PromptGarage.app" INSTALL_PREVIOUS_APP="$INSTALL_STAGING_DIR/Previous-PromptGarage.app" INSTALL_REJECTED_APP="$INSTALL_STAGING_DIR/Rejected-PromptGarage.app" ditto "$APP" "$staged_app" || fail "could not stage the verified app" assert_exact_arm64 "$staged_app/Contents/MacOS/PromptGarage" codesign --verify --deep --strict --verbose=2 "$staged_app" \ || fail "staged app failed code-signature verification; existing installation was not changed" if [[ -e "$INSTALL_APP" || -L "$INSTALL_APP" ]]; then mv "$INSTALL_APP" "$INSTALL_PREVIOUS_APP" \ || fail "could not preserve the existing installation; it was not changed" INSTALL_HAD_PREVIOUS=true INSTALL_PREVIOUS_MOVED=true fi mv "$staged_app" "$INSTALL_APP" || fail "could not move the staged app into place" INSTALL_NEW_IN_PLACE=true assert_exact_arm64 "$INSTALL_APP/Contents/MacOS/PromptGarage" codesign --verify --deep --strict --verbose=2 "$INSTALL_APP" \ || fail "installed app failed final code-signature verification" printf 'Installed app pending launch and source-family verification: %s\n' "$INSTALL_APP" } commit_install_transaction() { [[ "$INSTALL_TRANSACTION_ACTIVE" == true ]] || return 0 if ! rm -rf -- "$INSTALL_STAGING_DIR"; then fail "installation passed, but transaction cleanup failed: $INSTALL_STAGING_DIR" fi INSTALL_COMMITTED=true INSTALL_TRANSACTION_ACTIVE=false INSTALL_PREVIOUS_MOVED=false INSTALL_NEW_IN_PLACE=false printf 'Installed verified app: %s\n' "$INSTALL_APP" } launch_and_verify_new_process() { local launch_app="$1" local expected_executable="$launch_app/Contents/MacOS/PromptGarage" local before_pids pid process_command launched_pid="" before_pids=" $(pgrep -x PromptGarage 2>/dev/null | tr '\n' ' ' || true) " open -n "$launch_app" for _ in 1 2 3 4 5 6 7 8 9 10; do for pid in $(pgrep -x PromptGarage 2>/dev/null || true); do case "$before_pids" in *" $pid "*) continue ;; esac process_command="$(ps -p "$pid" -o command= 2>/dev/null || true)" case "$process_command" in "$expected_executable"*) launched_pid="$pid" ;; esac [[ -z "$launched_pid" ]] || break done [[ -z "$launched_pid" ]] || break sleep 1 done [[ -n "$launched_pid" ]] || fail "open returned, but no new process for $expected_executable stayed alive" sleep 2 ps -p "$launched_pid" >/dev/null 2>&1 || fail "PromptGarage process $launched_pid exited during launch verification" printf 'Launch verified: PromptGarage PID %s\n' "$launched_pid" } for command_name in uname sw_vers xcodebuild swift python3 awk sed grep lipo codesign shasum cmp diff; do require_command "$command_name" done if [[ "$INSTALL" == true ]]; then for command_name in dirname ditto mktemp mv rm; do require_command "$command_name" done fi if [[ "$LAUNCH" == true ]]; then for command_name in open pgrep ps tr sleep; do require_command "$command_name" done fi if [[ -n "$SOURCE_DB" ]]; then for command_name in dirname basename mktemp rm; do require_command "$command_name" done fi assert_supported_host if [[ -n "$SOURCE_DB" ]]; then [[ ! -L "$SOURCE_DB" ]] || fail "--source-db must not be a symlink; pass the real database path so SQLite sidecars are guarded" [[ -f "$SOURCE_DB" ]] || fail "source database not found: $SOURCE_DB" [[ -r "$SOURCE_DB" ]] || fail "source database is not readable: $SOURCE_DB" SOURCE_DB="$(cd "$(dirname "$SOURCE_DB")" && pwd -P)/$(basename "$SOURCE_DB")" [[ ! -L "$SOURCE_DB" ]] || fail "canonical --source-db resolved to a symlink; pass the real database path" SOURCE_SNAPSHOT_BEFORE="$(mktemp "${TMPDIR:-/tmp}/promptgarage-source-before.XXXXXX")" \ || fail "could not create the source-before snapshot" ensure_cleanup_trap SOURCE_SNAPSHOT_AFTER="$(mktemp "${TMPDIR:-/tmp}/promptgarage-source-after.XXXXXX")" \ || fail "could not create the source-after snapshot" snapshot_source_database "$SOURCE_SNAPSHOT_BEFORE" || fail "could not fingerprint source database" export PROMPTGARAGE_SOURCE_DB="$SOURCE_DB" fi cd "$ROOT" if [[ -n "$SOURCE_DB" ]]; then python3 verification/verify_contract.py --source-db "$SOURCE_DB" else python3 verification/verify_contract.py fi swift package resolve swift test --arch arm64 XCODE_SCHEME="$(resolve_xcode_scheme)" printf 'Running Xcode package tests with scheme: %s\n' "$XCODE_SCHEME" xcodebuild \ -scheme "$XCODE_SCHEME" \ -destination 'platform=macOS,arch=arm64' \ -configuration Debug \ ARCHS=arm64 \ ONLY_ACTIVE_ARCH=YES \ test "$ROOT/scripts/build-app.sh" [[ -x "$APP/Contents/MacOS/PromptGarage" ]] || fail "packaged app executable missing" assert_exact_arm64 "$APP/Contents/MacOS/PromptGarage" codesign --verify --deep --strict --verbose=2 "$APP" LAUNCH_APP="$APP" if [[ "$INSTALL" == true ]]; then install_verified_app LAUNCH_APP="$INSTALL_APP" fi if [[ "$LAUNCH" == true ]]; then launch_and_verify_new_process "$LAUNCH_APP" fi finish_source_guard if [[ "$INSTALL" == true ]]; then commit_install_transaction fi trap - EXIT printf 'Mac automated verification passed.\n' if [[ "$LAUNCH" == true ]]; then printf 'Complete verification/MAC_UI_SMOKE_CHECKLIST.md before calling the app done.\n' else printf 'No app was launched. Use --launch or --install only when ready for the UI smoke test.\n' fi