this repo has no description
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

at main 668 lines 19 kB view raw
1#!/usr/bin/env bash 2# Single-command deployment for assistant to Hetzner Cloud 3# 4# Prerequisites: 5# - hcloud CLI installed and configured (hcloud context create assistant) 6# - secrets.env filled in (cp secrets.env.example secrets.env) 7# 8# Usage: 9# ./deploy.sh 10 11set -euo pipefail 12IFS=$'\n\t' 13 14# Safe temp directory with automatic cleanup 15scratch=$(mktemp -d -t deploy.XXXXXXXXXX) 16function finish { 17 rm -rf "$scratch" 18} 19trap finish EXIT 20 21# Colors for output 22RED='\033[0;31m' 23GREEN='\033[0;32m' 24YELLOW='\033[0;33m' 25BLUE='\033[0;34m' 26NC='\033[0m' # No Color 27 28# Script directory 29SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" 30 31# Server name 32SERVER_NAME="assistant" 33 34####################################### 35# Logging functions 36####################################### 37log_info() { 38 echo -e "${BLUE}[INFO]${NC} $1" 39} 40 41log_success() { 42 echo -e "${GREEN}[OK]${NC} $1" 43} 44 45log_warn() { 46 echo -e "${YELLOW}[WARN]${NC} $1" 47} 48 49log_error() { 50 echo -e "${RED}[ERROR]${NC} $1" >&2 51} 52 53die() { 54 log_error "$1" 55 exit 1 56} 57 58####################################### 59# Check prerequisites 60####################################### 61check_prerequisites() { 62 log_info "Checking prerequisites..." 63 64 # Check required commands 65 local missing=() 66 for cmd in hcloud jq curl ssh scp; do 67 if ! command -v "$cmd" &> /dev/null; then 68 missing+=("$cmd") 69 fi 70 done 71 72 if [[ ${#missing[@]} -gt 0 ]]; then 73 die "Missing required commands: ${missing[*]}" 74 fi 75 76 # Check hcloud is configured 77 if ! hcloud context active &> /dev/null; then 78 die "hcloud not configured. Run: hcloud context create assistant" 79 fi 80 81 # Check secrets.env exists 82 if [[ ! -f "$SCRIPT_DIR/secrets.env" ]]; then 83 die "secrets.env not found. Run: cp secrets.env.example secrets.env" 84 fi 85 86 log_success "Prerequisites OK" 87} 88 89####################################### 90# Load and validate secrets 91####################################### 92load_secrets() { 93 log_info "Loading secrets..." 94 95 # shellcheck source=/dev/null 96 source "$SCRIPT_DIR/secrets.env" 97 98 # Required variables 99 local required=( 100 DOMAIN 101 SUBDOMAIN 102 CLOUDFLARE_API_TOKEN 103 CLOUDFLARE_ZONE_ID 104 TS_OAUTH_SECRET 105 TS_TAG 106 GH_REPO 107 TELEGRAM_BOT_TOKEN 108 OPENAI_API_KEY 109 ) 110 111 local missing=() 112 for var in "${required[@]}"; do 113 if [[ -z "${!var:-}" ]]; then 114 missing+=("$var") 115 fi 116 done 117 118 if [[ ${#missing[@]} -gt 0 ]]; then 119 die "Missing required secrets: ${missing[*]}" 120 fi 121 122 # Generate optional secrets if empty 123 if [[ -z "${ANTHROPIC_PROXY_SESSION_SECRET:-}" ]]; then 124 ANTHROPIC_PROXY_SESSION_SECRET=$(openssl rand -hex 32) 125 log_info "Generated ANTHROPIC_PROXY_SESSION_SECRET" 126 fi 127 128 if [[ -z "${TELEGRAM_WEBHOOK_SECRET_TOKEN:-}" ]]; then 129 TELEGRAM_WEBHOOK_SECRET_TOKEN=$(openssl rand -hex 16) 130 log_info "Generated TELEGRAM_WEBHOOK_SECRET_TOKEN" 131 fi 132 133 if [[ -z "${LETTA_SERVER_PASSWORD:-}" ]]; then 134 LETTA_SERVER_PASSWORD=$(openssl rand -hex 16) 135 log_info "Generated LETTA_SERVER_PASSWORD" 136 fi 137 138 # Defaults 139 HETZNER_LOCATION="${HETZNER_LOCATION:-fsn1}" 140 HETZNER_SERVER_TYPE="${HETZNER_SERVER_TYPE:-cx22}" 141 142 log_success "Secrets loaded" 143} 144 145####################################### 146# Create or get SSH key 147####################################### 148setup_ssh_key() { 149 log_info "Setting up SSH key..." 150 151 local key_name="assistant-deploy" 152 153 # Check if key exists in hcloud 154 if hcloud ssh-key describe "$key_name" &> /dev/null; then 155 log_info "Using existing SSH key: $key_name" 156 SSH_KEY_NAME="$key_name" 157 return 158 fi 159 160 # Check if local key exists 161 local local_key="$HOME/.ssh/id_ed25519" 162 if [[ ! -f "$local_key" ]]; then 163 local_key="$HOME/.ssh/id_rsa" 164 fi 165 166 if [[ ! -f "$local_key" ]]; then 167 die "No SSH key found. Create one with: ssh-keygen -t ed25519" 168 fi 169 170 # Upload to hcloud 171 hcloud ssh-key create --name "$key_name" --public-key-from-file "${local_key}.pub" 172 SSH_KEY_NAME="$key_name" 173 log_success "SSH key uploaded: $key_name" 174} 175 176####################################### 177# Check if server already exists 178####################################### 179check_existing_server() { 180 if hcloud server describe "$SERVER_NAME" &> /dev/null; then 181 log_warn "Server '$SERVER_NAME' already exists" 182 read -rp "Delete and recreate? [y/N] " confirm 183 if [[ "$confirm" =~ ^[Yy]$ ]]; then 184 log_info "Deleting existing server..." 185 hcloud server delete "$SERVER_NAME" 186 sleep 5 187 else 188 die "Aborted. Use teardown.sh to remove the existing server." 189 fi 190 fi 191} 192 193####################################### 194# Generate cloud-init config 195####################################### 196generate_cloud_init() { 197 log_info "Generating cloud-init config..." 198 199 local template="$SCRIPT_DIR/cloud-init.yaml.tmpl" 200 local output="$scratch/cloud-init.yaml" 201 202 if [[ ! -f "$template" ]]; then 203 die "cloud-init template not found: $template" 204 fi 205 206 # Substitute variables 207 sed \ 208 -e "s|__TS_OAUTH_SECRET__|${TS_OAUTH_SECRET}|g" \ 209 -e "s|__TS_TAG__|${TS_TAG}|g" \ 210 -e "s|__GH_REPO__|${GH_REPO}|g" \ 211 "$template" > "$output" 212 213 CLOUD_INIT_FILE="$output" 214 log_success "Cloud-init config generated" 215} 216 217####################################### 218# Create server 219####################################### 220create_server() { 221 log_info "Creating server..." 222 223 hcloud server create \ 224 --name "$SERVER_NAME" \ 225 --type "$HETZNER_SERVER_TYPE" \ 226 --image debian-13 \ 227 --location "$HETZNER_LOCATION" \ 228 --ssh-key "$SSH_KEY_NAME" \ 229 --user-data-from-file "$CLOUD_INIT_FILE" 230 231 # Get server IPs 232 SERVER_IP=$(hcloud server ip "$SERVER_NAME") 233 # hcloud returns a /64 prefix (e.g., 2a01:4f8::/64), convert to usable address 234 local ip6_raw 235 ip6_raw=$(hcloud server ip -6 "$SERVER_NAME" | head -1) 236 if [[ -n "$ip6_raw" && "$ip6_raw" =~ ^([^/]+):: ]]; then 237 SERVER_IP6="${BASH_REMATCH[1]}::1" 238 else 239 SERVER_IP6="" 240 fi 241 log_success "Server created: $SERVER_IP${SERVER_IP6:+ / $SERVER_IP6}" 242} 243 244####################################### 245# Update Cloudflare DNS 246####################################### 247update_dns_record() { 248 local record_type="$1" 249 local fqdn="$2" 250 local content="$3" 251 local api_base="https://api.cloudflare.com/client/v4" 252 253 # Check if record exists 254 local existing 255 existing=$(curl -s -X GET \ 256 "$api_base/zones/$CLOUDFLARE_ZONE_ID/dns_records?type=$record_type&name=$fqdn" \ 257 -H "Authorization: Bearer $CLOUDFLARE_API_TOKEN" \ 258 -H "Content-Type: application/json") 259 260 local record_id 261 record_id=$(echo "$existing" | jq -r '.result[0].id // empty') 262 263 if [[ -n "$record_id" ]]; then 264 # Update existing record 265 curl -s -X PUT \ 266 "$api_base/zones/$CLOUDFLARE_ZONE_ID/dns_records/$record_id" \ 267 -H "Authorization: Bearer $CLOUDFLARE_API_TOKEN" \ 268 -H "Content-Type: application/json" \ 269 --data "{\"type\":\"$record_type\",\"name\":\"$fqdn\",\"content\":\"$content\",\"ttl\":300,\"proxied\":false}" \ 270 | jq -e '.success' > /dev/null 271 log_success "DNS $record_type updated: $fqdn -> $content" 272 else 273 # Create new record 274 curl -s -X POST \ 275 "$api_base/zones/$CLOUDFLARE_ZONE_ID/dns_records" \ 276 -H "Authorization: Bearer $CLOUDFLARE_API_TOKEN" \ 277 -H "Content-Type: application/json" \ 278 --data "{\"type\":\"$record_type\",\"name\":\"$fqdn\",\"content\":\"$content\",\"ttl\":300,\"proxied\":false}" \ 279 | jq -e '.success' > /dev/null 280 log_success "DNS $record_type created: $fqdn -> $content" 281 fi 282} 283 284update_dns() { 285 log_info "Updating Cloudflare DNS..." 286 287 local fqdn="${SUBDOMAIN}.${DOMAIN}" 288 289 # Create/update A record (IPv4) 290 update_dns_record "A" "$fqdn" "$SERVER_IP" 291 292 # Create/update AAAA record (IPv6) 293 if [[ -n "$SERVER_IP6" ]]; then 294 update_dns_record "AAAA" "$fqdn" "$SERVER_IP6" 295 fi 296} 297 298####################################### 299# Wait for server to be ready 300####################################### 301wait_for_server() { 302 log_info "Waiting for server to be ready..." 303 304 local max_attempts=60 305 local attempt=0 306 307 # Wait for SSH 308 while [[ $attempt -lt $max_attempts ]]; do 309 if ssh -o ConnectTimeout=5 -o StrictHostKeyChecking=no -o BatchMode=yes \ 310 "root@$SERVER_IP" "echo ok" &> /dev/null; then 311 break 312 fi 313 attempt=$((attempt + 1)) 314 echo -n "." 315 sleep 5 316 done 317 echo 318 319 if [[ $attempt -ge $max_attempts ]]; then 320 die "Timeout waiting for SSH" 321 fi 322 323 # Capture host key for future connections 324 ssh-keyscan -H "$SERVER_IP" >> ~/.ssh/known_hosts 2>/dev/null || true 325 326 log_success "SSH is available" 327 328 # Wait for cloud-init to complete, streaming log output in real-time 329 log_info "Waiting for cloud-init to complete (streaming log)..." 330 331 # Start tail -f in background 332 ssh -o StrictHostKeyChecking=no "root@$SERVER_IP" \ 333 "tail -f /var/log/cloud-init-output.log 2>/dev/null" & 334 local tail_pid=$! 335 336 # Poll for completion file 337 attempt=0 338 while [[ $attempt -lt $max_attempts ]]; do 339 if ssh -o StrictHostKeyChecking=no "root@$SERVER_IP" \ 340 "test -f /opt/.cloud-init-complete" &> /dev/null; then 341 break 342 fi 343 attempt=$((attempt + 1)) 344 sleep 5 345 done 346 347 # Stop the tail process 348 kill "$tail_pid" 2>/dev/null || true 349 wait "$tail_pid" 2>/dev/null || true 350 351 if [[ $attempt -ge $max_attempts ]]; then 352 die "Timeout waiting for cloud-init" 353 fi 354 355 log_success "Cloud-init completed" 356} 357 358####################################### 359# Get Tailscale IP 360####################################### 361get_tailscale_ip() { 362 log_info "Getting Tailscale IP..." 363 364 TAILSCALE_IP=$(ssh -o StrictHostKeyChecking=no "root@$SERVER_IP" \ 365 "tailscale ip -4" 2>/dev/null || echo "") 366 367 if [[ -z "$TAILSCALE_IP" ]]; then 368 log_warn "Could not get Tailscale IP. Check Tailscale auth." 369 else 370 log_success "Tailscale IP: $TAILSCALE_IP" 371 fi 372} 373 374####################################### 375# Generate and upload .env file 376####################################### 377upload_env_file() { 378 log_info "Generating .env file..." 379 380 local env_file="$scratch/.env" 381 local fqdn="${SUBDOMAIN}.${DOMAIN}" 382 383 cat > "$env_file" << EOF 384# Generated by deploy.sh on $(date -u +"%Y-%m-%dT%H:%M:%SZ") 385 386# === Server === 387PORT=3000 388NODE_ENV=production 389 390# === Letta === 391LETTA_BASE_URL=http://letta:8283 392LETTA_SERVER_PASSWORD=${LETTA_SERVER_PASSWORD} 393 394# === Telegram === 395TELEGRAM_BOT_TOKEN=${TELEGRAM_BOT_TOKEN} 396TELEGRAM_WEBHOOK_URL=https://${fqdn}/webhook 397TELEGRAM_WEBHOOK_SECRET_TOKEN=${TELEGRAM_WEBHOOK_SECRET_TOKEN} 398 399# === Anthropic Proxy === 400ANTHROPIC_PROXY_URL=http://anthropic-proxy:4001/v1 401ANTHROPIC_PROXY_SESSION_SECRET=${ANTHROPIC_PROXY_SESSION_SECRET} 402ANTHROPIC_PROXY_SESSION_ID= 403 404# === LiteLLM === 405LITELLM_URL=http://litellm:4000 406 407# === OpenAI === 408OPENAI_API_KEY=${OPENAI_API_KEY} 409 410# === Database === 411DB_PATH=/app/data/assistant.db 412 413# === Tool Webhooks === 414TOOL_WEBHOOK_URL=http://app:3000 415 416# === Monitoring === 417NETDATA_CLAIM_TOKEN=${NETDATA_CLAIM_TOKEN:-} 418EOF 419 420 log_info "Uploading .env file..." 421 scp -o StrictHostKeyChecking=no "$env_file" "root@$SERVER_IP:/opt/assistant/.env" 422 log_success ".env file uploaded" 423} 424 425####################################### 426# Generate and upload Caddyfile 427####################################### 428upload_caddyfile() { 429 log_info "Generating Caddyfile..." 430 431 local caddyfile="$scratch/Caddyfile" 432 local fqdn="${SUBDOMAIN}.${DOMAIN}" 433 434 cat > "$caddyfile" << EOF 435# Generated by deploy.sh on $(date -u +"%Y-%m-%dT%H:%M:%SZ") 436 437# Main app - Telegram webhook 438${fqdn} { 439 reverse_proxy app:3000 440} 441 442# Netdata and Letta are accessed via Tailscale only 443# http://TAILSCALE_IP:19999 - Netdata 444# http://TAILSCALE_IP:8283 - Letta 445# Anthropic Proxy: internal only, use SSH tunnel for OAuth setup 446EOF 447 448 log_info "Uploading Caddyfile..." 449 scp -o StrictHostKeyChecking=no "$caddyfile" "root@$SERVER_IP:/opt/assistant/Caddyfile" 450 log_success "Caddyfile uploaded" 451} 452 453####################################### 454# Upload system prompt 455####################################### 456upload_system_prompt() { 457 log_info "Uploading system prompt..." 458 459 local prompt_file="$SCRIPT_DIR/../prompts/SYSTEM_PROMPT.md" 460 461 if [[ ! -f "$prompt_file" ]]; then 462 log_warn "System prompt not found: $prompt_file" 463 log_warn "Create it from the example: cp prompts/SYSTEM_PROMPT.md.example prompts/SYSTEM_PROMPT.md" 464 return 465 fi 466 467 ssh -T -o StrictHostKeyChecking=no "root@$SERVER_IP" "mkdir -p /opt/assistant/prompts" 468 scp -o StrictHostKeyChecking=no "$prompt_file" "root@$SERVER_IP:/opt/assistant/prompts/SYSTEM_PROMPT.md" 469 ssh -T -o StrictHostKeyChecking=no "root@$SERVER_IP" "chown -R 1000:1000 /opt/assistant/prompts" 470 log_success "System prompt uploaded" 471} 472 473####################################### 474# Start services 475####################################### 476start_services() { 477 log_info "Pulling pre-built images..." 478 479 ssh -T -o StrictHostKeyChecking=no "root@$SERVER_IP" << 'EOF' 480cd /opt/assistant 481docker compose -f docker-compose.yml -f docker-compose.prod.yml pull --ignore-buildable 482EOF 483 484 log_success "Images pulled" 485 log_info "Building custom images..." 486 487 ssh -T -o StrictHostKeyChecking=no "root@$SERVER_IP" << 'EOF' 488cd /opt/assistant 489export DOCKER_BUILDKIT=1 490export COMPOSE_DOCKER_CLI_BUILD=1 491docker compose -f docker-compose.yml -f docker-compose.prod.yml build 492EOF 493 494 log_success "Images built" 495 log_info "Starting services..." 496 497 ssh -T -o StrictHostKeyChecking=no "root@$SERVER_IP" << 'EOF' 498cd /opt/assistant 499docker compose -f docker-compose.yml -f docker-compose.prod.yml up -d 500EOF 501 502 log_success "Services started" 503} 504 505####################################### 506# Wait for health check 507####################################### 508wait_for_health() { 509 log_info "Waiting for services to be healthy..." 510 511 local fqdn="${SUBDOMAIN}.${DOMAIN}" 512 local max_attempts=30 513 local attempt=0 514 515 while [[ $attempt -lt $max_attempts ]]; do 516 if curl -sf "https://${fqdn}/health" &> /dev/null; then 517 break 518 fi 519 attempt=$((attempt + 1)) 520 # Show container health status 521 local status 522 status=$(ssh -o StrictHostKeyChecking=no -o ConnectTimeout=5 "root@$SERVER_IP" \ 523 "cd /opt/assistant && docker compose -f docker-compose.yml -f docker-compose.prod.yml ps --format 'table {{.Service}}\t{{.Status}}' 2>/dev/null | tail -n +2 | tr '\n' ' '" 2>/dev/null || echo "connecting...") 524 printf "\r %-100s" "$status" 525 sleep 10 526 done 527 printf "\r%-110s\n" "" # Clear the status line 528 529 if [[ $attempt -ge $max_attempts ]]; then 530 log_warn "Health check timeout. Services may still be starting." 531 log_info "Check logs with: ssh root@$SERVER_IP 'docker compose -f /opt/assistant/docker-compose.yml -f /opt/assistant/docker-compose.prod.yml logs -f'" 532 else 533 log_success "Services healthy" 534 fi 535} 536 537####################################### 538# Set Telegram webhook 539####################################### 540set_telegram_webhook() { 541 log_info "Setting Telegram webhook..." 542 543 local fqdn="${SUBDOMAIN}.${DOMAIN}" 544 local webhook_url="https://${fqdn}/webhook" 545 546 local response 547 response=$(curl -s -X POST \ 548 "https://api.telegram.org/bot${TELEGRAM_BOT_TOKEN}/setWebhook" \ 549 -H "Content-Type: application/json" \ 550 -d "{\"url\":\"$webhook_url\",\"secret_token\":\"$TELEGRAM_WEBHOOK_SECRET_TOKEN\"}") 551 552 if echo "$response" | jq -e '.ok' > /dev/null; then 553 log_success "Telegram webhook set: $webhook_url" 554 else 555 log_warn "Failed to set webhook: $response" 556 fi 557} 558 559####################################### 560# Get deploy key for GitHub Actions 561####################################### 562get_deploy_key() { 563 log_info "Getting deploy key for GitHub Actions..." 564 565 DEPLOY_KEY_PUBLIC=$(ssh -o StrictHostKeyChecking=no "root@$SERVER_IP" \ 566 "cat /root/.ssh/deploy_key.pub" 2>/dev/null || echo "") 567 568 # Write private key to file with restricted permissions (never print to stdout) 569 DEPLOY_KEY_FILE="$SCRIPT_DIR/deploy_key_${SERVER_NAME}" 570 ssh -o StrictHostKeyChecking=no "root@$SERVER_IP" \ 571 "cat /root/.ssh/deploy_key" 2>/dev/null > "$DEPLOY_KEY_FILE" || true 572 573 if [[ -s "$DEPLOY_KEY_FILE" ]]; then 574 chmod 600 "$DEPLOY_KEY_FILE" 575 log_success "Deploy key saved to: $DEPLOY_KEY_FILE" 576 else 577 rm -f "$DEPLOY_KEY_FILE" 578 DEPLOY_KEY_FILE="" 579 log_warn "Could not retrieve deploy key" 580 fi 581} 582 583####################################### 584# Print summary 585####################################### 586print_summary() { 587 local fqdn="${SUBDOMAIN}.${DOMAIN}" 588 589 echo 590 echo -e "${GREEN}========================================${NC}" 591 echo -e "${GREEN} Deployment Complete!${NC}" 592 echo -e "${GREEN}========================================${NC}" 593 echo 594 echo -e "${BLUE}Server:${NC}" 595 echo " IPv4: $SERVER_IP" 596 echo " IPv6: ${SERVER_IP6:-'(none)'}" 597 echo " Tailscale: ${TAILSCALE_IP:-'(check Tailscale admin)'}" 598 echo 599 echo -e "${BLUE}Service URLs:${NC}" 600 echo " App (public): https://${fqdn}" 601 echo " Health check: https://${fqdn}/health" 602 if [[ -n "${TAILSCALE_IP:-}" ]]; then 603 echo " Letta (Tailscale): http://${TAILSCALE_IP}:8283" 604 echo " Netdata (Tailscale): http://${TAILSCALE_IP}:19999" 605 fi 606 echo " OAuth setup (SSH tunnel): ssh -L 4001:localhost:4001 root@$SERVER_IP" 607 echo 608 echo -e "${YELLOW}NEXT STEP: Complete Anthropic OAuth${NC}" 609 echo " The OAuth endpoint is internal-only. Use SSH tunnel to access it:" 610 echo 611 echo " ssh -L 4001:localhost:4001 root@$SERVER_IP" 612 echo " # Then open in browser: http://localhost:4001/auth/device" 613 echo 614 echo " After completing OAuth, copy the session ID and update .env:" 615 echo " ssh root@$SERVER_IP" 616 echo " nano /opt/assistant/.env" 617 echo " # Set ANTHROPIC_PROXY_SESSION_ID=your_session_id" 618 echo " docker compose -f docker-compose.yml -f docker-compose.prod.yml restart" 619 echo 620 if [[ -n "${DEPLOY_KEY_PUBLIC:-}" ]]; then 621 echo -e "${BLUE}GitHub Actions Setup:${NC}" 622 echo " Add this deploy key to GitHub (Settings → Deploy keys):" 623 echo " $DEPLOY_KEY_PUBLIC" 624 echo 625 echo " Add these secrets to GitHub Actions (Settings → Secrets):" 626 echo " HOST: $SERVER_IP" 627 if [[ -n "${DEPLOY_KEY_FILE:-}" ]]; then 628 echo " SSH_KEY: contents of $DEPLOY_KEY_FILE" 629 fi 630 echo 631 fi 632 echo -e "${BLUE}Useful commands:${NC}" 633 echo " SSH: ssh root@$SERVER_IP" 634 echo " Logs: ssh root@$SERVER_IP 'cd /opt/assistant && docker compose logs -f'" 635 echo " Restart: ssh root@$SERVER_IP 'cd /opt/assistant && docker compose -f docker-compose.yml -f docker-compose.prod.yml restart'" 636 echo " Teardown: ./teardown.sh" 637 echo 638} 639 640####################################### 641# Main 642####################################### 643main() { 644 echo -e "${BLUE}========================================${NC}" 645 echo -e "${BLUE} Assistant Deployment Script${NC}" 646 echo -e "${BLUE}========================================${NC}" 647 echo 648 649 check_prerequisites 650 load_secrets 651 setup_ssh_key 652 check_existing_server 653 generate_cloud_init 654 create_server 655 update_dns 656 wait_for_server 657 get_tailscale_ip 658 upload_env_file 659 upload_caddyfile 660 upload_system_prompt 661 start_services 662 wait_for_health 663 set_telegram_webhook 664 get_deploy_key 665 print_summary 666} 667 668main "$@"