diff --git a/CLAUDE.md b/CLAUDE.md index 4347185b..00f416b3 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -88,6 +88,9 @@ Research cloud providers with API-based provisioning. To add one: ``` spawn/ + cli/ + spawn.sh # Main CLI binary (interactive picker, matrix viewer, launcher) + install.sh # One-liner installer (downloads spawn.sh to ~/.local/bin) {cloud}/ lib/common.sh # Cloud-specific shared functions {agent}.sh # One script per agent @@ -97,6 +100,13 @@ spawn/ README.md # User-facing docs ``` +## CLI (`cli/`) + +The `spawn` CLI is a pure-bash binary that provides a unified entry point for the matrix. + +- **`cli/spawn.sh`** — Main binary. Fetches manifest.json from GitHub (cached for 1hr at `~/.cache/spawn/`), parses it with `jq` or `python3` fallback, and provides: interactive picker (`spawn`), direct launch (`spawn `), agent info (`spawn `), matrix table (`spawn list`), and self-update (`spawn update`). Installed to `~/.local/bin/spawn`. +- **`cli/install.sh`** — One-liner installer. Downloads `spawn.sh` to `~/.local/bin/spawn`, sets executable bit, and prints PATH instructions if needed. Override install dir with `SPAWN_INSTALL_DIR` env var. + ## Script Conventions - `#!/bin/bash` + `set -e` diff --git a/README.md b/README.md index 6bd04baf..61fd364b 100644 --- a/README.md +++ b/README.md @@ -2,6 +2,25 @@ One command to launch any AI coding agent on any cloud, pre-configured with [OpenRouter](https://openrouter.ai). +## Quick Start + +Install the `spawn` CLI: + +```bash +curl -fsSL https://raw.githubusercontent.com/OpenRouterTeam/spawn/main/cli/install.sh | bash +``` + +Then launch any agent interactively: + +```bash +spawn # Interactive picker +spawn claude sprite # Launch Claude Code on Sprite +spawn aider hetzner # Launch Aider on Hetzner Cloud +spawn list # See the full matrix +``` + +Or run directly without installing: + ```bash bash <(curl -fsSL https://openrouter.ai/lab/spawn/{cloud}/{agent}.sh) ``` diff --git a/aws-lightsail/lib/common.sh b/aws-lightsail/lib/common.sh index b4d43c1f..84a8a871 100644 --- a/aws-lightsail/lib/common.sh +++ b/aws-lightsail/lib/common.sh @@ -25,7 +25,7 @@ safe_read() { nc_listen() { local port=$1; shift - if nc --help 2>&1 | grep -q "BusyBox\|busybox" || nc --help 2>&1 | grep -q "\-p "; then + if nc --help 2>&1 | grep -q "BusyBox\|busybox"; then nc -l -p "$port" "$@" else nc -l "$port" "$@"; fi } @@ -62,23 +62,29 @@ try_oauth_flow() { local auth_url="https://openrouter.ai/auth?callback_url=${callback_url}" local oauth_dir=$(mktemp -d) code_file="$oauth_dir/code" log_warn "Starting local OAuth server on port ${callback_port}..." + + # Write the HTTP response to a file (using printf for macOS bash 3.x compat) + local response_tpl="$oauth_dir/response.http" + printf 'HTTP/1.1 200 OK\r\nContent-Type: text/html\r\nConnection: close\r\n\r\n

Authentication Successful!

You can close this tab

' > "$response_tpl" + + # Background listener ( - local success_response='HTTP/1.1 200 OK\r\nContent-Type: text/html\r\nConnection: close\r\n\r\n

Authentication Successful!

You can close this tab

