set -euo pipefail
repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
state_dir="${CUA_SANDBOX_STATE_DIR:-$repo_root/target/cua-sandbox}"
cli_prefix="$state_dir/cli"
playwright_prefix="$state_dir/playwright"
cuabot="$cli_prefix/node_modules/.bin/cuabot"
default_session="${CUA_SANDBOX_SESSION:-}"
custom_image="aven-cuabot:latest"
cuabot_image="trycua/cuabot:latest"
cuabot_version="1.0.14"
default_db="${AVEN_SANDBOX_DB:-$HOME/.local/state/aven/marketing-screenshot.sqlite}"
usage() {
cat <<'USAGE'
Usage: scripts/cua-sandbox <command> [arguments]
Commands:
setup Install the local CLI and build the persistent image
start <session> Start an isolated CuaBot server in the foreground
stop <session> Stop a named CuaBot server
status <session> Show a named CuaBot server's status
list List discoverable CuaBot sessions
db-bootstrap <session> [source] [--force]
Snapshot a host aven database into a session
cua <session> [args...] Pass arguments to a named CuaBot server
container <session> Print the Docker container name
Environment:
CUA_SANDBOX_SESSION Session fallback when an argument is omitted
CUA_SANDBOX_STATE_DIR Host CLI state (default: target/cua-sandbox)
AVEN_SANDBOX_DB Database source for db-bootstrap
USAGE
}
require_command() {
if ! command -v "$1" >/dev/null 2>&1; then
printf 'missing required command: %s\n' "$1" >&2
exit 1
fi
}
require_cli() {
if [[ ! -x "$cuabot" ]]; then
echo "CuaBot CLI is missing. Run: scripts/cua-sandbox setup" >&2
exit 1
fi
}
require_image() {
if ! docker image inspect "$custom_image" >/dev/null 2>&1; then
echo "Sandbox image is missing. Run: scripts/cua-sandbox setup" >&2
exit 1
fi
}
activate_image() {
require_image
docker tag "$custom_image" "$cuabot_image"
}
resolve_session() {
local requested="${1:-$default_session}"
if [[ -z "$requested" ]]; then
echo "a session name is required" >&2
echo "Pass one explicitly or set CUA_SANDBOX_SESSION." >&2
exit 1
fi
if [[ ! "$requested" =~ ^[a-zA-Z0-9][a-zA-Z0-9._-]*$ ]]; then
echo "invalid session name: $requested" >&2
exit 1
fi
printf '%s\n' "$requested"
}
list_sessions() {
local found=0
printf '%-28s %-8s %-7s %s\n' "SESSION" "SERVER" "PORT" "CONTAINER"
shopt -s nullglob
for pid_file in "$HOME"/.cuabot/server.*.pid; do
local filename session pid port state container_state
filename="${pid_file##*/}"
session="${filename#server.}"
session="${session%.pid}"
pid="$(head -n 1 "$pid_file" 2>/dev/null || true)"
port="$(cat "$HOME/.cuabot/server.$session.port" 2>/dev/null || true)"
state="stale"
if [[ "$pid" =~ ^[0-9]+$ ]] && kill -0 "$pid" 2>/dev/null; then
state="active"
fi
container_state="$(
docker inspect --format '{{.State.Status}}' "cuabot-xpra-$session" 2>/dev/null \
|| printf 'absent'
)"
printf '%-28s %-8s %-7s %s\n' \
"$session" "$state" "${port:--}" "$container_state"
found=1
done
shopt -u nullglob
if [[ "$found" -eq 0 ]]; then
echo "(none)"
fi
}
bootstrap_database() (
local session="$1"
local source="$2"
local force="$3"
local container="cuabot-xpra-$session"
local destination="/home/user/aven-run/aven.db"
local binary="/home/user/aven-run/aven"
require_command docker
require_command sqlite3
if [[ ! -f "$source" ]]; then
echo "database source does not exist: $source" >&2
exit 1
fi
if [[ "$(docker inspect --format '{{.State.Status}}' "$container" 2>/dev/null || true)" != "running" ]]; then
echo "sandbox container is not running: $container" >&2
exit 1
fi
if docker exec "$container" pgrep -f 'aven tui' >/dev/null 2>&1; then
echo "aven is running in session '$session'; stop it before replacing its database" >&2
exit 1
fi
if ! docker exec -u user "$container" test -x "$binary"; then
echo "sandbox aven binary is missing: $binary" >&2
echo "Deploy the branch build before bootstrapping its database." >&2
exit 1
fi
if [[ "$force" != "true" ]] \
&& docker exec -u user "$container" test -e "$destination"
then
echo "sandbox database already exists: $destination"
echo "Pass --force to replace it."
exit 0
fi
local temp_dir snapshot remote
temp_dir="$(mktemp -d)"
trap 'rm -rf "$temp_dir"' EXIT
snapshot="$temp_dir/aven.sqlite"
remote="/home/user/aven-run/.aven-bootstrap-$session-$$.sqlite"
sqlite3 "$source" ".backup '$snapshot'"
local version migration
local -a normalized=()
while IFS= read -r version; do
if [[ ! "$version" =~ ^[0-9]+$ ]]; then
echo "database contains an invalid migration version: $version" >&2
exit 1
fi
migration="$(compgen -G "$repo_root/migrations/${version}_*.sql" || true)"
if [[ -z "$migration" ]]; then
sqlite3 "$snapshot" "DELETE FROM _sqlx_migrations WHERE version = $version;"
normalized+=("$version")
fi
done < <(sqlite3 "$snapshot" 'SELECT version FROM _sqlx_migrations ORDER BY version;')
if [[ "$(sqlite3 "$snapshot" 'PRAGMA quick_check;')" != "ok" ]]; then
echo "database snapshot failed its integrity check" >&2
exit 1
fi
chmod 0644 "$snapshot"
docker exec -u user "$container" mkdir -p /home/user/aven-run
docker cp "$snapshot" "$container:$remote"
docker exec -u user "$container" sh -lc "
set -eu
cp '$remote' '$destination.bootstrap'
"
docker exec "$container" rm -f "$remote"
if ! docker exec -u user -e AVEN_DB="$destination.bootstrap" \
"$container" "$binary" list --all --limit 1 >/dev/null
then
docker exec -u user "$container" rm -f "$destination.bootstrap"
echo "sandbox aven could not open the database snapshot" >&2
exit 1
fi
docker exec -u user "$container" \
rm -f "$destination-wal" "$destination-shm"
docker exec -u user "$container" mv "$destination.bootstrap" "$destination"
echo "Bootstrapped $destination"
echo "Source: $source"
echo "Session: $session"
if [[ "${#normalized[@]}" -gt 0 ]]; then
printf 'Ignored source-only migrations:'
printf ' %s' "${normalized[@]}"
printf '\n'
fi
)
configure_cuabot() {
local settings="$HOME/.cuabot/settings.json"
mkdir -p "$(dirname "$settings")"
SETTINGS_FILE="$settings" node <<'NODE'
const fs = require('fs');
const path = process.env.SETTINGS_FILE;
let settings = {};
try {
settings = JSON.parse(fs.readFileSync(path, 'utf8'));
} catch (error) {
if (error.code !== 'ENOENT') throw error;
}
if (settings.telemetryEnabled === undefined) settings.telemetryEnabled = false;
if (settings.aliasIgnored === undefined) settings.aliasIgnored = true;
fs.writeFileSync(path, `${JSON.stringify(settings, null, 2)}\n`);
NODE
}
setup() {
require_command docker
require_command node
require_command npm
if [[ ! -x /Applications/Xpra.app/Contents/MacOS/Xpra ]]; then
echo "Xpra is missing. Install it with: brew install --cask xpra" >&2
exit 1
fi
if xattr -p com.apple.quarantine /Applications/Xpra.app >/dev/null 2>&1; then
echo "Xpra is quarantined. Clear it with:" >&2
echo " xattr -cr /Applications/Xpra.app" >&2
exit 1
fi
if ! docker info >/dev/null 2>&1; then
echo "Docker is unavailable. Start OrbStack and retry." >&2
exit 1
fi
mkdir -p "$state_dir"
npm install --prefix "$cli_prefix" "cuabot@$cuabot_version"
local playwright_version
playwright_version="$(
node -p \
"require('$cli_prefix/node_modules/playwright/package.json').version"
)"
npm install --prefix "$playwright_prefix" "playwright@$playwright_version"
"$playwright_prefix/node_modules/.bin/playwright" install chromium
configure_cuabot
docker build \
--file "$repo_root/sandbox/cua/Dockerfile" \
--tag "$custom_image" \
"$repo_root/sandbox/cua"
activate_image
echo
echo "Sandbox setup is ready."
echo "Start one with: scripts/cua-sandbox start <unique-session>"
}
command="${1:-}"
case "$command" in
setup)
setup
;;
start)
require_cli
session="$(resolve_session "${2:-}")"
pid_file="$HOME/.cuabot/server.$session.pid"
if [[ -f "$pid_file" ]]; then
pid="$(head -n 1 "$pid_file" 2>/dev/null || true)"
if [[ "$pid" =~ ^[0-9]+$ ]] && kill -0 "$pid" 2>/dev/null; then
echo "session '$session' is already active under pid $pid" >&2
echo "Use a different session or stop this one explicitly." >&2
exit 1
fi
fi
activate_image
export XPRA_USER_CONF_DIRS="$repo_root/sandbox/cua/xpra"
export XPRA_OSX_SHOW_MENU_DEFAULT=0
export XPRA_OSX_USE_WINDOW_MENU=0
exec "$cuabot" -n "$session" --serve
;;
stop)
require_cli
session="$(resolve_session "${2:-}")"
exec "$cuabot" -n "$session" --stop
;;
status)
require_cli
session="$(resolve_session "${2:-}")"
exec "$cuabot" -n "$session" --status
;;
list)
require_command docker
list_sessions
;;
db-bootstrap)
session="$(resolve_session "${2:-}")"
source="${3:-$default_db}"
force="false"
if [[ "$source" == "--force" ]]; then
source="$default_db"
force="true"
elif [[ "${4:-}" == "--force" ]]; then
force="true"
elif [[ -n "${4:-}" ]]; then
echo "unexpected argument: ${4}" >&2
exit 1
fi
bootstrap_database "$session" "$source" "$force"
;;
cua)
require_cli
session="$(resolve_session "${2:-}")"
shift 2
exec "$cuabot" -n "$session" "$@"
;;
container)
session="$(resolve_session "${2:-}")"
printf 'cuabot-xpra-%s\n' "$session"
;;
help|-h|--help|'')
usage
;;
*)
echo "unknown command: $command" >&2
usage >&2
exit 1
;;
esac