spawn/github-codespaces/lib/common.sh
A 5ebe3e5a13
fix: add actionable guidance to destroy_server failures and service timeouts (#959)
When server destruction fails, users are left with a bare error message and
no indication that they may still be billed for a running server. This adds
dashboard URLs and clear warnings to destroy_server errors across 9 clouds
(Hetzner, UpCloud, Contabo, Netcup, RamNode, Hostinger, HOSTKEY, OVH,
Latitude). Also improves error messages for Koyeb (app creation, service
deployment, deployment timeout, instance ID), GitHub Codespaces (creation
failure, readiness timeout), and E2B (sandbox creation failure).

Agent: ux-engineer

Co-authored-by: A <6723574+louisgv@users.noreply.github.com>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-02-13 09:38:58 -08:00

249 lines
7.6 KiB
Bash
Executable file

#!/bin/bash
# Common bash functions for GitHub Codespaces spawn scripts
# Uses GitHub CLI (gh) for provisioning and SSH access
# Bash safety flags
set -eo pipefail
# ============================================================
# Provider-agnostic functions
# ============================================================
# Source shared provider-agnostic functions (local or remote fallback)
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" 2>/dev/null && pwd)"
if [[ -n "$SCRIPT_DIR" && -f "$SCRIPT_DIR/../../shared/common.sh" ]]; then
source "$SCRIPT_DIR/../../shared/common.sh"
else
eval "$(curl -fsSL https://raw.githubusercontent.com/OpenRouterTeam/spawn/main/shared/common.sh)"
fi
# Note: Provider-agnostic functions (logging, OAuth, browser, nc_listen) are now in shared/common.sh
# ============================================================
# GitHub Codespaces specific functions
# ============================================================
# Ensure gh CLI is installed
ensure_gh_cli() {
if command -v gh &>/dev/null; then
log_info "GitHub CLI (gh) available"
return 0
fi
log_step "Installing GitHub CLI (gh)..."
# Detect OS and install accordingly
if [[ "$OSTYPE" == "darwin"* ]]; then
if command -v brew &>/dev/null; then
brew install gh || {
log_error "Failed to install gh via Homebrew"
return 1
}
else
log_error "Homebrew not found. Install from https://cli.github.com/"
return 1
fi
elif [[ "$OSTYPE" == "linux-gnu"* ]]; then
curl -fsSL https://cli.github.com/packages/githubcli-archive-keyring.gpg | sudo dd of=/usr/share/keyrings/githubcli-archive-keyring.gpg
sudo chmod go+r /usr/share/keyrings/githubcli-archive-keyring.gpg
echo "deb [arch=$(dpkg --print-architecture) signed-by=/usr/share/keyrings/githubcli-archive-keyring.gpg] https://cli.github.com/packages stable main" | sudo tee /etc/apt/sources.list.d/github-cli.list > /dev/null
sudo apt update
sudo apt install gh -y || {
log_error "Failed to install gh via apt"
return 1
}
else
log_error "Unsupported OS. Install from https://cli.github.com/"
return 1
fi
if ! command -v gh &>/dev/null; then
log_error "gh not found in PATH after installation"
return 1
fi
log_info "GitHub CLI (gh) installed"
}
# Ensure user is authenticated with gh CLI
ensure_gh_auth() {
if ! gh auth status &>/dev/null; then
log_step "Not authenticated with GitHub CLI"
log_step "Initiating GitHub CLI authentication..."
gh auth login || {
log_error "Failed to authenticate with GitHub CLI"
log_error "Run: gh auth login"
return 1
}
fi
log_info "Authenticated with GitHub CLI"
return 0
}
# Create a new codespace
# Args: $1 = repo (e.g., "OpenRouterTeam/spawn")
# $2 = machine type (optional, default: basicLinux32gb)
# $3 = idle timeout (optional, default: 30m)
create_codespace() {
local repo="$1"
local machine="${2:-basicLinux32gb}"
local idle_timeout="${3:-30m}"
log_step "Creating GitHub Codespace..."
log_info "Repo: $repo"
log_info "Machine: $machine"
log_info "Idle timeout: $idle_timeout"
local codespace_name
codespace_name=$(gh codespace create \
--repo "$repo" \
--machine "$machine" \
--idle-timeout "$idle_timeout" \
2>&1)
if [[ $? -ne 0 ]]; then
log_error "Failed to create codespace"
if [[ -n "$codespace_name" ]]; then
log_error "Error: $codespace_name"
fi
log_error ""
log_error "Common causes:"
log_error " - Codespace spending limit reached (check: https://github.com/settings/billing)"
log_error " - Machine type unavailable for this repository"
log_error " - Repository does not exist or you lack access"
log_error " - GitHub CLI not authenticated (run: gh auth login)"
return 1
fi
echo "$codespace_name"
}
# Wait for codespace to be ready
# Args: $1 = codespace name
wait_for_codespace() {
local codespace="$1"
local max_attempts=60
local attempt=0
log_step "Waiting for codespace to be ready..."
while [[ $attempt -lt $max_attempts ]]; do
local state
state=$(gh codespace view --codespace "$codespace" --json state --jq '.state' 2>/dev/null || echo "Unknown")
if [[ "$state" == "Available" ]]; then
log_info "Codespace is ready"
return 0
fi
attempt=$((attempt + 1))
sleep 2
done
log_error "Codespace failed to become ready after $max_attempts attempts"
log_error ""
log_error "The codespace may still be starting. You can:"
log_error " 1. Check status: gh codespace list"
log_error " 2. Connect manually: gh codespace ssh --codespace $codespace"
log_error " 3. View in browser: https://github.com/codespaces"
return 1
}
# Run command in codespace
# Args: $1 = codespace name
# $2+ = command to run
run_in_codespace() {
local codespace="$1"
shift
gh codespace ssh --codespace "$codespace" -- "$@"
}
# Copy file to codespace
# Args: $1 = codespace name
# $2 = source file
# $3 = destination path
copy_to_codespace() {
local codespace="$1"
local source="$2"
local dest="$3"
gh codespace cp "$source" "$codespace:$dest"
}
# Open interactive SSH session in codespace
# Args: $1 = codespace name
ssh_to_codespace() {
local codespace="$1"
log_step "Opening SSH session to codespace..."
gh codespace ssh --codespace "$codespace"
}
# Delete a codespace
# Args: $1 = codespace name
delete_codespace() {
local codespace="$1"
log_step "Deleting codespace $codespace..."
gh codespace delete --codespace "$codespace" --force || {
log_warn "Failed to delete codespace (may already be deleted)"
}
}
# Upload a file to codespace via gh codespace cp
# Args: $1 = local path
# $2 = remote path
upload_file() {
local local_path="$1"
local remote_path="$2"
if [[ ! -f "$local_path" ]]; then
log_error "Local file not found: $local_path"
return 1
fi
if [[ -z "${CODESPACE_NAME:-}" ]]; then
log_error "CODESPACE_NAME not set. Call create_codespace first."
return 1
fi
gh codespace cp "$local_path" "${CODESPACE_NAME}:${remote_path}"
}
# Run a command on the codespace (wrapper matching other providers' interface)
# SECURITY: Uses printf %q to properly escape commands to prevent injection
run_server() {
local cmd="$1"
if [[ -z "${CODESPACE_NAME:-}" ]]; then
log_error "CODESPACE_NAME not set. Call create_codespace first."
return 1
fi
local escaped_cmd
escaped_cmd=$(printf '%q' "$cmd")
gh codespace ssh --codespace "$CODESPACE_NAME" -- bash -c "$escaped_cmd"
}
# Inject environment variables into shell config
# Writes to a temp file and uploads to avoid shell interpolation of values
inject_env_vars() {
log_step "Injecting environment variables..."
local env_temp
env_temp=$(mktemp)
chmod 600 "${env_temp}"
track_temp_file "${env_temp}"
generate_env_config "$@" > "${env_temp}"
# Upload and append to .bashrc
upload_file "${env_temp}" "/tmp/env_config"
run_server "cat /tmp/env_config >> ~/.bashrc && rm /tmp/env_config"
log_info "Environment variables configured"
}
# Get codespace info
# Args: $1 = codespace name
get_codespace_info() {
local codespace="$1"
gh codespace view --codespace "$codespace" --json name,state,machine,repository,idleTimeoutNotice
}