' while true; do - local response_file=$(mktemp); echo -e "$success_response" > "$response_file" - local request=$(nc_listen "$callback_port" < "$response_file" 2>/dev/null | head -1) - local nc_status=$?; rm -f "$response_file" - if [[ $nc_status -ne 0 ]]; then break; fi - if [[ "$request" == *"/callback?code="* ]]; then - echo "$request" | sed -n 's/.*code=\([^ &]*\).*/\1/p' > "$code_file"; break - fi + request=$(nc_listen "$callback_port" < "$response_tpl" 2>/dev/null | head -1) || break + + case "$request" in + *"/callback?code="*) + echo "$request" | sed -n 's/.*code=\([^ &]*\).*/\1/p' > "$code_file" + break + ;; + esac done ) /dev/null; then log_warn "Failed to start OAuth server"; rm -rf "$oauth_dir"; return 1; fi log_warn "Opening browser to authenticate with OpenRouter..."; open_browser "$auth_url" local timeout=120 elapsed=0 - while [[ ! -f "$code_file" ]] && [[ $elapsed -lt $timeout ]]; do sleep 1; ((elapsed++)); done + while [[ ! -f "$code_file" ]] && [[ $elapsed -lt $timeout ]]; do sleep 1; elapsed=$((elapsed + 1)); done kill $server_pid 2>/dev/null || true; wait $server_pid 2>/dev/null || true if [[ ! -f "$code_file" ]]; then log_warn "OAuth timeout"; rm -rf "$oauth_dir"; return 1; fi local oauth_code=$(cat "$code_file"); rm -rf "$oauth_dir" @@ -224,7 +230,7 @@ create_server() { return 0 fi log_warn "Instance state: $state ($attempt/$max_attempts)" - sleep 5; ((attempt++)) + sleep 5; attempt=$((attempt + 1)) done log_error "Instance did not become running in time"; return 1 } @@ -236,7 +242,7 @@ verify_server_connectivity() { if ssh $SSH_OPTS -o ConnectTimeout=5 "ubuntu@$ip" "echo ok" >/dev/null 2>&1; then log_info "SSH connection established"; return 0 fi - log_warn "Waiting for SSH... ($attempt/$max_attempts)"; sleep 5; ((attempt++)) + log_warn "Waiting for SSH... ($attempt/$max_attempts)"; sleep 5; attempt=$((attempt + 1)) done log_error "Server failed to respond via SSH after $max_attempts attempts"; return 1 } @@ -248,7 +254,7 @@ wait_for_cloud_init() { if ssh $SSH_OPTS "ubuntu@$ip" "test -f /home/ubuntu/.cloud-init-complete" >/dev/null 2>&1; then log_info "Cloud-init completed"; return 0 fi - log_warn "Cloud-init in progress... ($attempt/$max_attempts)"; sleep 5; ((attempt++)) + log_warn "Cloud-init in progress... ($attempt/$max_attempts)"; sleep 5; attempt=$((attempt + 1)) done log_error "Cloud-init did not complete after $max_attempts attempts"; return 1 } diff --git a/cli/install.sh b/cli/install.sh new file mode 100755 index 00000000..c0dfe27e --- /dev/null +++ b/cli/install.sh @@ -0,0 +1,66 @@ +#!/bin/bash +# Installer for the spawn CLI +# +# Usage: +# curl -fsSL https://raw.githubusercontent.com/OpenRouterTeam/spawn/main/cli/install.sh | bash +# +# Override install directory: +# SPAWN_INSTALL_DIR=/usr/local/bin curl -fsSL ... | bash + +set -euo pipefail + +SPAWN_REPO="OpenRouterTeam/spawn" +SPAWN_RAW_BASE="https://raw.githubusercontent.com/$SPAWN_REPO/main" +INSTALL_DIR="${SPAWN_INSTALL_DIR:-$HOME/.local/bin}" + +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +NC='\033[0m' + +log_info() { echo -e "${GREEN}[spawn]${NC} $1"; } +log_warn() { echo -e "${YELLOW}[spawn]${NC} $1"; } +log_error() { echo -e "${RED}[spawn]${NC} $1"; } + +# Check curl +if ! command -v curl &>/dev/null; then + log_error "curl is required but not found" + exit 1 +fi + +# Create install directory +mkdir -p "$INSTALL_DIR" + +# Download spawn CLI +log_info "Downloading spawn CLI..." +if ! curl -fsSL "$SPAWN_RAW_BASE/cli/spawn.sh" -o "$INSTALL_DIR/spawn"; then + log_error "Failed to download spawn CLI" + exit 1 +fi + +chmod +x "$INSTALL_DIR/spawn" +log_info "Installed spawn to $INSTALL_DIR/spawn" + +# Check if install dir is in PATH +if ! echo "$PATH" | tr ':' '\n' | grep -qx "$INSTALL_DIR"; then + log_warn "$INSTALL_DIR is not in your PATH" + echo "" + echo "Add it by running one of:" + echo "" + + # Detect shell and suggest appropriate config + case "${SHELL:-/bin/bash}" in + */zsh) + echo " echo 'export PATH=\"$INSTALL_DIR:\$PATH\"' >> ~/.zshrc && source ~/.zshrc" + ;; + */fish) + echo " fish_add_path $INSTALL_DIR" + ;; + *) + echo " echo 'export PATH=\"$INSTALL_DIR:\$PATH\"' >> ~/.bashrc && source ~/.bashrc" + ;; + esac + echo "" +else + log_info "Run 'spawn' to get started" +fi diff --git a/cli/spawn.sh b/cli/spawn.sh new file mode 100755 index 00000000..aa9dc148 --- /dev/null +++ b/cli/spawn.sh @@ -0,0 +1,650 @@ +#!/bin/bash +# spawn — Dynamic entry point for the Spawn matrix +# +# Launch any AI coding agent on any cloud, pre-configured with OpenRouter. +# Fetches the manifest dynamically from GitHub so it's always up-to-date. +# +# Usage: +# spawn Interactive agent + cloud picker +# spawn Launch agent on cloud directly +# spawn Show available clouds for agent +# spawn list Full matrix table +# spawn agents List all agents with descriptions +# spawn clouds List all cloud providers +# spawn improve [--loop] Run improvement system +# spawn update Self-update from GitHub +# spawn version Show version +# spawn help Show this help + +set -uo pipefail + +# ── Constants ────────────────────────────────────────────────────────────────── + +SPAWN_VERSION="0.1.0" +SPAWN_REPO="OpenRouterTeam/spawn" +SPAWN_RAW_BASE="https://raw.githubusercontent.com/$SPAWN_REPO/main" +SPAWN_CACHE_DIR="${XDG_CACHE_HOME:-$HOME/.cache}/spawn" +SPAWN_MANIFEST="$SPAWN_CACHE_DIR/manifest.json" +SPAWN_CACHE_TTL=3600 # 1 hour in seconds + +# ── Colors & Logging ────────────────────────────────────────────────────────── + +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +BLUE='\033[0;34m' +BOLD='\033[1m' +DIM='\033[2m' +NC='\033[0m' + +log_info() { echo -e "${GREEN}[spawn]${NC} $1" >&2; } +log_warn() { echo -e "${YELLOW}[spawn]${NC} $1" >&2; } +log_error() { echo -e "${RED}[spawn]${NC} $1" >&2; } + +# ── Dependency Checks ───────────────────────────────────────────────────────── + +HAS_JQ=false +HAS_PYTHON3=false + +check_deps() { + if ! command -v curl &>/dev/null; then + log_error "curl is required but not found" + exit 1 + fi + command -v jq &>/dev/null && HAS_JQ=true + command -v python3 &>/dev/null && HAS_PYTHON3=true + if ! $HAS_JQ && ! $HAS_PYTHON3; then + log_error "Either jq or python3 is required for JSON parsing" + exit 1 + fi +} + +# ── JSON Helpers ─────────────────────────────────────────────────────────────── + +# Each helper tries jq first, falls back to python3. + +json_validate() { + local file="$1" + if $HAS_JQ; then + jq empty "$file" 2>/dev/null + elif $HAS_PYTHON3; then + python3 -c "import json; json.load(open('$file'))" 2>/dev/null + fi +} + +# List agent keys (one per line) +manifest_agents() { + if $HAS_JQ; then + jq -r '.agents | keys[]' "$SPAWN_MANIFEST" + else + python3 -c " +import json +m = json.load(open('$SPAWN_MANIFEST')) +for k in m['agents']: + print(k) +" + fi +} + +# List cloud keys (one per line) +manifest_clouds() { + if $HAS_JQ; then + jq -r '.clouds | keys[]' "$SPAWN_MANIFEST" + else + python3 -c " +import json +m = json.load(open('$SPAWN_MANIFEST')) +for k in m['clouds']: + print(k) +" + fi +} + +# Get agent display name +manifest_agent_name() { + local agent="$1" + if $HAS_JQ; then + jq -r --arg a "$agent" '.agents[$a].name // empty' "$SPAWN_MANIFEST" + else + python3 -c " +import json +m = json.load(open('$SPAWN_MANIFEST')) +a = m['agents'].get('$agent', {}) +print(a.get('name', '')) +" + fi +} + +# Get agent description +manifest_agent_desc() { + local agent="$1" + if $HAS_JQ; then + jq -r --arg a "$agent" '.agents[$a].description // empty' "$SPAWN_MANIFEST" + else + python3 -c " +import json +m = json.load(open('$SPAWN_MANIFEST')) +a = m['agents'].get('$agent', {}) +print(a.get('description', '')) +" + fi +} + +# Get cloud display name +manifest_cloud_name() { + local cloud="$1" + if $HAS_JQ; then + jq -r --arg c "$cloud" '.clouds[$c].name // empty' "$SPAWN_MANIFEST" + else + python3 -c " +import json +m = json.load(open('$SPAWN_MANIFEST')) +c = m['clouds'].get('$cloud', {}) +print(c.get('name', '')) +" + fi +} + +# Get cloud description +manifest_cloud_desc() { + local cloud="$1" + if $HAS_JQ; then + jq -r --arg c "$cloud" '.clouds[$c].description // empty' "$SPAWN_MANIFEST" + else + python3 -c " +import json +m = json.load(open('$SPAWN_MANIFEST')) +c = m['clouds'].get('$cloud', {}) +print(c.get('description', '')) +" + fi +} + +# Get matrix status for cloud/agent +manifest_matrix_status() { + local cloud="$1" agent="$2" + if $HAS_JQ; then + jq -r --arg key "$cloud/$agent" '.matrix[$key] // "missing"' "$SPAWN_MANIFEST" + else + python3 -c " +import json +m = json.load(open('$SPAWN_MANIFEST')) +print(m.get('matrix', {}).get('$cloud/$agent', 'missing')) +" + fi +} + +# Count implemented entries +manifest_count_implemented() { + if $HAS_JQ; then + jq '[.matrix | to_entries[] | select(.value == "implemented")] | length' "$SPAWN_MANIFEST" + else + python3 -c " +import json +m = json.load(open('$SPAWN_MANIFEST')) +print(sum(1 for v in m.get('matrix', {}).values() if v == 'implemented')) +" + fi +} + +# ── Manifest Cache ───────────────────────────────────────────────────────────── + +file_age_seconds() { + local file="$1" + local now + now=$(date +%s) + local mtime + # macOS uses -f, Linux uses -c + if stat -f %m "$file" &>/dev/null; then + mtime=$(stat -f %m "$file") + else + mtime=$(stat -c %Y "$file" 2>/dev/null || echo 0) + fi + echo $(( now - mtime )) +} + +ensure_manifest() { + mkdir -p "$SPAWN_CACHE_DIR" + + # Check if cache exists and is fresh + if [[ -f "$SPAWN_MANIFEST" ]]; then + local age + age=$(file_age_seconds "$SPAWN_MANIFEST") + if (( age < SPAWN_CACHE_TTL )); then + return 0 + fi + fi + + log_info "Fetching manifest..." + local tmp + tmp=$(mktemp) + if curl -fsSL "$SPAWN_RAW_BASE/manifest.json" -o "$tmp" 2>/dev/null; then + if json_validate "$tmp"; then + mv "$tmp" "$SPAWN_MANIFEST" + return 0 + else + rm -f "$tmp" + log_warn "Downloaded manifest is invalid JSON" + fi + else + rm -f "$tmp" + log_warn "Failed to fetch manifest" + fi + + # Offline fallback: use stale cache if available + if [[ -f "$SPAWN_MANIFEST" ]]; then + log_warn "Using cached manifest (offline fallback)" + return 0 + fi + + log_error "No manifest available. Check your internet connection." + exit 1 +} + +# ── Interactive Picker ───────────────────────────────────────────────────────── + +picker() { + local prompt="$1" + shift + local -a items=("$@") + local count=${#items[@]} + + echo "" >&2 + echo -e "${BOLD}${prompt}${NC}" >&2 + echo "" >&2 + + local i + for (( i = 0; i < count; i++ )); do + printf " %s%2d)%s %s\n" "${GREEN}" $(( i + 1 )) "${NC}" "${items[$i]}" >&2 + done + + echo "" >&2 + local choice + while true; do + printf " Enter number (1-%d): " "$count" >&2 + read -r choice = 1 && choice <= count )); then + echo $(( choice - 1 )) + return 0 + fi + log_warn "Invalid choice. Enter a number between 1 and $count." + done +} + +# ── Commands ─────────────────────────────────────────────────────────────────── + +cmd_interactive() { + ensure_manifest + + # Build agent list with descriptions + local -a agent_keys=() + local -a agent_labels=() + while IFS= read -r key; do + agent_keys+=("$key") + local name desc + name=$(manifest_agent_name "$key") + desc=$(manifest_agent_desc "$key") + agent_labels+=("${name} ${DIM}- ${desc}${NC}") + done < <(manifest_agents) + + if (( ${#agent_keys[@]} == 0 )); then + log_error "No agents found in manifest" + exit 1 + fi + + local agent_idx + agent_idx=$(picker "Select an agent:" "${agent_labels[@]}") + local agent="${agent_keys[$agent_idx]}" + + # Build cloud list — only show implemented clouds for this agent + local -a cloud_keys=() + local -a cloud_labels=() + while IFS= read -r key; do + local status + status=$(manifest_matrix_status "$key" "$agent") + if [[ "$status" == "implemented" ]]; then + cloud_keys+=("$key") + local name desc + name=$(manifest_cloud_name "$key") + desc=$(manifest_cloud_desc "$key") + cloud_labels+=("${name} ${DIM}- ${desc}${NC}") + fi + done < <(manifest_clouds) + + if (( ${#cloud_keys[@]} == 0 )); then + local aname + aname=$(manifest_agent_name "$agent") + log_error "No implemented clouds found for $aname" + exit 1 + fi + + local cloud_idx + cloud_idx=$(picker "Select a cloud provider:" "${cloud_labels[@]}") + local cloud="${cloud_keys[$cloud_idx]}" + + cmd_run "$agent" "$cloud" +} + +cmd_run() { + local agent="$1" cloud="$2" + ensure_manifest + + local agent_name cloud_name status + agent_name=$(manifest_agent_name "$agent") + cloud_name=$(manifest_cloud_name "$cloud") + + if [[ -z "$agent_name" ]]; then + log_error "Unknown agent: $agent" + echo "Run 'spawn agents' to see available agents." >&2 + exit 1 + fi + if [[ -z "$cloud_name" ]]; then + log_error "Unknown cloud: $cloud" + echo "Run 'spawn clouds' to see available clouds." >&2 + exit 1 + fi + + status=$(manifest_matrix_status "$cloud" "$agent") + if [[ "$status" != "implemented" ]]; then + log_error "$agent_name on $cloud_name is not yet implemented" + exit 1 + fi + + log_info "Launching ${BOLD}${agent_name}${NC}${GREEN} on ${BOLD}${cloud_name}${NC}${GREEN}...${NC}" + local url="https://openrouter.ai/lab/spawn/${cloud}/${agent}.sh" + bash <(curl -fsSL "$url") +} + +cmd_list() { + ensure_manifest + + if $HAS_PYTHON3; then + cmd_list_python + elif $HAS_JQ; then + cmd_list_jq + fi +} + +cmd_list_jq() { + jq -r ' + def pad($n): . + (" " * ($n - length)) | .[:$n]; + def status_icon: if . == "implemented" then " ✓" else " -" end; + + . as $root | + ($root.agents | keys) as $agents | + ($root.clouds | keys) as $clouds | + + (("" | pad(18)) + ($clouds | map(. as $c | $root.clouds[$c].name | pad(14)) | join(""))), + ($agents[] as $agent | + ($root.agents[$agent].name | pad(18)) + + ($clouds | map(. as $cloud | + ($root.matrix["\($cloud)/\($agent)"] // "missing" | status_icon | pad(14)) + ) | join("")) + ), + "", + ([$root.matrix | to_entries[] | select(.value == "implemented")] | length | tostring) + + "/" + + (($agents | length) * ($clouds | length) | tostring) + + " implemented" + ' "$SPAWN_MANIFEST" +} + +cmd_list_python() { + python3 -c " +import json + +m = json.load(open('$SPAWN_MANIFEST')) +agents = list(m['agents'].keys()) +clouds = list(m['clouds'].keys()) +matrix = m.get('matrix', {}) + +G = '\033[0;32m' +R = '\033[0;31m' +D = '\033[2m' +B = '\033[1m' +NC = '\033[0m' + +# Header +header = f'{\"\":18s}' +for c in clouds: + header += f'{m[\"clouds\"][c][\"name\"]:14s}' +print(B + header + NC) + +# Rows +for a in agents: + row = f'{m[\"agents\"][a][\"name\"]:18s}' + for c in clouds: + key = f'{c}/{a}' + status = matrix.get(key, 'missing') + if status == 'implemented': + row += G + f'{\" ✓\":14s}' + NC + else: + row += D + f'{\" -\":14s}' + NC + print(row) + +# Summary +impl = sum(1 for v in matrix.values() if v == 'implemented') +total = len(agents) * len(clouds) +print(f'\n{impl}/{total} implemented') +" +} + +cmd_agents() { + ensure_manifest + + echo "" + echo -e "${BOLD}Agents${NC}" + echo "" + + while IFS= read -r key; do + local name desc + name=$(manifest_agent_name "$key") + desc=$(manifest_agent_desc "$key") + printf " ${GREEN}%-16s${NC} %s\n" "$name" "$desc" + done < <(manifest_agents) + echo "" +} + +cmd_clouds() { + ensure_manifest + + echo "" + echo -e "${BOLD}Cloud Providers${NC}" + echo "" + + while IFS= read -r key; do + local name desc + name=$(manifest_cloud_name "$key") + desc=$(manifest_cloud_desc "$key") + printf " ${GREEN}%-16s${NC} %s\n" "$name" "$desc" + done < <(manifest_clouds) + echo "" +} + +cmd_agent_info() { + local agent="$1" + ensure_manifest + + local agent_name agent_desc + agent_name=$(manifest_agent_name "$agent") + agent_desc=$(manifest_agent_desc "$agent") + + if [[ -z "$agent_name" ]]; then + log_error "Unknown agent: $agent" + echo "Run 'spawn agents' to see available agents." >&2 + exit 1 + fi + + echo "" + echo -e "${BOLD}${agent_name}${NC} — ${agent_desc}" + echo "" + echo -e "${BOLD}Available clouds:${NC}" + echo "" + + local found=false + while IFS= read -r cloud; do + local status + status=$(manifest_matrix_status "$cloud" "$agent") + if [[ "$status" == "implemented" ]]; then + local cloud_name + cloud_name=$(manifest_cloud_name "$cloud") + printf " ${GREEN}%-16s${NC} spawn %s %s\n" "$cloud_name" "$agent" "$cloud" + found=true + fi + done < <(manifest_clouds) + + if ! $found; then + echo " No implemented clouds yet." + fi + echo "" +} + +cmd_improve() { + shift # remove 'improve' from args + local repo_dir + + # Check if we're already in the spawn repo + if [[ -f "./improve.sh" && -f "./manifest.json" ]]; then + repo_dir="." + else + repo_dir="$SPAWN_CACHE_DIR/repo" + if [[ -d "$repo_dir/.git" ]]; then + log_info "Updating spawn repo..." + git -C "$repo_dir" pull --ff-only 2>/dev/null || true + else + log_info "Cloning spawn repo..." + git clone "https://github.com/$SPAWN_REPO.git" "$repo_dir" + fi + fi + + (cd "$repo_dir" && bash improve.sh "$@") +} + +cmd_update() { + local self + self=$(command -v spawn 2>/dev/null || echo "") + if [[ -z "$self" ]]; then + # Try common install locations + if [[ -f "$HOME/.local/bin/spawn" ]]; then + self="$HOME/.local/bin/spawn" + else + log_error "Cannot find spawn binary for self-update" + exit 1 + fi + fi + + log_info "Checking for updates..." + local tmp + tmp=$(mktemp) + if curl -fsSL "$SPAWN_RAW_BASE/cli/spawn.sh" -o "$tmp" 2>/dev/null; then + local remote_version + remote_version=$(grep '^SPAWN_VERSION=' "$tmp" | head -1 | cut -d'"' -f2) + if [[ -z "$remote_version" ]]; then + rm -f "$tmp" + log_error "Could not determine remote version" + exit 1 + fi + + if [[ "$remote_version" == "$SPAWN_VERSION" ]]; then + rm -f "$tmp" + log_info "Already up to date (v${SPAWN_VERSION})" + return 0 + fi + + chmod +x "$tmp" + mv "$tmp" "$self" + log_info "Updated: v${SPAWN_VERSION} → v${remote_version}" + + # Invalidate manifest cache on update + rm -f "$SPAWN_MANIFEST" + else + rm -f "$tmp" + log_error "Failed to download update" + exit 1 + fi +} + +cmd_help() { + echo -e " +${BOLD}spawn${NC} — Launch any AI coding agent on any cloud + +${BOLD}USAGE${NC} + spawn Interactive agent + cloud picker + spawn Launch agent on cloud directly + spawn Show available clouds for agent + spawn list Full matrix table + spawn agents List all agents with descriptions + spawn clouds List all cloud providers + spawn improve [--loop] Run improvement system (wraps improve.sh) + spawn update Self-update from GitHub + spawn version Show version + spawn help Show this help + +${BOLD}EXAMPLES${NC} + spawn Pick interactively + spawn claude sprite Launch Claude Code on Sprite + spawn aider hetzner Launch Aider on Hetzner Cloud + spawn claude Show which clouds support Claude Code + spawn list See the full agent x cloud matrix + +${BOLD}INSTALL${NC} + curl -fsSL $SPAWN_RAW_BASE/cli/install.sh | bash +" +} + +# ── Main ─────────────────────────────────────────────────────────────────────── + +main() { + check_deps + + if (( $# == 0 )); then + cmd_interactive + return + fi + + case "$1" in + help|--help|-h) + cmd_help + ;; + version|--version|-v|-V) + echo "spawn v${SPAWN_VERSION}" + ;; + list|ls) + cmd_list + ;; + agents) + cmd_agents + ;; + clouds) + cmd_clouds + ;; + improve) + cmd_improve "$@" + ;; + update) + cmd_update + ;; + *) + # Could be: spawn or spawn + local agent="$1" + ensure_manifest + + # Check if it's a valid agent + local agent_name + agent_name=$(manifest_agent_name "$agent") + if [[ -z "$agent_name" ]]; then + log_error "Unknown command or agent: $agent" + echo "Run 'spawn help' for usage." >&2 + exit 1 + fi + + if (( $# >= 2 )); then + cmd_run "$agent" "$2" + else + cmd_agent_info "$agent" + fi + ;; + esac +} + +main "$@" diff --git a/digitalocean/lib/common.sh b/digitalocean/lib/common.sh index f017ea01..7ff2bb04 100755 --- a/digitalocean/lib/common.sh +++ b/digitalocean/lib/common.sh @@ -45,7 +45,7 @@ safe_read() { nc_listen() { local port=$1 shift - if nc --help 2>&1 | grep -q "BusyBox\|busybox" || nc --help 2>&1 | grep -q "\-p "; then + if nc --help 2>&1 | grep -q "BusyBox\|busybox"; then nc -l -p "$port" "$@" else nc -l "$port" "$@" @@ -113,26 +113,21 @@ try_oauth_flow() { log_warn "Starting local OAuth server on port ${callback_port}..." + # Write the HTTP response to a file (using printf for macOS bash 3.x compat) + local response_tpl="$oauth_dir/response.http" + printf 'HTTP/1.1 200 OK\r\nContent-Type: text/html\r\nConnection: close\r\n\r\n

Authentication Successful!

Redirecting back to terminal...

This tab will close automatically

' > "$response_tpl" + + # Background listener ( - local success_response='HTTP/1.1 200 OK\r\nContent-Type: text/html\r\nConnection: close\r\n\r\n

Authentication Successful!

Redirecting back to terminal...

This tab will close automatically

' - while true; do - local response_file=$(mktemp) - echo -e "$success_response" > "$response_file" - - local request=$(nc_listen "$callback_port" < "$response_file" 2>/dev/null | head -1) - local nc_status=$? - rm -f "$response_file" - - if [[ $nc_status -ne 0 ]]; then - break - fi - - if [[ "$request" == *"/callback?code="* ]]; then - local code=$(echo "$request" | sed -n 's/.*code=\([^ &]*\).*/\1/p') - echo "$code" > "$code_file" - break - fi + request=$(nc_listen "$callback_port" < "$response_tpl" 2>/dev/null | head -1) || break + + case "$request" in + *"/callback?code="*) + echo "$request" | sed -n 's/.*code=\([^ &]*\).*/\1/p' > "$code_file" + break + ;; + esac done ) /dev/null || true @@ -452,7 +447,7 @@ for net in data['droplet']['networks']['v4']: log_warn "Droplet status: $status ($attempt/$max_attempts)" sleep 5 - ((attempt++)) + attempt=$((attempt + 1)) done log_error "Droplet did not become active in time" @@ -473,7 +468,7 @@ verify_server_connectivity() { fi log_warn "Waiting for SSH... ($attempt/$max_attempts)" sleep 5 - ((attempt++)) + attempt=$((attempt + 1)) done log_error "Server failed to respond via SSH after $max_attempts attempts" @@ -494,7 +489,7 @@ wait_for_cloud_init() { fi log_warn "Cloud-init in progress... ($attempt/$max_attempts)" sleep 5 - ((attempt++)) + attempt=$((attempt + 1)) done log_error "Cloud-init did not complete after $max_attempts attempts" diff --git a/e2b/lib/common.sh b/e2b/lib/common.sh index ee628c1e..db13a388 100644 --- a/e2b/lib/common.sh +++ b/e2b/lib/common.sh @@ -27,7 +27,7 @@ safe_read() { nc_listen() { local port=$1; shift - if nc --help 2>&1 | grep -q "BusyBox\|busybox" || nc --help 2>&1 | grep -q "\-p "; then + if nc --help 2>&1 | grep -q "BusyBox\|busybox"; then nc -l -p "$port" "$@" else nc -l "$port" "$@"; fi } @@ -64,23 +64,29 @@ try_oauth_flow() { local auth_url="https://openrouter.ai/auth?callback_url=${callback_url}" local oauth_dir=$(mktemp -d) code_file="$oauth_dir/code" log_warn "Starting local OAuth server on port ${callback_port}..." + + # Write the HTTP response to a file (using printf for macOS bash 3.x compat) + local response_tpl="$oauth_dir/response.http" + printf 'HTTP/1.1 200 OK\r\nContent-Type: text/html\r\nConnection: close\r\n\r\n

Authentication Successful!

You can close this tab

' > "$response_tpl" + + # Background listener ( - local success_response='HTTP/1.1 200 OK\r\nContent-Type: text/html\r\nConnection: close\r\n\r\n

Authentication Successful!

You can close this tab

' while true; do - local response_file=$(mktemp); echo -e "$success_response" > "$response_file" - local request=$(nc_listen "$callback_port" < "$response_file" 2>/dev/null | head -1) - local nc_status=$?; rm -f "$response_file" - if [[ $nc_status -ne 0 ]]; then break; fi - if [[ "$request" == *"/callback?code="* ]]; then - echo "$request" | sed -n 's/.*code=\([^ &]*\).*/\1/p' > "$code_file"; break - fi + request=$(nc_listen "$callback_port" < "$response_tpl" 2>/dev/null | head -1) || break + + case "$request" in + *"/callback?code="*) + echo "$request" | sed -n 's/.*code=\([^ &]*\).*/\1/p' > "$code_file" + break + ;; + esac done ) /dev/null; then log_warn "Failed to start OAuth server"; rm -rf "$oauth_dir"; return 1; fi log_warn "Opening browser to authenticate with OpenRouter..."; open_browser "$auth_url" local timeout=120 elapsed=0 - while [[ ! -f "$code_file" ]] && [[ $elapsed -lt $timeout ]]; do sleep 1; ((elapsed++)); done + while [[ ! -f "$code_file" ]] && [[ $elapsed -lt $timeout ]]; do sleep 1; elapsed=$((elapsed + 1)); done kill $server_pid 2>/dev/null || true; wait $server_pid 2>/dev/null || true if [[ ! -f "$code_file" ]]; then log_warn "OAuth timeout"; rm -rf "$oauth_dir"; return 1; fi local oauth_code=$(cat "$code_file"); rm -rf "$oauth_dir" diff --git a/gcp/lib/common.sh b/gcp/lib/common.sh index dbc6e821..2fb7afa7 100644 --- a/gcp/lib/common.sh +++ b/gcp/lib/common.sh @@ -25,7 +25,7 @@ safe_read() { nc_listen() { local port=$1; shift - if nc --help 2>&1 | grep -q "BusyBox\|busybox" || nc --help 2>&1 | grep -q "\-p "; then + if nc --help 2>&1 | grep -q "BusyBox\|busybox"; then nc -l -p "$port" "$@" else nc -l "$port" "$@"; fi } @@ -62,23 +62,29 @@ try_oauth_flow() { local auth_url="https://openrouter.ai/auth?callback_url=${callback_url}" local oauth_dir=$(mktemp -d) code_file="$oauth_dir/code" log_warn "Starting local OAuth server on port ${callback_port}..." + + # Write the HTTP response to a file (using printf for macOS bash 3.x compat) + local response_tpl="$oauth_dir/response.http" + printf 'HTTP/1.1 200 OK\r\nContent-Type: text/html\r\nConnection: close\r\n\r\n

Authentication Successful!

You can close this tab

' > "$response_tpl" + + # Background listener ( - local success_response='HTTP/1.1 200 OK\r\nContent-Type: text/html\r\nConnection: close\r\n\r\n

Authentication Successful!

You can close this tab

' while true; do - local response_file=$(mktemp); echo -e "$success_response" > "$response_file" - local request=$(nc_listen "$callback_port" < "$response_file" 2>/dev/null | head -1) - local nc_status=$?; rm -f "$response_file" - if [[ $nc_status -ne 0 ]]; then break; fi - if [[ "$request" == *"/callback?code="* ]]; then - echo "$request" | sed -n 's/.*code=\([^ &]*\).*/\1/p' > "$code_file"; break - fi + request=$(nc_listen "$callback_port" < "$response_tpl" 2>/dev/null | head -1) || break + + case "$request" in + *"/callback?code="*) + echo "$request" | sed -n 's/.*code=\([^ &]*\).*/\1/p' > "$code_file" + break + ;; + esac done ) /dev/null; then log_warn "Failed to start OAuth server"; rm -rf "$oauth_dir"; return 1; fi log_warn "Opening browser to authenticate with OpenRouter..."; open_browser "$auth_url" local timeout=120 elapsed=0 - while [[ ! -f "$code_file" ]] && [[ $elapsed -lt $timeout ]]; do sleep 1; ((elapsed++)); done + while [[ ! -f "$code_file" ]] && [[ $elapsed -lt $timeout ]]; do sleep 1; elapsed=$((elapsed + 1)); done kill $server_pid 2>/dev/null || true; wait $server_pid 2>/dev/null || true if [[ ! -f "$code_file" ]]; then log_warn "OAuth timeout"; rm -rf "$oauth_dir"; return 1; fi local oauth_code=$(cat "$code_file"); rm -rf "$oauth_dir" @@ -222,7 +228,7 @@ verify_server_connectivity() { if ssh $SSH_OPTS -o ConnectTimeout=5 "${username}@$ip" "echo ok" >/dev/null 2>&1; then log_info "SSH connection established"; return 0 fi - log_warn "Waiting for SSH... ($attempt/$max_attempts)"; sleep 5; ((attempt++)) + log_warn "Waiting for SSH... ($attempt/$max_attempts)"; sleep 5; attempt=$((attempt + 1)) done log_error "Server failed to respond via SSH after $max_attempts attempts"; return 1 } @@ -235,7 +241,7 @@ wait_for_cloud_init() { if ssh $SSH_OPTS "${username}@$ip" "test -f /tmp/.cloud-init-complete" >/dev/null 2>&1; then log_info "Startup script completed"; return 0 fi - log_warn "Startup script in progress... ($attempt/$max_attempts)"; sleep 5; ((attempt++)) + log_warn "Startup script in progress... ($attempt/$max_attempts)"; sleep 5; attempt=$((attempt + 1)) done log_error "Startup script did not complete after $max_attempts attempts"; return 1 } diff --git a/hetzner/lib/common.sh b/hetzner/lib/common.sh index 57e93bce..6f82bb68 100755 --- a/hetzner/lib/common.sh +++ b/hetzner/lib/common.sh @@ -49,7 +49,7 @@ nc_listen() { local port=$1 shift # Detect if nc requires -p flag (busybox nc on Termux) - if nc --help 2>&1 | grep -q "BusyBox\|busybox" || nc --help 2>&1 | grep -q "\-p "; then + if nc --help 2>&1 | grep -q "BusyBox\|busybox"; then nc -l -p "$port" "$@" else nc -l "$port" "$@" @@ -120,29 +120,21 @@ try_oauth_flow() { log_warn "Starting local OAuth server on port ${callback_port}..." - # Use a simpler nc approach - pipe response while capturing request + # Write the HTTP response to a file (using printf for macOS bash 3.x compat) + local response_tpl="$oauth_dir/response.http" + printf 'HTTP/1.1 200 OK\r\nContent-Type: text/html\r\nConnection: close\r\n\r\n

Authentication Successful!

Redirecting back to terminal...

This tab will close automatically

' > "$response_tpl" + + # Background listener ( - local success_response='HTTP/1.1 200 OK\r\nContent-Type: text/html\r\nConnection: close\r\n\r\n

Authentication Successful!

Redirecting back to terminal...

This tab will close automatically

' - while true; do - # Listen and capture just the first line of the request, then respond - local response_file=$(mktemp) - echo -e "$success_response" > "$response_file" - - local request=$(nc_listen "$callback_port" < "$response_file" 2>/dev/null | head -1) - local nc_status=$? - rm -f "$response_file" - - # If nc failed, exit the loop - if [[ $nc_status -ne 0 ]]; then - break - fi - - if [[ "$request" == *"/callback?code="* ]]; then - local code=$(echo "$request" | sed -n 's/.*code=\([^ &]*\).*/\1/p') - echo "$code" > "$code_file" - break - fi + request=$(nc_listen "$callback_port" < "$response_tpl" 2>/dev/null | head -1) || break + + case "$request" in + *"/callback?code="*) + echo "$request" | sed -n 's/.*code=\([^ &]*\).*/\1/p' > "$code_file" + break + ;; + esac done ) &1 | grep -q "BusyBox\|busybox" || nc --help 2>&1 | grep -q "\-p "; then + if nc --help 2>&1 | grep -q "BusyBox\|busybox"; then nc -l -p "$port" "$@" else nc -l "$port" "$@"; fi } @@ -62,23 +62,29 @@ try_oauth_flow() { local auth_url="https://openrouter.ai/auth?callback_url=${callback_url}" local oauth_dir=$(mktemp -d) code_file="$oauth_dir/code" log_warn "Starting local OAuth server on port ${callback_port}..." + + # Write the HTTP response to a file (using printf for macOS bash 3.x compat) + local response_tpl="$oauth_dir/response.http" + printf 'HTTP/1.1 200 OK\r\nContent-Type: text/html\r\nConnection: close\r\n\r\n

Authentication Successful!

You can close this tab

' > "$response_tpl" + + # Background listener ( - local success_response='HTTP/1.1 200 OK\r\nContent-Type: text/html\r\nConnection: close\r\n\r\n

Authentication Successful!

You can close this tab

' while true; do - local response_file=$(mktemp); echo -e "$success_response" > "$response_file" - local request=$(nc_listen "$callback_port" < "$response_file" 2>/dev/null | head -1) - local nc_status=$?; rm -f "$response_file" - if [[ $nc_status -ne 0 ]]; then break; fi - if [[ "$request" == *"/callback?code="* ]]; then - echo "$request" | sed -n 's/.*code=\([^ &]*\).*/\1/p' > "$code_file"; break - fi + request=$(nc_listen "$callback_port" < "$response_tpl" 2>/dev/null | head -1) || break + + case "$request" in + *"/callback?code="*) + echo "$request" | sed -n 's/.*code=\([^ &]*\).*/\1/p' > "$code_file" + break + ;; + esac done ) /dev/null; then log_warn "Failed to start OAuth server"; rm -rf "$oauth_dir"; return 1; fi log_warn "Opening browser to authenticate with OpenRouter..."; open_browser "$auth_url" local timeout=120 elapsed=0 - while [[ ! -f "$code_file" ]] && [[ $elapsed -lt $timeout ]]; do sleep 1; ((elapsed++)); done + while [[ ! -f "$code_file" ]] && [[ $elapsed -lt $timeout ]]; do sleep 1; elapsed=$((elapsed + 1)); done kill $server_pid 2>/dev/null || true; wait $server_pid 2>/dev/null || true if [[ ! -f "$code_file" ]]; then log_warn "OAuth timeout"; rm -rf "$oauth_dir"; return 1; fi local oauth_code=$(cat "$code_file"); rm -rf "$oauth_dir" @@ -245,7 +251,7 @@ print(d.get('error', {}).get('message', d.get('error', 'Unknown error'))) return 0 fi log_warn "Instance status: $status ($attempt/$max_attempts)" - sleep 10; ((attempt++)) + sleep 10; attempt=$((attempt + 1)) done log_error "Instance did not become active in time"; return 1 } @@ -257,7 +263,7 @@ verify_server_connectivity() { if ssh $SSH_OPTS -o ConnectTimeout=5 "ubuntu@$ip" "echo ok" >/dev/null 2>&1; then log_info "SSH connection established"; return 0 fi - log_warn "Waiting for SSH... ($attempt/$max_attempts)"; sleep 5; ((attempt++)) + log_warn "Waiting for SSH... ($attempt/$max_attempts)"; sleep 5; attempt=$((attempt + 1)) done log_error "Server failed to respond via SSH after $max_attempts attempts"; return 1 } diff --git a/linode/lib/common.sh b/linode/lib/common.sh index ee06ebcf..f765b485 100644 --- a/linode/lib/common.sh +++ b/linode/lib/common.sh @@ -24,7 +24,7 @@ safe_read() { nc_listen() { local port=$1; shift - if nc --help 2>&1 | grep -q "BusyBox\|busybox" || nc --help 2>&1 | grep -q "\-p "; then + if nc --help 2>&1 | grep -q "BusyBox\|busybox"; then nc -l -p "$port" "$@" else nc -l "$port" "$@"; fi } @@ -61,23 +61,29 @@ try_oauth_flow() { local auth_url="https://openrouter.ai/auth?callback_url=${callback_url}" local oauth_dir=$(mktemp -d) code_file="$oauth_dir/code" log_warn "Starting local OAuth server on port ${callback_port}..." + + # Write the HTTP response to a file (using printf for macOS bash 3.x compat) + local response_tpl="$oauth_dir/response.http" + printf 'HTTP/1.1 200 OK\r\nContent-Type: text/html\r\nConnection: close\r\n\r\n

Authentication Successful!

You can close this tab

' > "$response_tpl" + + # Background listener ( - local success_response='HTTP/1.1 200 OK\r\nContent-Type: text/html\r\nConnection: close\r\n\r\n

Authentication Successful!

You can close this tab

' while true; do - local response_file=$(mktemp); echo -e "$success_response" > "$response_file" - local request=$(nc_listen "$callback_port" < "$response_file" 2>/dev/null | head -1) - local nc_status=$?; rm -f "$response_file" - if [[ $nc_status -ne 0 ]]; then break; fi - if [[ "$request" == *"/callback?code="* ]]; then - echo "$request" | sed -n 's/.*code=\([^ &]*\).*/\1/p' > "$code_file"; break - fi + request=$(nc_listen "$callback_port" < "$response_tpl" 2>/dev/null | head -1) || break + + case "$request" in + *"/callback?code="*) + echo "$request" | sed -n 's/.*code=\([^ &]*\).*/\1/p' > "$code_file" + break + ;; + esac done ) /dev/null; then log_warn "Failed to start OAuth server"; rm -rf "$oauth_dir"; return 1; fi log_warn "Opening browser to authenticate with OpenRouter..."; open_browser "$auth_url" local timeout=120 elapsed=0 - while [[ ! -f "$code_file" ]] && [[ $elapsed -lt $timeout ]]; do sleep 1; ((elapsed++)); done + while [[ ! -f "$code_file" ]] && [[ $elapsed -lt $timeout ]]; do sleep 1; elapsed=$((elapsed + 1)); done kill $server_pid 2>/dev/null || true; wait $server_pid 2>/dev/null || true if [[ ! -f "$code_file" ]]; then log_warn "OAuth timeout"; rm -rf "$oauth_dir"; return 1; fi local oauth_code=$(cat "$code_file"); rm -rf "$oauth_dir" @@ -276,7 +282,7 @@ print('; '.join(e.get('reason','Unknown') for e in errs) if errs else 'Unknown e return 0 fi log_warn "Linode status: $status ($attempt/$max_attempts)" - sleep 5; ((attempt++)) + sleep 5; attempt=$((attempt + 1)) done log_error "Linode did not become active in time"; return 1 } @@ -288,7 +294,7 @@ verify_server_connectivity() { if ssh $SSH_OPTS -o ConnectTimeout=5 "root@$ip" "echo ok" >/dev/null 2>&1; then log_info "SSH connection established"; return 0 fi - log_warn "Waiting for SSH... ($attempt/$max_attempts)"; sleep 5; ((attempt++)) + log_warn "Waiting for SSH... ($attempt/$max_attempts)"; sleep 5; attempt=$((attempt + 1)) done log_error "Server failed to respond via SSH after $max_attempts attempts"; return 1 } @@ -300,7 +306,7 @@ wait_for_cloud_init() { if ssh $SSH_OPTS "root@$ip" "test -f /root/.cloud-init-complete" >/dev/null 2>&1; then log_info "Cloud-init completed"; return 0 fi - log_warn "Cloud-init in progress... ($attempt/$max_attempts)"; sleep 5; ((attempt++)) + log_warn "Cloud-init in progress... ($attempt/$max_attempts)"; sleep 5; attempt=$((attempt + 1)) done log_error "Cloud-init did not complete after $max_attempts attempts"; return 1 } diff --git a/modal/lib/common.sh b/modal/lib/common.sh index a2f7987b..a2d59108 100644 --- a/modal/lib/common.sh +++ b/modal/lib/common.sh @@ -27,7 +27,7 @@ safe_read() { nc_listen() { local port=$1; shift - if nc --help 2>&1 | grep -q "BusyBox\|busybox" || nc --help 2>&1 | grep -q "\-p "; then + if nc --help 2>&1 | grep -q "BusyBox\|busybox"; then nc -l -p "$port" "$@" else nc -l "$port" "$@"; fi } @@ -64,23 +64,29 @@ try_oauth_flow() { local auth_url="https://openrouter.ai/auth?callback_url=${callback_url}" local oauth_dir=$(mktemp -d) code_file="$oauth_dir/code" log_warn "Starting local OAuth server on port ${callback_port}..." + + # Write the HTTP response to a file (using printf for macOS bash 3.x compat) + local response_tpl="$oauth_dir/response.http" + printf 'HTTP/1.1 200 OK\r\nContent-Type: text/html\r\nConnection: close\r\n\r\n

Authentication Successful!

You can close this tab

' > "$response_tpl" + + # Background listener ( - local success_response='HTTP/1.1 200 OK\r\nContent-Type: text/html\r\nConnection: close\r\n\r\n

Authentication Successful!

You can close this tab

' while true; do - local response_file=$(mktemp); echo -e "$success_response" > "$response_file" - local request=$(nc_listen "$callback_port" < "$response_file" 2>/dev/null | head -1) - local nc_status=$?; rm -f "$response_file" - if [[ $nc_status -ne 0 ]]; then break; fi - if [[ "$request" == *"/callback?code="* ]]; then - echo "$request" | sed -n 's/.*code=\([^ &]*\).*/\1/p' > "$code_file"; break - fi + request=$(nc_listen "$callback_port" < "$response_tpl" 2>/dev/null | head -1) || break + + case "$request" in + *"/callback?code="*) + echo "$request" | sed -n 's/.*code=\([^ &]*\).*/\1/p' > "$code_file" + break + ;; + esac done ) /dev/null; then log_warn "Failed to start OAuth server"; rm -rf "$oauth_dir"; return 1; fi log_warn "Opening browser to authenticate with OpenRouter..."; open_browser "$auth_url" local timeout=120 elapsed=0 - while [[ ! -f "$code_file" ]] && [[ $elapsed -lt $timeout ]]; do sleep 1; ((elapsed++)); done + while [[ ! -f "$code_file" ]] && [[ $elapsed -lt $timeout ]]; do sleep 1; elapsed=$((elapsed + 1)); done kill $server_pid 2>/dev/null || true; wait $server_pid 2>/dev/null || true if [[ ! -f "$code_file" ]]; then log_warn "OAuth timeout"; rm -rf "$oauth_dir"; return 1; fi local oauth_code=$(cat "$code_file"); rm -rf "$oauth_dir" diff --git a/sprite/lib/common.sh b/sprite/lib/common.sh index 9cbe8282..dc3ff48e 100644 --- a/sprite/lib/common.sh +++ b/sprite/lib/common.sh @@ -149,14 +149,15 @@ EOF rm "$bash_temp" } -# Listen on a port with netcat (handles busybox/Termux nc requiring -p flag) +# Listen on a port with netcat (handles macOS, Linux, busybox/Termux) nc_listen() { local port=$1 shift - # Detect if nc requires -p flag (busybox nc on Termux) - if nc --help 2>&1 | grep -q "BusyBox\|busybox" || nc --help 2>&1 | grep -q "\-p "; then + # BusyBox nc (Termux) requires -p flag + if nc --help 2>&1 | grep -q "BusyBox\|busybox"; then nc -l -p "$port" "$@" else + # macOS and Linux GNU nc both accept: nc -l nc -l "$port" "$@" fi } @@ -211,29 +212,21 @@ try_oauth_flow() { log_warn "Starting local OAuth server on port ${callback_port}..." - # Use a simpler nc approach - pipe response while capturing request + # Write the HTTP response to a file (using printf for macOS bash 3.x compat) + local response_tpl="$oauth_dir/response.http" + printf 'HTTP/1.1 200 OK\r\nContent-Type: text/html\r\nConnection: close\r\n\r\n

Authentication Successful!

You can close this tab

' > "$response_tpl" + + # Background listener ( - local success_response='HTTP/1.1 200 OK\r\nContent-Type: text/html\r\nConnection: close\r\n\r\n

Authentication Successful!

Redirecting back to terminal...

This tab will close automatically

' - while true; do - # Listen and capture just the first line of the request, then respond - local response_file=$(mktemp) - echo -e "$success_response" > "$response_file" + request=$(nc_listen "$callback_port" < "$response_tpl" 2>/dev/null | head -1) || break - local request=$(nc_listen "$callback_port" < "$response_file" 2>/dev/null | head -1) - local nc_status=$? - rm -f "$response_file" - - # If nc failed, exit the loop - if [[ $nc_status -ne 0 ]]; then - break - fi - - if [[ "$request" == *"/callback?code="* ]]; then - local code=$(echo "$request" | sed -n 's/.*code=\([^ &]*\).*/\1/p') - echo "$code" > "$code_file" - break - fi + case "$request" in + *"/callback?code="*) + echo "$request" | sed -n 's/.*code=\([^ &]*\).*/\1/p' > "$code_file" + break + ;; + esac done ) &1 | grep -q "BusyBox\|busybox" || nc --help 2>&1 | grep -q "\-p "; then + if nc --help 2>&1 | grep -q "BusyBox\|busybox"; then nc -l -p "$port" "$@" else nc -l "$port" "$@" @@ -77,20 +77,22 @@ try_oauth_flow() { local oauth_dir=$(mktemp -d) local code_file="$oauth_dir/code" log_warn "Starting local OAuth server on port ${callback_port}..." + + # Write the HTTP response to a file (using printf for macOS bash 3.x compat) + local response_tpl="$oauth_dir/response.http" + printf 'HTTP/1.1 200 OK\r\nContent-Type: text/html\r\nConnection: close\r\n\r\n

Authentication Successful!

Redirecting back to terminal...

This tab will close automatically

' > "$response_tpl" + + # Background listener ( - local success_response='HTTP/1.1 200 OK\r\nContent-Type: text/html\r\nConnection: close\r\n\r\n

Authentication Successful!

Redirecting back to terminal...

This tab will close automatically

' while true; do - local response_file=$(mktemp) - echo -e "$success_response" > "$response_file" - local request=$(nc_listen "$callback_port" < "$response_file" 2>/dev/null | head -1) - local nc_status=$? - rm -f "$response_file" - if [[ $nc_status -ne 0 ]]; then break; fi - if [[ "$request" == *"/callback?code="* ]]; then - local code=$(echo "$request" | sed -n 's/.*code=\([^ &]*\).*/\1/p') - echo "$code" > "$code_file" - break - fi + request=$(nc_listen "$callback_port" < "$response_tpl" 2>/dev/null | head -1) || break + + case "$request" in + *"/callback?code="*) + echo "$request" | sed -n 's/.*code=\([^ &]*\).*/\1/p' > "$code_file" + break + ;; + esac done ) /dev/null || true wait $server_pid 2>/dev/null || true if [[ ! -f "$code_file" ]]; then @@ -334,7 +336,7 @@ print(json.dumps(body)) log_warn "Instance status: $status/$power ($attempt/$max_attempts)" sleep 5 - ((attempt++)) + attempt=$((attempt + 1)) done log_error "Instance did not become active in time" @@ -353,7 +355,7 @@ verify_server_connectivity() { fi log_warn "Waiting for SSH... ($attempt/$max_attempts)" sleep 5 - ((attempt++)) + attempt=$((attempt + 1)) done log_error "Server failed to respond via SSH after $max_attempts attempts" return 1 @@ -371,7 +373,7 @@ wait_for_cloud_init() { fi log_warn "Cloud-init in progress... ($attempt/$max_attempts)" sleep 5 - ((attempt++)) + attempt=$((attempt + 1)) done log_error "Cloud-init did not complete after $max_attempts attempts" return 1