diff --git a/.claude/skills/setup-agent-team/qa-e2e-prompt.md b/.claude/skills/setup-agent-team/qa-e2e-prompt.md index b078b82bc..dcc8f10fa 100644 --- a/.claude/skills/setup-agent-team/qa-e2e-prompt.md +++ b/.claude/skills/setup-agent-team/qa-e2e-prompt.md @@ -2,7 +2,7 @@ You are a single-agent QA E2E tester for the spawn codebase. ## Mission -Run the Fly.io E2E test suite, investigate any failures, and fix broken provisioning scripts or test infrastructure. +Run the AWS E2E test suite, investigate any failures, and fix broken provisioning scripts or test infrastructure. ## Time Budget @@ -21,8 +21,8 @@ cd WORKTREE_BASE_PLACEHOLDER ```bash cd REPO_ROOT_PLACEHOLDER -chmod +x sh/e2e/fly-e2e.sh -./sh/e2e/fly-e2e.sh --parallel 6 +chmod +x sh/e2e/aws-e2e.sh +./sh/e2e/aws-e2e.sh --parallel 6 ``` Capture the full output. Note which agents passed and which failed. @@ -35,22 +35,21 @@ If every agent passes, you're done. Log the results and exit. No PR needed. For each failed agent, investigate the root cause. The failure categories are: -### Provision failure (app does not exist after provisioning) +### Provision failure (instance does not exist after provisioning) 1. Check the stderr log in the temp directory printed at the start of the run 2. Common causes: - Missing env var for headless mode (e.g., `MODEL_ID` for openclaw) - - Fly.io API auth issues + - AWS API auth issues - Agent-specific install script changed upstream -3. Read the agent's provisioning code: `packages/cli/src/fly/agents.ts` and `packages/cli/src/shared/agent-setup.ts` +3. Read the agent's provisioning code: `packages/cli/src/aws/aws.ts` and `packages/cli/src/shared/agent-setup.ts` 4. Read the E2E provision script: `sh/e2e/lib/provision.sh` -### Verification failure (app exists but checks fail) +### Verification failure (instance exists but checks fail) 1. SSH into the VM to investigate: ```bash - flyctl machines list -a APP_NAME --json | jq -r '.[0].id' - flyctl machine exec MACHINE_ID -a APP_NAME --timeout 30 "bash -c 'ls -la ~; cat ~/.spawnrc; echo ---; env'" + ssh -o StrictHostKeyChecking=no root@INSTANCE_IP "ls -la ~; cat ~/.spawnrc; echo ---; env" ``` 2. Check if the binary path changed — read the agent's install script in `packages/cli/src/shared/agent-setup.ts` 3. Check if the env var names changed — read the agent's config in `manifest.json` @@ -59,7 +58,6 @@ For each failed agent, investigate the root cause. The failure categories are: ### Timeout (provision took too long) 1. Check if `PROVISION_TIMEOUT` or `INSTALL_WAIT` need increasing -2. Check if the agent's install script has a new heavy dependency ## Step 4 — Fix @@ -69,13 +67,13 @@ Make fixes in the worktree at WORKTREE_BASE_PLACEHOLDER. Fixes may be in: - `sh/e2e/lib/verify.sh` — binary paths, config file locations, env var checks - `sh/e2e/lib/common.sh` — API helpers, constants - `sh/e2e/lib/teardown.sh` — cleanup logic -- `sh/e2e/lib/cleanup.sh` — stale app detection +- `sh/e2e/lib/cleanup.sh` — stale instance detection After fixing: 1. Run `bash -n` on every modified `.sh` file 2. Re-run the E2E suite for the failed agent(s) only to verify the fix: ```bash - ./sh/e2e/fly-e2e.sh AGENT_NAME + ./sh/e2e/aws-e2e.sh AGENT_NAME ``` ## Step 5 — Commit and PR diff --git a/.claude/skills/setup-agent-team/qa-fixtures-prompt.md b/.claude/skills/setup-agent-team/qa-fixtures-prompt.md index 1231e5448..ab9871b13 100644 --- a/.claude/skills/setup-agent-team/qa-fixtures-prompt.md +++ b/.claude/skills/setup-agent-team/qa-fixtures-prompt.md @@ -58,11 +58,6 @@ curl -s -H "Authorization: Bearer ${DO_API_TOKEN}" "https://api.digitalocean.com curl -s -H "Authorization: Bearer ${DO_API_TOKEN}" "https://api.digitalocean.com/v2/regions" ``` -### Fly.io (needs FLY_API_TOKEN) -```bash -curl -s -H "Authorization: Bearer ${FLY_API_TOKEN}" "https://api.machines.dev/v1/apps?org_slug=personal" -``` - For any other cloud directories found, read their TypeScript module in `packages/cli/src/{cloud}/` to discover the API base URL and auth pattern, then call equivalent GET-only endpoints. ## Step 4 — Save Fixtures diff --git a/.claude/skills/setup-agent-team/qa-quality-prompt.md b/.claude/skills/setup-agent-team/qa-quality-prompt.md index 7e8854710..8fb3afbee 100644 --- a/.claude/skills/setup-agent-team/qa-quality-prompt.md +++ b/.claude/skills/setup-agent-team/qa-quality-prompt.md @@ -122,29 +122,28 @@ cd REPO_ROOT_PLACEHOLDER && git worktree remove WORKTREE_BASE_PLACEHOLDER/TASK_N ### Teammate 4: e2e-tester (model=sonnet) -**Task**: Run the Fly.io E2E test suite, investigate failures, and fix broken test infrastructure. +**Task**: Run the AWS E2E test suite, investigate failures, and fix broken test infrastructure. **Protocol**: 1. Run the E2E suite from the main repo checkout (E2E tests provision live VMs — no worktree needed for the test runner itself): ```bash cd REPO_ROOT_PLACEHOLDER - chmod +x sh/e2e/fly-e2e.sh - ./sh/e2e/fly-e2e.sh --parallel 6 + chmod +x sh/e2e/aws-e2e.sh + ./sh/e2e/aws-e2e.sh --parallel 6 ``` 2. Capture the full output. Note which agents passed and which failed. 3. If all agents pass: report results and you're done. No PR needed. 4. If any agent fails, investigate the root cause. Failure categories: - **a) Provision failure** (app does not exist after provisioning): + **a) Provision failure** (instance does not exist after provisioning): - Check the stderr log in the temp directory printed at the start of the run - - Common causes: missing env var for headless mode, Fly.io API auth issues, agent install script changed upstream - - Read: `packages/cli/src/fly/agents.ts`, `packages/cli/src/shared/agent-setup.ts`, `sh/e2e/lib/provision.sh` + - Common causes: missing env var for headless mode, AWS API auth issues, agent install script changed upstream + - Read: `packages/cli/src/aws/aws.ts`, `packages/cli/src/shared/agent-setup.ts`, `sh/e2e/lib/provision.sh` - **b) Verification failure** (app exists but checks fail): + **b) Verification failure** (instance exists but checks fail): - SSH into the VM to investigate: ```bash - flyctl machines list -a APP_NAME --json | jq -r '.[0].id' - flyctl machine exec MACHINE_ID -a APP_NAME --timeout 30 "bash -c 'ls -la ~; cat ~/.spawnrc; echo ---; env'" + ssh -o StrictHostKeyChecking=no root@INSTANCE_IP "ls -la ~; cat ~/.spawnrc; echo ---; env" ``` - Check if binary paths or env var names changed in `manifest.json` or `packages/cli/src/shared/agent-setup.ts` - Update verification checks in `sh/e2e/lib/verify.sh` if stale @@ -161,9 +160,9 @@ cd REPO_ROOT_PLACEHOLDER && git worktree remove WORKTREE_BASE_PLACEHOLDER/TASK_N - `sh/e2e/lib/verify.sh` — binary paths, config file locations, env var checks - `sh/e2e/lib/common.sh` — API helpers, constants - `sh/e2e/lib/teardown.sh` — cleanup logic - - `sh/e2e/lib/cleanup.sh` — stale app detection + - `sh/e2e/lib/cleanup.sh` — stale instance detection 7. Run `bash -n` on every modified `.sh` file -8. Re-run the E2E suite for the failed agent(s) only: `./sh/e2e/fly-e2e.sh AGENT_NAME` +8. Re-run the E2E suite for the failed agent(s) only: `./sh/e2e/aws-e2e.sh AGENT_NAME` 9. If changes were made: commit, push, open draft PR with title "fix(e2e): [description]" 10. Clean up worktree when done 11. Report: agents tested, passed, failed, fixed diff --git a/.claude/skills/setup-agent-team/qa.sh b/.claude/skills/setup-agent-team/qa.sh index f0b2f7a62..cef2df34d 100644 --- a/.claude/skills/setup-agent-team/qa.sh +++ b/.claude/skills/setup-agent-team/qa.sh @@ -7,7 +7,7 @@ set -eo pipefail # RUN_MODE=quality — agent team: test-runner + dedup-scanner + code-quality-reviewer + e2e-tester (reason=schedule/workflow_dispatch, 40 min) # RUN_MODE=fixtures — single agent: collect API fixtures from cloud providers (reason=fixtures, 20 min) # RUN_MODE=issue — single agent: investigate and fix a specific issue (reason=issues, 15 min) -# RUN_MODE=e2e — single agent: run Fly.io E2E tests, investigate failures (reason=e2e, 20 min) +# RUN_MODE=e2e — single agent: run AWS E2E tests, investigate failures (reason=e2e, 20 min) SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" REPO_ROOT="$(cd "${SCRIPT_DIR}/../../.." && pwd)" diff --git a/.github/workflows/cli-release.yml b/.github/workflows/cli-release.yml index be97e20f3..0abf8bc47 100644 --- a/.github/workflows/cli-release.yml +++ b/.github/workflows/cli-release.yml @@ -66,7 +66,7 @@ jobs: env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | - # Upload each cloud bundle as a separate release (fly-latest/fly.js, etc.) + # Upload each cloud bundle as a separate release (aws-latest/aws.js, etc.) for bundle in packages/cli/*.js; do name=$(basename "$bundle" .js) [[ "$name" == "cli" ]] && continue # skip cli.js, already uploaded above diff --git a/.github/workflows/fly-docker.yml b/.github/workflows/docker.yml similarity index 83% rename from .github/workflows/fly-docker.yml rename to .github/workflows/docker.yml index 52c3c1e7f..787bae43d 100644 --- a/.github/workflows/fly-docker.yml +++ b/.github/workflows/docker.yml @@ -1,10 +1,10 @@ -name: Build Fly Docker Images +name: Build Docker Images on: push: branches: [main] paths: - - "fly/docker/openclaw.Dockerfile" + - "sh/docker/**" schedule: # Daily: pick up new openclaw releases - cron: "0 6 * * *" @@ -29,6 +29,6 @@ jobs: - uses: docker/build-push-action@v6 with: context: . - file: fly/docker/openclaw.Dockerfile + file: sh/docker/openclaw.Dockerfile push: true tags: ghcr.io/openrouterteam/spawn-openclaw:latest diff --git a/CLAUDE.md b/CLAUDE.md index d1761eee5..25fd41a21 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -39,15 +39,14 @@ Look at `manifest.json` → `matrix` for any `"missing"` entry. To implement it: ### 2. Add a new cloud provider (HIGH BAR) -We are currently shipping with **8 curated clouds** (sorted by price): +We are currently shipping with **7 curated clouds** (sorted by price): 1. **local** — free (no provisioning) 2. **hetzner** — ~€3.29/mo (CX22) -3. **fly** — free tier (3 shared-cpu VMs) -4. **aws** — $3.50/mo (nano) -5. **daytona** — pay-per-second sandboxes -6. **digitalocean** — $4/mo (Basic droplet) -7. **gcp** — $7.11/mo (e2-micro) -8. **sprite** — Fly.io managed VMs +3. **aws** — $3.50/mo (nano) +4. **daytona** — pay-per-second sandboxes +5. **digitalocean** — $4/mo (Basic droplet) +6. **gcp** — $7.11/mo (e2-micro) +7. **sprite** — managed cloud VMs **Do NOT add clouds speculatively.** Every cloud must be manually tested and verified end-to-end before shipping. Adding a cloud that can't be tested is worse than not having it. @@ -118,7 +117,6 @@ spawn/ github-auth.sh # Standalone GitHub CLI auth helper key-request.sh # API key provisioning helpers (used by QA) e2e/ - fly-e2e.sh # Fly.io E2E test suite lib/*.sh # E2E helper libraries test/ macos-compat.sh # macOS compatibility test script diff --git a/README.md b/README.md index c8ebd92ad..7f9653061 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ Launch any AI agent on any cloud with a single command. Coding agents, research agents, self-hosted AI tools — Spawn deploys them all. All models powered by [OpenRouter](https://openrouter.ai). (ALPHA software, use at your own risk!) -**6 agents. 8 clouds. 48 working combinations. Zero config.** +**6 agents. 7 clouds. 42 working combinations. Zero config.** ## Install @@ -160,14 +160,14 @@ If an agent fails to install or launch on a cloud: ## Matrix -| | [Local Machine](sh/local/) | [Hetzner Cloud](sh/hetzner/) | [Fly.io](sh/fly/) | [AWS Lightsail](sh/aws/) | [Daytona](sh/daytona/) | [DigitalOcean](sh/digitalocean/) | [GCP Compute Engine](sh/gcp/) | [Sprite](sh/sprite/) | -|---|---|---|---|---|---|---|---|---| -| [**Claude Code**](https://claude.ai) | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | -| [**OpenClaw**](https://github.com/openclaw/openclaw) | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | -| [**ZeroClaw**](https://github.com/zeroclaw-labs/zeroclaw) | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | -| [**Codex CLI**](https://github.com/openai/codex) | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | -| [**OpenCode**](https://github.com/sst/opencode) | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | -| [**Kilo Code**](https://github.com/Kilo-Org/kilocode) | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | +| | [Local Machine](sh/local/) | [Hetzner Cloud](sh/hetzner/) | [AWS Lightsail](sh/aws/) | [Daytona](sh/daytona/) | [DigitalOcean](sh/digitalocean/) | [GCP Compute Engine](sh/gcp/) | [Sprite](sh/sprite/) | +|---|---|---|---|---|---|---|---| +| [**Claude Code**](https://claude.ai) | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | +| [**OpenClaw**](https://github.com/openclaw/openclaw) | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | +| [**ZeroClaw**](https://github.com/zeroclaw-labs/zeroclaw) | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | +| [**Codex CLI**](https://github.com/openai/codex) | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | +| [**OpenCode**](https://github.com/sst/opencode) | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | +| [**Kilo Code**](https://github.com/Kilo-Org/kilocode) | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ### How it works diff --git a/assets/clouds/.sources.json b/assets/clouds/.sources.json index 1940abec8..af1a68354 100644 --- a/assets/clouds/.sources.json +++ b/assets/clouds/.sources.json @@ -1,6 +1,5 @@ { "hetzner": { "url": "https://www.hetzner.com/_resources/themes/hetzner/images/favicons/ms-icon-310x310.png", "ext": "png" }, - "fly": { "url": "https://fly.io/phx/ui/images/favicon/android-chrome-512x512.png", "ext": "png" }, "aws": { "url": "https://a0.awsstatic.com/libra-css/images/site/touch-icon-ipad-144-smile.png", "ext": "png" }, "daytona": { "url": "https://avatars.githubusercontent.com/u/130513197?s=400&v=4", "ext": "png" }, "digitalocean": { "url": "https://www.digitalocean.com/_next/static/media/android-chrome-512x512.5f2e6221.png", "ext": "png" }, diff --git a/assets/clouds/fly.png b/assets/clouds/fly.png deleted file mode 100644 index 44196ea52..000000000 Binary files a/assets/clouds/fly.png and /dev/null differ diff --git a/fixtures/fly/_metadata.json b/fixtures/fly/_metadata.json deleted file mode 100644 index 8a57a7795..000000000 --- a/fixtures/fly/_metadata.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "cloud": "fly", - "recorded_at": "2026-02-17T00:00:00Z", - "fixtures": { - "apps": {"endpoint": "/apps?org_slug=personal", "type": "synthetic", "recorded_at": "2026-02-17T00:00:00Z"}, - "create_app": {"endpoint": "POST /apps", "type": "synthetic", "recorded_at": "2026-02-17T00:00:00Z"}, - "create_server": {"endpoint": "POST /apps/{name}/machines", "type": "synthetic", "recorded_at": "2026-02-17T00:00:00Z"} - } -} diff --git a/fixtures/fly/apps.json b/fixtures/fly/apps.json deleted file mode 100644 index 0854fc0d0..000000000 --- a/fixtures/fly/apps.json +++ /dev/null @@ -1,13 +0,0 @@ -{ - "apps": [ - { - "id": "test-app-id", - "name": "test-app", - "organization": { - "slug": "personal" - }, - "status": "deployed" - } - ], - "total_apps": 1 -} diff --git a/fixtures/fly/create_server.json b/fixtures/fly/create_server.json deleted file mode 100644 index e97e0f144..000000000 --- a/fixtures/fly/create_server.json +++ /dev/null @@ -1,16 +0,0 @@ -{ - "config": { - "guest": { - "cpu_kind": "shared", - "cpus": 1, - "memory_mb": 1024 - }, - "image": "ubuntu:24.04" - }, - "id": "d890e84b0d3089", - "instance_id": "01JTEST", - "name": "test-app", - "private_ip": "fdaa:0:0:0:a7b:0:0:2", - "region": "iad", - "state": "created" -} diff --git a/manifest.json b/manifest.json index 201334314..84ecdfef5 100644 --- a/manifest.json +++ b/manifest.json @@ -271,24 +271,6 @@ }, "icon": "https://raw.githubusercontent.com/OpenRouterTeam/spawn/main/assets/clouds/hetzner.png" }, - "fly": { - "name": "Fly.io", - "description": "Deploy globally on Fly.io with free-tier VMs", - "url": "https://fly.io", - "type": "api+cli", - "auth": "FLY_API_TOKEN", - "provision_method": "POST /v1/apps + POST /v1/apps/{app}/machines", - "exec_method": "fly ssh console -C", - "interactive_method": "fly ssh console", - "defaults": { - "region": "iad", - "vm_size": "shared-cpu-1x", - "vm_memory": 1024, - "image": "ubuntu:24.04" - }, - "notes": "Uses Machines API for provisioning and flyctl SSH for exec. Docker-based, pay-per-second pricing. Requires flyctl CLI.", - "icon": "https://raw.githubusercontent.com/OpenRouterTeam/spawn/main/assets/clouds/fly.png" - }, "aws": { "name": "AWS Lightsail", "description": "Simple AWS instances starting at $3.50/mo", @@ -381,12 +363,6 @@ "hetzner/codex": "implemented", "hetzner/opencode": "implemented", "hetzner/kilocode": "implemented", - "fly/claude": "implemented", - "fly/openclaw": "implemented", - "fly/zeroclaw": "implemented", - "fly/codex": "implemented", - "fly/opencode": "implemented", - "fly/kilocode": "implemented", "aws/claude": "implemented", "aws/openclaw": "implemented", "aws/zeroclaw": "implemented", diff --git a/packages/cli/.gitignore b/packages/cli/.gitignore index d77b6f616..3e8e70c06 100644 --- a/packages/cli/.gitignore +++ b/packages/cli/.gitignore @@ -7,7 +7,6 @@ dist/ aws.js daytona.js digitalocean.js -fly.js gcp.js hetzner.js local.js diff --git a/packages/cli/build-clouds.ts b/packages/cli/build-clouds.ts index cd1b37f3c..7e22fe97f 100644 --- a/packages/cli/build-clouds.ts +++ b/packages/cli/build-clouds.ts @@ -5,7 +5,7 @@ // // Usage: // bun run cli/build-clouds.ts # build all clouds -// bun run cli/build-clouds.ts fly # build specific cloud +// bun run cli/build-clouds.ts aws # build specific cloud import { readdirSync, existsSync } from "fs"; import path from "path"; diff --git a/packages/cli/package.json b/packages/cli/package.json index 558a9bc33..dd601398e 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,6 +1,6 @@ { "name": "@openrouter/spawn", - "version": "0.10.26", + "version": "0.11.0", "type": "module", "bin": { "spawn": "cli.js" diff --git a/packages/cli/src/__tests__/custom-flag.test.ts b/packages/cli/src/__tests__/custom-flag.test.ts index 6921f05cc..237f335fb 100644 --- a/packages/cli/src/__tests__/custom-flag.test.ts +++ b/packages/cli/src/__tests__/custom-flag.test.ts @@ -135,27 +135,6 @@ describe("GCP --custom prompts", () => { }); }); -describe("Fly --custom prompts", () => { - const savedCustom = process.env.SPAWN_CUSTOM; - const savedMemory = process.env.FLY_VM_MEMORY; - - afterEach(() => { - restoreEnv("SPAWN_CUSTOM", savedCustom); - restoreEnv("FLY_VM_MEMORY", savedMemory); - }); - - it("should return defaults without --custom", async () => { - delete process.env.FLY_VM_MEMORY; - delete process.env.SPAWN_CUSTOM; - const { DEFAULT_VM_TIER } = await import("../fly/fly"); - // The promptVmOptions is local to main.ts, so we test the behavior - // via the exported DEFAULT_VM_TIER and the env-var pattern - expect(DEFAULT_VM_TIER.cpuKind).toBeDefined(); - expect(DEFAULT_VM_TIER.cpus).toBeGreaterThan(0); - expect(DEFAULT_VM_TIER.memoryMb).toBeGreaterThan(0); - }); -}); - describe("Hetzner --custom prompts", () => { const savedCustom = process.env.SPAWN_CUSTOM; const savedServerType = process.env.HETZNER_SERVER_TYPE; diff --git a/packages/cli/src/__tests__/fly.test.ts b/packages/cli/src/__tests__/fly.test.ts deleted file mode 100644 index a58ab17fa..000000000 --- a/packages/cli/src/__tests__/fly.test.ts +++ /dev/null @@ -1,268 +0,0 @@ -import { describe, it, expect } from "bun:test"; - -// Import modules under test — these are pure functions, no side effects -import { jsonEscape, validateServerName, validateRegionName, validateModelId, toKebabCase } from "../shared/ui"; - -import { sanitizeFlyToken, FLY_VM_TIERS, DEFAULT_VM_TIER } from "../fly/fly"; - -import { generateEnvConfig, resolveAgent, agents } from "../fly/agents"; - -// ─── ui.ts tests ───────────────────────────────────────────────────────────── - -describe("fly/lib/ui", () => { - describe("jsonEscape", () => { - it("escapes simple strings", () => { - expect(jsonEscape("hello")).toBe('"hello"'); - }); - it("escapes quotes", () => { - expect(jsonEscape('say "hi"')).toBe('"say \\"hi\\""'); - }); - it("escapes backslashes", () => { - expect(jsonEscape("a\\b")).toBe('"a\\\\b"'); - }); - it("escapes newlines", () => { - expect(jsonEscape("a\nb")).toBe('"a\\nb"'); - }); - it("handles empty string", () => { - expect(jsonEscape("")).toBe('""'); - }); - }); - - describe("validateServerName", () => { - it("accepts valid names", () => { - expect(validateServerName("my-server")).toBe(true); - expect(validateServerName("dev-box-01")).toBe(true); - expect(validateServerName("abc")).toBe(true); - }); - it("rejects too short", () => { - expect(validateServerName("ab")).toBe(false); - }); - it("rejects too long", () => { - expect(validateServerName("a".repeat(64))).toBe(false); - }); - it("rejects leading dash", () => { - expect(validateServerName("-abc")).toBe(false); - }); - it("rejects trailing dash", () => { - expect(validateServerName("abc-")).toBe(false); - }); - it("rejects special characters", () => { - expect(validateServerName("my_server")).toBe(false); - expect(validateServerName("my.server")).toBe(false); - expect(validateServerName("my server")).toBe(false); - }); - }); - - describe("validateRegionName", () => { - it("accepts valid regions", () => { - expect(validateRegionName("iad")).toBe(true); - expect(validateRegionName("us-east-1")).toBe(true); - expect(validateRegionName("eu_west_2")).toBe(true); - }); - it("rejects empty", () => { - expect(validateRegionName("")).toBe(false); - }); - it("rejects special chars", () => { - expect(validateRegionName("us east")).toBe(false); - }); - }); - - describe("validateModelId", () => { - it("accepts valid model IDs", () => { - expect(validateModelId("anthropic/claude-3.5-sonnet")).toBe(true); - expect(validateModelId("openai/gpt-4-turbo")).toBe(true); - expect(validateModelId("openrouter/auto")).toBe(true); - }); - it("accepts empty (optional)", () => { - expect(validateModelId("")).toBe(true); - }); - it("rejects shell metacharacters", () => { - expect(validateModelId("model;rm -rf /")).toBe(false); - expect(validateModelId("model$(whoami)")).toBe(false); - }); - }); - - describe("toKebabCase", () => { - it("converts display names", () => { - expect(toKebabCase("My Dev Box")).toBe("my-dev-box"); - expect(toKebabCase("Claude 2024!")).toBe("claude-2024"); - }); - it("deduplicates dashes", () => { - expect(toKebabCase("a--b")).toBe("a-b"); - }); - it("handles empty string", () => { - expect(toKebabCase("")).toBe(""); - }); - }); -}); - -// ─── fly.ts tests ──────────────────────────────────────────────────────────── - -describe("fly/lib/fly", () => { - describe("sanitizeFlyToken", () => { - it("passes through plain tokens", () => { - expect(sanitizeFlyToken("FlyV1 abc123")).toBe("FlyV1 abc123"); - }); - it("extracts FlyV1 from noisy input", () => { - expect(sanitizeFlyToken("some-name FlyV1 abc123")).toBe("FlyV1 abc123"); - }); - it("wraps fm2_ tokens with FlyV1", () => { - expect(sanitizeFlyToken("fm2_abc123")).toBe("FlyV1 fm2_abc123"); - }); - it("preserves comma-separated macaroon discharge tokens", () => { - expect(sanitizeFlyToken("fm2_abc,fm2_def,fo1_ghi")).toBe("FlyV1 fm2_abc,fm2_def,fo1_ghi"); - }); - it("extracts full macaroon from noisy input", () => { - expect(sanitizeFlyToken("deploy token fm2_abc,fm2_def extra")).toBe("FlyV1 fm2_abc,fm2_def"); - }); - it("wraps m2. tokens with FlyV1", () => { - expect(sanitizeFlyToken("m2.abc")).toBe("FlyV1 m2.abc"); - }); - it("trims whitespace", () => { - expect(sanitizeFlyToken(" bearer-token ")).toBe("bearer-token"); - }); - it("strips newlines", () => { - expect(sanitizeFlyToken("token\n\r")).toBe("token"); - }); - }); - - describe("FLY_VM_TIERS", () => { - it("has shared and dedicated tiers", () => { - expect(FLY_VM_TIERS.length).toBe(6); - expect(FLY_VM_TIERS.filter((t) => t.cpuKind === "shared").length).toBe(3); - expect(FLY_VM_TIERS.filter((t) => t.cpuKind === "performance").length).toBe(3); - }); - - it("default tier is performance-2x", () => { - expect(DEFAULT_VM_TIER.id).toBe("performance-2x"); - expect(DEFAULT_VM_TIER.cpuKind).toBe("performance"); - expect(DEFAULT_VM_TIER.cpus).toBe(2); - expect(DEFAULT_VM_TIER.memoryMb).toBe(4096); - }); - - it("all tiers have required fields", () => { - for (const tier of FLY_VM_TIERS) { - expect(tier.id).toBeTruthy(); - expect(tier.cpuKind === "shared" || tier.cpuKind === "performance").toBe(true); - expect(tier.cpus).toBeGreaterThan(0); - expect(tier.memoryMb).toBeGreaterThan(0); - expect(tier.label).toBeTruthy(); - } - }); - }); -}); - -// ─── agents.ts tests ───────────────────────────────────────────────────────── - -describe("fly/lib/agents", () => { - describe("generateEnvConfig", () => { - it("generates export lines", () => { - const result = generateEnvConfig([ - "OPENROUTER_API_KEY=sk-test", - "FOO=bar", - ]); - expect(result).toContain("export IS_SANDBOX='1'"); - expect(result).toContain("export OPENROUTER_API_KEY='sk-test'"); - expect(result).toContain("export FOO='bar'"); - }); - - it("escapes single quotes in values", () => { - const result = generateEnvConfig([ - "FOO=it's", - ]); - expect(result).toContain("export FOO='it'\\''s'"); - }); - - it("rejects invalid env var names", () => { - const result = generateEnvConfig([ - "invalid-name=val", - ]); - expect(result).not.toContain("invalid-name"); - }); - - it("allows empty values", () => { - const result = generateEnvConfig([ - "ANTHROPIC_API_KEY=", - ]); - expect(result).toContain("export ANTHROPIC_API_KEY=''"); - }); - }); - - describe("resolveAgent", () => { - it("resolves known agents by name", () => { - expect(resolveAgent("claude").name).toBe("Claude Code"); - expect(resolveAgent("codex").name).toBe("Codex CLI"); - expect(resolveAgent("openclaw").name).toBe("OpenClaw"); - expect(resolveAgent("opencode").name).toBe("OpenCode"); - expect(resolveAgent("kilocode").name).toBe("Kilo Code"); - expect(resolveAgent("zeroclaw").name).toBe("ZeroClaw"); - }); - - it("is case-insensitive", () => { - expect(resolveAgent("Claude").name).toBe("Claude Code"); - expect(resolveAgent("CODEX").name).toBe("Codex CLI"); - }); - - it("throws for unknown agents", () => { - expect(() => resolveAgent("nonexistent")).toThrow("Unknown agent"); - }); - }); - - describe("agent configs", () => { - it("all agents have required fields", () => { - for (const [key, agent] of Object.entries(agents)) { - expect(agent.name).toBeTruthy(); - expect(typeof agent.install).toBe("function"); - expect(typeof agent.envVars).toBe("function"); - expect(typeof agent.launchCmd).toBe("function"); - } - }); - - it("claude envVars include OpenRouter config", () => { - const vars = agents.claude.envVars("sk-test"); - expect(vars).toContain("OPENROUTER_API_KEY=sk-test"); - expect(vars).toContain("ANTHROPIC_BASE_URL=https://openrouter.ai/api"); - expect(vars).toContain("ANTHROPIC_AUTH_TOKEN=sk-test"); - }); - - it("openclaw has model prompt enabled", () => { - expect(agents.openclaw.modelPrompt).toBe(true); - expect(agents.openclaw.modelDefault).toBe("openrouter/auto"); - }); - - it("agents have no vmMemory field (VM sizing is user-chosen)", () => { - for (const [key, agent] of Object.entries(agents)) { - expect("vmMemory" in agent).toBe(false); - } - }); - - it("kilocode envVars include provider type", () => { - const vars = agents.kilocode.envVars("sk-test"); - expect(vars).toContain("KILO_PROVIDER_TYPE=openrouter"); - expect(vars).toContain("KILO_OPEN_ROUTER_API_KEY=sk-test"); - }); - - it("zeroclaw envVars include provider", () => { - const vars = agents.zeroclaw.envVars("sk-test"); - expect(vars).toContain("ZEROCLAW_PROVIDER=openrouter"); - }); - - it("claude launch command sources .spawnrc", () => { - expect(agents.claude.launchCmd()).toContain("source ~/.spawnrc"); - expect(agents.claude.launchCmd()).toContain("claude"); - }); - - it("codex launch command launches codex", () => { - expect(agents.codex.launchCmd()).toContain("codex"); - }); - - it("openclaw launch command launches openclaw tui", () => { - expect(agents.openclaw.launchCmd()).toContain("openclaw tui"); - }); - - it("zeroclaw launch command sources cargo env", () => { - expect(agents.zeroclaw.launchCmd()).toContain("source ~/.cargo/env"); - expect(agents.zeroclaw.launchCmd()).toContain("zeroclaw agent"); - }); - }); -}); diff --git a/packages/cli/src/__tests__/security-connection-validation.test.ts b/packages/cli/src/__tests__/security-connection-validation.test.ts index 9fc15fd8e..322dc73ed 100644 --- a/packages/cli/src/__tests__/security-connection-validation.test.ts +++ b/packages/cli/src/__tests__/security-connection-validation.test.ts @@ -24,7 +24,6 @@ describe("validateConnectionIP", () => { it("should accept special sentinel values", () => { expect(() => validateConnectionIP("sprite-console")).not.toThrow(); - expect(() => validateConnectionIP("fly-ssh")).not.toThrow(); expect(() => validateConnectionIP("daytona-sandbox")).not.toThrow(); expect(() => validateConnectionIP("localhost")).not.toThrow(); }); diff --git a/packages/cli/src/commands.ts b/packages/cli/src/commands.ts index 6e3d32e02..d9e2820f5 100644 --- a/packages/cli/src/commands.ts +++ b/packages/cli/src/commands.ts @@ -39,7 +39,6 @@ import { getHistoryPath, } from "./history.js"; import { buildDashboardHint, EXIT_CODE_GUIDANCE, SIGNAL_GUIDANCE } from "./guidance-data.js"; -import { destroyServer as flyDestroyServer, ensureFlyCli, ensureFlyToken } from "./fly/fly.js"; import { destroyServer as hetznerDestroyServer, ensureHcloudToken } from "./hetzner/hetzner.js"; import { destroyServer as doDestroyServer, ensureDoToken } from "./digitalocean/digitalocean.js"; import { @@ -424,7 +423,6 @@ const CLOUD_CLI_MAP: Record = { gcp: "gcloud", aws: "aws", - fly: "flyctl", sprite: "sprite", hetzner: "hcloud", digitalocean: "doctl", @@ -2290,13 +2288,6 @@ async function execDeleteServer(record: SpawnRecord): Promise { }; switch (conn.cloud) { - case "fly": - return tryDelete(async () => { - await ensureFlyCli(); - await ensureFlyToken(); - await flyDestroyServer(id); - }); - case "hetzner": return tryDelete(async () => { await ensureHcloudToken(); @@ -2436,11 +2427,9 @@ async function handleRecordAction(selected: SpawnRecord, manifest: Manifest | nu hint: conn.ip === "sprite-console" ? `sprite console -s ${conn.server_name}` - : conn.ip === "fly-ssh" - ? `fly ssh console -a ${conn.server_name}` - : conn.ip === "daytona-sandbox" - ? `daytona ssh ${conn.server_id}` - : `ssh ${conn.user}@${conn.ip}`, + : conn.ip === "daytona-sandbox" + ? `daytona ssh ${conn.server_id}` + : `ssh ${conn.user}@${conn.ip}`, }); } @@ -2797,23 +2786,6 @@ async function cmdConnect(connection: VMConnection): Promise { ); } - // Handle Fly.io SSH connections (uses flyctl, not direct SSH) - if (connection.ip === "fly-ssh" && connection.server_name) { - p.log.step(`Connecting to Fly.io app ${pc.bold(connection.server_name)}...`); - return runInteractiveCommand( - "fly", - [ - "ssh", - "console", - "-a", - connection.server_name, - "--pty", - ], - "Fly.io SSH connection failed", - `fly ssh console -a ${connection.server_name}`, - ); - } - // Handle SSH connections p.log.step(`Connecting to ${pc.bold(connection.ip)}...`); const sshCmd = `ssh ${connection.user}@${connection.ip}`; @@ -2898,30 +2870,6 @@ async function cmdEnterAgent(connection: VMConnection, agentKey: string, manifes ); } - // Handle Fly.io SSH connections - if (connection.ip === "fly-ssh" && connection.server_name) { - p.log.step(`Entering ${pc.bold(agentName)} on Fly.io app ${pc.bold(connection.server_name)}...`); - // Wrap in bash -c to ensure shell builtins (source, export) and operators (;) are - // interpreted correctly — fly ssh console -C exec's the command directly without a - // shell, so semicolons and builtins would fail without this wrapper. - // This matches how interactiveSession() and runServer() handle fly commands. - const escapedCmd = remoteCmd.replace(/'/g, "'\\''"); - return runInteractiveCommand( - "fly", - [ - "ssh", - "console", - "-a", - connection.server_name, - "--pty", - "-C", - `bash -c '${escapedCmd}'`, - ], - `Failed to enter ${agentName}`, - `fly ssh console -a ${connection.server_name} --pty -C 'bash -c ${escapedCmd}'`, - ); - } - // Handle Daytona sandbox connections if (connection.ip === "daytona-sandbox" && connection.server_id) { p.log.step(`Entering ${pc.bold(agentName)} on Daytona sandbox ${pc.bold(connection.server_id)}...`); @@ -3377,7 +3325,7 @@ function getHelpExamplesSection(): string { spawn claude sprite --prompt "Fix all linter errors" ${pc.dim("# Execute Claude with prompt and exit")} spawn codex sprite -p "Add tests" ${pc.dim("# Short form of --prompt")} - spawn openclaw fly -f instructions.txt + spawn openclaw aws -f instructions.txt ${pc.dim("# Read prompt from file (short for --prompt-file)")} spawn opencode gcp --dry-run ${pc.dim("# Preview without provisioning")} spawn claude hetzner --headless ${pc.dim("# Provision, print connection info, exit")} diff --git a/packages/cli/src/fly/agents.ts b/packages/cli/src/fly/agents.ts deleted file mode 100644 index 5fd92e3d2..000000000 --- a/packages/cli/src/fly/agents.ts +++ /dev/null @@ -1,63 +0,0 @@ -// fly/agents.ts — Fly.io agent configs (thin wrapper over shared) - -import { runServer, uploadFile } from "./fly"; -import { createAgents, installAgent, setupOpenclawBatched, resolveAgent as _resolveAgent } from "../shared/agent-setup"; -import type { CloudRunner } from "../shared/agent-setup"; -import type { AgentConfig } from "../shared/agents"; -import { generateEnvConfig } from "../shared/agents"; -import { logInfo, logStep } from "../shared/ui"; - -/** Fly extends AgentConfig with an optional Docker image field. */ -export interface FlyAgentConfig extends AgentConfig { - image?: string; -} - -export type { AgentConfig }; -export { generateEnvConfig }; - -const runner: CloudRunner = { - runServer, - uploadFile, -}; - -// Start from default agents, then override Fly-specific differences -export const agents: Record = (() => { - const base = createAgents(runner); - const fly: Record = { - ...base, - }; - - // Fly openclaw uses a pre-built Docker image + batched setup (2 SSH sessions total) - fly.openclaw = { - ...base.openclaw, - image: "ghcr.io/openrouterteam/spawn-openclaw:latest", - install: async () => { - logStep("Verifying openclaw installation..."); - try { - await runServer("command -v openclaw"); - logInfo("openclaw is pre-installed"); - } catch { - logInfo("openclaw not found in image, installing from scratch..."); - await installAgent( - runner, - "openclaw", - 'export PATH="$HOME/.bun/bin:$HOME/.local/bin:/usr/local/bin:$PATH" && npm install -g openclaw && command -v openclaw', - ); - } - }, - setup: (envContent, apiKey, modelId) => - setupOpenclawBatched(runner, envContent, apiKey, modelId || "openrouter/auto"), - }; - - return fly; -})(); - -export function resolveAgent(name: string): FlyAgentConfig { - const agent = agents[name.toLowerCase()]; - if (!agent) { - // Fall back to shared resolver for error handling - _resolveAgent(agents, name); - throw new Error(`Unknown agent: ${name}`); - } - return agent; -} diff --git a/packages/cli/src/fly/fly.ts b/packages/cli/src/fly/fly.ts deleted file mode 100644 index 0d0d2ede3..000000000 --- a/packages/cli/src/fly/fly.ts +++ /dev/null @@ -1,1239 +0,0 @@ -// fly/lib/fly.ts — Core Fly.io provider: API, auth, orgs, provisioning, execution - -import { existsSync, readFileSync } from "node:fs"; - -import { - logInfo, - logWarn, - logError, - logStep, - prompt, - selectFromList, - jsonEscape, - validateServerName, - validateRegionName, - toKebabCase, - defaultSpawnName, - sanitizeTermValue, -} from "../shared/ui"; -import type { CloudInitTier } from "../shared/agents"; -import { getPackagesForTier, needsNode, needsBun, NODE_INSTALL_CMD } from "../shared/cloud-init"; -import { parseJsonObj, parseJsonRaw, isString, isNumber, toObjectArray } from "@openrouter/spawn-shared"; -import { killWithTimeout, sleep, spawnInteractive } from "../shared/ssh"; -import { saveVmConnection } from "../history.js"; - -const FLY_API_BASE = "https://api.machines.dev/v1"; -const FLY_DASHBOARD_URL = "https://fly.io/dashboard"; - -// ─── VM Size Tiers ────────────────────────────────────────────────────────── - -export type CpuKind = "shared" | "performance"; - -export interface VmTier { - id: string; - cpuKind: CpuKind; - cpus: number; - memoryMb: number; - label: string; -} - -export const FLY_VM_TIERS: VmTier[] = [ - { - id: "shared-cpu-1x", - cpuKind: "shared", - cpus: 1, - memoryMb: 1024, - label: "1 shared vCPU, 1 GB (~$3/mo)", - }, - { - id: "shared-cpu-2x", - cpuKind: "shared", - cpus: 2, - memoryMb: 4096, - label: "2 shared vCPUs, 4 GB (~$12/mo)", - }, - { - id: "shared-cpu-4x", - cpuKind: "shared", - cpus: 4, - memoryMb: 8192, - label: "4 shared vCPUs, 8 GB (~$51/mo)", - }, - { - id: "performance-1x", - cpuKind: "performance", - cpus: 1, - memoryMb: 2048, - label: "1 dedicated vCPU, 2 GB (~$32/mo)", - }, - { - id: "performance-2x", - cpuKind: "performance", - cpus: 2, - memoryMb: 4096, - label: "2 dedicated vCPUs, 4 GB (~$63/mo)", - }, - { - id: "performance-4x", - cpuKind: "performance", - cpus: 4, - memoryMb: 8192, - label: "4 dedicated vCPUs, 8 GB (~$126/mo)", - }, -]; - -export const DEFAULT_VM_TIER = FLY_VM_TIERS[4]; // performance-2x - -// ─── Server Options ───────────────────────────────────────────────────────── - -export interface ServerOptions { - cpuKind: CpuKind; - cpus: number; - memoryMb: number; - volumeId?: string; - newVolumeSizeGb?: number; -} - -// ─── State ─────────────────────────────────────────────────────────────────── -let flyApiToken = ""; -let flyOrg = ""; -let flyMachineId = ""; -let flyAppName = ""; - -export function getState() { - return { - flyApiToken, - flyOrg, - flyMachineId, - flyAppName, - }; -} - -export function setOrg(org: string) { - flyOrg = org; -} - -// ─── API Client ────────────────────────────────────────────────────────────── - -async function flyApi(method: string, endpoint: string, body?: string, maxRetries = 3): Promise { - const url = `${FLY_API_BASE}${endpoint}`; - const authHeader = flyApiToken.startsWith("FlyV1 ") ? flyApiToken : `Bearer ${flyApiToken}`; - - let interval = 2; - for (let attempt = 1; attempt <= maxRetries; attempt++) { - try { - const headers: Record = { - "Content-Type": "application/json", - Authorization: authHeader, - }; - const opts: RequestInit = { - method, - headers, - }; - if (body && (method === "POST" || method === "PUT" || method === "PATCH")) { - opts.body = body; - } - const resp = await fetch(url, opts); - const text = await resp.text(); - - // Retry on 429 / 5xx - if ((resp.status === 429 || resp.status >= 500) && attempt < maxRetries) { - logWarn(`API ${resp.status} (attempt ${attempt}/${maxRetries}), retrying in ${interval}s...`); - await sleep(interval * 1000); - interval = Math.min(interval * 2, 30); - continue; - } - return text; - } catch (err) { - if (attempt >= maxRetries) { - throw err; - } - logWarn(`API request failed (attempt ${attempt}/${maxRetries}), retrying...`); - await sleep(interval * 1000); - interval = Math.min(interval * 2, 30); - } - } - throw new Error("flyApi: unreachable"); -} - -// ─── Helpers ───────────────────────────────────────────────────────────────── - -function hasError(text: string): boolean { - return text.includes('"error"') || text.includes('"errors"'); -} - -function getCmd(): string | null { - // Check PATH first - for (const name of [ - "fly", - "flyctl", - ]) { - if ( - Bun.spawnSync( - [ - "which", - name, - ], - { - stdio: [ - "ignore", - "pipe", - "ignore", - ], - }, - ).exitCode === 0 - ) { - return name; - } - } - // Bun.spawnSync inherits the original PATH, not process.env mutations. - // Check the default install location directly. - const flyBin = `${process.env.HOME}/.fly/bin`; - for (const name of [ - "fly", - "flyctl", - ]) { - const fullPath = `${flyBin}/${name}`; - if (existsSync(fullPath)) { - return fullPath; - } - } - return null; -} - -// ─── Token Sanitization ───────────────────────────────────────────────────── - -export function sanitizeFlyToken(raw: string): string { - let t = raw.replace(/[\n\r]/g, "").trim(); - if (t.includes("FlyV1 ")) { - // Already prefixed — extract everything after "FlyV1 " - t = "FlyV1 " + (t.split("FlyV1 ").pop() || ""); - } else if (t.includes("fm2_")) { - // Macaroon token — may have comma-separated discharge tokens (fm2_xxx,fm2_yyy,fo1_zzz). - // Extract from the first fm2_ to end-of-string, preserving all segments. - const m = t.match(/(fm2_\S+)/); - if (m) { - t = "FlyV1 " + m[1]; - } - } else if (t.startsWith("m2.")) { - t = "FlyV1 " + t; - } - return t; -} - -// ─── Token Validation ──────────────────────────────────────────────────────── - -async function testFlyToken(): Promise { - if (!flyApiToken) { - return false; - } - try { - const org = flyOrg || "personal"; - const resp = await flyApi("GET", `/apps?org_slug=${org}`, undefined, 1); - if (!hasError(resp)) { - return true; - } - } catch { - // fall through - } - // Fallback: user API (OAuth/personal tokens) - try { - const authHeader = flyApiToken.startsWith("FlyV1 ") ? flyApiToken : `Bearer ${flyApiToken}`; - const resp = await fetch("https://api.fly.io/v1/user", { - headers: { - Authorization: authHeader, - }, - signal: AbortSignal.timeout(10_000), - }); - if (resp.ok) { - const text = await resp.text(); - if (text && !hasError(text)) { - return true; - } - } - } catch { - // fall through - } - return false; -} - -// ─── Token Persistence ─────────────────────────────────────────────────────── - -const FLY_CONFIG_PATH = `${process.env.HOME}/.config/spawn/fly.json`; - -async function saveTokenToConfig(token: string): Promise { - const dir = FLY_CONFIG_PATH.replace(/\/[^/]+$/, ""); - await Bun.spawn([ - "mkdir", - "-p", - dir, - ]).exited; - const escaped = jsonEscape(token); - await Bun.write(FLY_CONFIG_PATH, `{\n "api_key": ${escaped},\n "token": ${escaped}\n}\n`, { - mode: 0o600, - }); -} - -/** Sync the resolved token to process.env so fly CLI subprocesses (ssh console) can authenticate. */ -function syncTokenToEnv(): void { - if (flyApiToken) { - process.env.FLY_API_TOKEN = flyApiToken; - } -} - -function loadTokenFromConfig(): string | null { - try { - const data = JSON.parse(readFileSync(FLY_CONFIG_PATH, "utf-8")); - const token = data.api_key || data.token || ""; - if (!token) { - return null; - } - // Security: validate token chars - if (!/^[a-zA-Z0-9._/@:+=, -]+$/.test(token)) { - return null; - } - return token; - } catch { - return null; - } -} - -// ─── Authentication ────────────────────────────────────────────────────────── - -export async function ensureFlyCli(): Promise { - if (getCmd()) { - logInfo("flyctl CLI available"); - return; - } - logStep("Installing flyctl CLI..."); - const proc = Bun.spawn( - [ - "sh", - "-c", - "curl -L https://fly.io/install.sh | sh", - ], - { - stdio: [ - "ignore", - "inherit", - "inherit", - ], - }, - ); - const exitCode = await proc.exited; - if (exitCode !== 0) { - logError("Failed to install flyctl CLI"); - logError("Install manually: curl -L https://fly.io/install.sh | sh"); - throw new Error("flyctl install failed"); - } - // Add to PATH - const flyBin = `${process.env.HOME}/.fly/bin`; - if (!process.env.PATH?.includes(flyBin)) { - process.env.PATH = `${flyBin}:${process.env.PATH}`; - } - if (!getCmd()) { - logError("flyctl not found in PATH after installation"); - throw new Error("flyctl not in PATH"); - } - logInfo("flyctl CLI installed"); -} - -/** - * Extract a token from fly CLI output. - * Runs the given command, strips ANSI codes, and finds a line that looks like a token. - * Token formats: "FlyV1 fm2_...", "fm2_...", "m2...." or a bare alphanumeric string. - * `fly tokens create` outputs the token prefixed with "FlyV1 " (~650-700 chars). - */ -function extractTokenFromCli(flyCmd: string, args: string[]): string { - try { - const proc = Bun.spawnSync( - [ - flyCmd, - ...args, - ], - { - stdio: [ - "ignore", - "pipe", - "pipe", - ], - }, - ); - const stdout = new TextDecoder().decode(proc.stdout); - const stderr = new TextDecoder().decode(proc.stderr); - // Try stdout first, then stderr - for (const output of [ - stdout, - stderr, - ]) { - for (const line of output.split("\n")) { - const cleaned = line.replace(/\x1b\[[0-9;]*m/g, "").trim(); - if (!cleaned) { - continue; - } - // Match "FlyV1 fm2_..." (the standard output format) - if (/^FlyV1\s+\S+/.test(cleaned)) { - return cleaned; - } - // Match bare macaroon tokens: fm2_..., m2.... - if (/^(fm2_|m2\.)\S+/.test(cleaned)) { - return cleaned; - } - // Skip deprecation notices, help text, error messages - if (/deprecated|command|usage|error|failed|help|available|flags/i.test(cleaned)) { - continue; - } - if (cleaned.startsWith("-") || cleaned.startsWith("The ") || cleaned.startsWith("Use ")) { - continue; - } - // A long alphanumeric string is likely a token - if (/^[a-zA-Z0-9_.,+/=: -]{40,}$/.test(cleaned)) { - return cleaned; - } - } - } - } catch { - // ignore - } - return ""; -} - -export async function ensureFlyToken(): Promise { - const flyCmd = getCmd(); - - // 1. Env var - if (process.env.FLY_API_TOKEN) { - flyApiToken = sanitizeFlyToken(process.env.FLY_API_TOKEN); - if (await testFlyToken()) { - logInfo("Using Fly.io API token from environment"); - await saveTokenToConfig(flyApiToken); - syncTokenToEnv(); - return; - } - logWarn("FLY_API_TOKEN from environment is invalid or expired"); - flyApiToken = ""; - } - - // 2. Saved config - const saved = loadTokenFromConfig(); - if (saved) { - flyApiToken = sanitizeFlyToken(saved); - if (await testFlyToken()) { - logInfo("Using saved Fly.io API token"); - syncTokenToEnv(); - return; - } - logWarn("Saved Fly.io token is invalid or expired"); - flyApiToken = ""; - } - - // 3. Try existing fly CLI session — try multiple token commands - // "fly auth token" is deprecated in newer flyctl; "fly tokens create org" is the replacement. - // Org tokens are needed (not deploy tokens) since spawn creates new apps. - if (flyCmd) { - const tokenCmds: string[][] = [ - [ - "tokens", - "create", - "org", - "--expiry", - "24h", - ], - [ - "auth", - "token", - ], - ]; - for (const args of tokenCmds) { - const token = extractTokenFromCli(flyCmd, args); - if (token) { - flyApiToken = sanitizeFlyToken(token); - if (await testFlyToken()) { - logInfo("Using Fly.io API token from fly CLI"); - await saveTokenToConfig(flyApiToken); - syncTokenToEnv(); - return; - } - flyApiToken = ""; - } - } - logWarn("No valid token from fly CLI session"); - } - - // 4. OAuth login via fly auth login - if (flyCmd) { - logStep("Launching Fly.io OAuth login..."); - const proc = Bun.spawn( - [ - flyCmd, - "auth", - "login", - ], - { - stdio: [ - "inherit", - "inherit", - "inherit", - ], - }, - ); - await proc.exited; - - // After login, try to get an org token (needed for creating apps) - const tokenCmds: string[][] = [ - [ - "tokens", - "create", - "org", - "--expiry", - "24h", - ], - [ - "auth", - "token", - ], - ]; - for (const args of tokenCmds) { - const token = extractTokenFromCli(flyCmd, args); - if (token) { - flyApiToken = sanitizeFlyToken(token); - await saveTokenToConfig(flyApiToken); - syncTokenToEnv(); - logInfo("Authenticated with Fly.io via OAuth"); - return; - } - } - logWarn("fly auth login did not succeed"); - } - - // 5. Manual token paste - logStep("Manual token entry (last resort)"); - logWarn("Get a token from: https://fly.io/dashboard -> Tokens"); - logWarn("Or run: fly tokens create org"); - const token = await prompt("Enter your Fly.io API token: "); - if (!token) { - throw new Error("No token provided"); - } - flyApiToken = sanitizeFlyToken(token); - if (!(await testFlyToken())) { - logError("Token is invalid"); - flyApiToken = ""; - throw new Error("Invalid Fly.io token"); - } - await saveTokenToConfig(flyApiToken); - syncTokenToEnv(); - logInfo("Using manually entered Fly.io API token"); -} - -// ─── Organization Listing ──────────────────────────────────────────────────── - -interface OrgEntry { - slug: string; - label: string; -} - -function parseOrgsJson(json: string): OrgEntry[] { - const raw = parseJsonRaw(json); - if (!raw || typeof raw !== "object") { - return []; - } - - let orgs: Record[] = []; - if (Array.isArray(raw)) { - orgs = toObjectArray(raw); - } else { - // Re-parse as Record via valibot schema - const data = parseJsonObj(json); - if (!data) { - return []; - } - - if (data.nodes) { - orgs = toObjectArray(data.nodes); - } else if (data.organizations) { - orgs = toObjectArray(data.organizations); - } else if (data.data && typeof data.data === "object") { - const inner = parseJsonObj(JSON.stringify(data.data)); - if (inner?.organizations) { - const orgData = parseJsonObj(JSON.stringify(inner.organizations)); - if (orgData) { - orgs = toObjectArray(orgData.nodes); - } - } - } else { - // {slug: name} format - return Object.entries(data) - .filter(([slug]) => slug) - .map(([slug, name]) => ({ - slug, - label: String(name), - })); - } - } - - return orgs - .filter((o) => o.slug || o.name) - .map((o) => { - const slug = String(o.slug || o.name || ""); - const name = String(o.name || slug); - const suffix = o.type ? ` (${o.type})` : ""; - return { - slug, - label: `${name}${suffix}`, - }; - }); -} - -async function listOrgs(): Promise { - const flyCmd = getCmd(); - - // 1. Try fly CLI - if (flyCmd) { - try { - const proc = Bun.spawnSync( - [ - flyCmd, - "orgs", - "list", - "--json", - ], - { - stdio: [ - "ignore", - "pipe", - "pipe", - ], - }, - ); - const json = new TextDecoder().decode(proc.stdout).trim(); - if (json) { - const orgs = parseOrgsJson(json); - if (orgs.length > 0) { - return orgs; - } - } - } catch { - // fall through - } - } - - // 2. Fall back to GraphQL - if (!flyApiToken) { - return []; - } - const authHeader = flyApiToken.startsWith("FlyV1 ") ? flyApiToken : `Bearer ${flyApiToken}`; - - try { - const resp = await fetch("https://api.fly.io/graphql", { - method: "POST", - headers: { - Authorization: authHeader, - "Content-Type": "application/json", - }, - body: '{"query":"{ organizations { nodes { slug name type } } }"}', - signal: AbortSignal.timeout(15_000), - }); - const json = await resp.text(); - const orgs = parseOrgsJson(json); - if (orgs.length > 0) { - return orgs; - } - } catch { - // fall through - } - - return []; -} - -export async function promptOrg(): Promise { - if (process.env.FLY_ORG) { - flyOrg = process.env.FLY_ORG; - return; - } - if (process.env.SPAWN_NON_INTERACTIVE === "1") { - flyOrg = "personal"; - return; - } - - logStep("Fetching available Fly.io organizations..."); - const orgs = await listOrgs(); - if (orgs.length === 0) { - logError("Failed to fetch Fly.io organizations"); - logWarn("Debug hints:"); - logWarn(" 1. Is fly installed? Run: fly version"); - logWarn(" 2. Is your token valid? Run: fly auth whoami"); - logWarn(" 3. Can you list orgs? Run: fly orgs list --json"); - throw new Error("Cannot list Fly.io organizations"); - } - - const items = orgs.map((o) => `${o.slug}|${o.label}`); - flyOrg = await selectFromList(items, "Fly.io organizations", "personal"); - logInfo(`Using Fly.io org: ${flyOrg}`); -} - -// ─── Provisioning ──────────────────────────────────────────────────────────── - -async function createApp(name: string): Promise { - logStep(`Creating Fly.io app '${name}'...`); - const body = JSON.stringify({ - app_name: name, - org_slug: flyOrg || "personal", - }); - const resp = await flyApi("POST", "/apps", body); - if (resp.includes('"error"')) { - const data = parseJsonObj(resp); - const errMsg = data?.error || "Unknown error"; - if (/already exists/i.test(String(errMsg))) { - logInfo(`App '${name}' already exists, reusing it`); - return; - } - logError(`Failed to create Fly.io app: ${errMsg}`); - if (/taken|Name.*valid/i.test(String(errMsg))) { - logWarn("Fly.io app names are globally unique. Set a different name with: FLY_APP_NAME=my-unique-name"); - } - throw new Error(`App creation failed: ${errMsg}`); - } - logInfo(`App '${name}' created`); -} - -async function createMachine( - name: string, - region: string, - cpuKind: CpuKind, - cpus: number, - vmMemory: number, - volumeId?: string, - image?: string, -): Promise { - const kindLabel = cpuKind === "performance" ? "dedicated" : "shared"; - logStep(`Creating Fly.io machine (region: ${region}, ${cpus} ${kindLabel} vCPU, ${vmMemory}MB)...`); - const config: Record = { - image: image || "ubuntu:24.04", - guest: { - cpu_kind: cpuKind, - cpus, - memory_mb: vmMemory, - }, - init: { - exec: [ - "/bin/sleep", - "inf", - ], - }, - auto_destroy: false, - }; - if (volumeId) { - config.mounts = [ - { - volume: volumeId, - path: "/data", - }, - ]; - } - const body = JSON.stringify({ - name, - region, - config, - }); - - const resp = await flyApi("POST", `/apps/${name}/machines`, body); - if (resp.includes('"error"')) { - const data = parseJsonObj(resp); - logError(`Failed to create Fly.io machine: ${data?.error || "Unknown error"}`); - logWarn("Check your dashboard: https://fly.io/dashboard"); - throw new Error("Machine creation failed"); - } - - const data = parseJsonObj(resp); - const machineId = isString(data?.id) ? data.id : undefined; - if (!machineId) { - logError("Failed to extract machine ID from API response"); - throw new Error("No machine ID"); - } - logInfo(`Machine created: ID=${machineId}, App=${name}`); - return machineId; -} - -async function waitForMachineStart(name: string, machineId: string, timeout = 60, retries = 3): Promise { - for (let attempt = 1; attempt <= retries; attempt++) { - logStep(`Waiting for machine to start (timeout: ${timeout}s, attempt ${attempt}/${retries})...`); - const resp = await flyApi("GET", `/apps/${name}/machines/${machineId}/wait?state=started&timeout=${timeout}`); - if (!hasError(resp)) { - logInfo("Machine is running"); - return; - } - if (attempt < retries) { - logWarn("Machine not ready yet, retrying..."); - continue; - } - const data = parseJsonObj(resp); - logError(`Machine did not reach 'started' state: ${data?.error || "timeout"}`); - logError("Try a new region: FLY_REGION=ord spawn fly "); - throw new Error("Machine start timeout"); - } -} - -async function cleanupOnFailure(appName: string): Promise { - logWarn(`Cleaning up app '${appName}' after provisioning failure...`); - try { - await flyApi("DELETE", `/apps/${appName}`, undefined, 1); - } catch { - // best-effort cleanup - } -} - -async function createVolume(name: string, region: string, sizeGb: number): Promise { - logStep(`Creating ${sizeGb}GB volume...`); - const body = JSON.stringify({ - name: "data", - region, - size_gb: sizeGb, - }); - const resp = await flyApi("POST", `/apps/${name}/volumes`, body); - const data = parseJsonObj(resp); - if (!data?.id) { - logError("Failed to create volume"); - throw new Error("Volume creation failed"); - } - const volumeId = isString(data.id) ? data.id : String(data.id); - logInfo(`Volume created: ${volumeId}`); - return volumeId; -} - -export async function listVolumes(appName: string): Promise< - Array<{ - id: string; - name: string; - size_gb: number; - }> -> { - const resp = await flyApi("GET", `/apps/${appName}/volumes`); - const data = parseJsonRaw(resp); - if (!Array.isArray(data)) { - return []; - } - const items = toObjectArray(data); - return items - .filter((item) => item.id) - .map((item) => ({ - id: String(item.id), - name: String(item.name || "unnamed"), - size_gb: isNumber(item.size_gb) ? item.size_gb : 0, - })); -} - -export async function createServer(name: string, opts: ServerOptions, image?: string): Promise { - const region = process.env.FLY_REGION || "iad"; - - if (!validateRegionName(region)) { - logError("Invalid FLY_REGION"); - throw new Error("Invalid region"); - } - - await createApp(name); - - // Resolve volume: attach existing, create new, or skip - let volumeId: string | undefined = opts.volumeId; - if (!volumeId && opts.newVolumeSizeGb) { - try { - volumeId = await createVolume(name, region, opts.newVolumeSizeGb); - } catch (err) { - await cleanupOnFailure(name); - throw err; - } - } - - let machineId: string; - try { - machineId = await createMachine(name, region, opts.cpuKind, opts.cpus, opts.memoryMb, volumeId, image); - } catch (err) { - await cleanupOnFailure(name); - throw err; - } - - await waitForMachineStart(name, machineId); - - flyMachineId = machineId; - flyAppName = name; - - saveVmConnection("fly-ssh", "root", machineId, name, "fly"); -} - -// ─── Execution ─────────────────────────────────────────────────────────────── - -export async function runServer(cmd: string, timeoutSecs?: number): Promise { - const fullCmd = `export PATH="$HOME/.local/bin:$HOME/.bun/bin:$PATH" && ${cmd}`; - const flyCmd = getCmd(); - if (!flyCmd) { - throw new Error("flyctl not found in PATH — run `spawn fly` to reinstall"); - } - - // Wrap command with a background keepalive that sends a space to stderr every - // 10s. Without this, flyctl tears down silent SSH sessions ("session forcibly - // closed") when no data flows for too long (e.g. during npm install). - const wrappedCmd = `(while true; do sleep 10; printf ' ' >&2; done) & _ka=$!; (${fullCmd}); _rc=$?; kill $_ka 2>/dev/null; wait $_ka 2>/dev/null; exit $_rc`; - - const escapedCmd = wrappedCmd.replace(/'/g, "'\\''"); - // Use fly ssh console (WireGuard) instead of fly machine exec (HTTP) to avoid - // 408 deadline_exceeded on long-running commands. - const args = [ - flyCmd, - "ssh", - "console", - "-a", - flyAppName, - "-C", - `bash -c '${escapedCmd}'`, - ]; - - // Don't inherit stdin — commands like `claude install` try to read input and - // hang. Use "pipe" but keep it open until the process exits — closing stdin - // early causes flyctl to tear down the WireGuard transport ("session forcibly - // closed") before long-running commands like `bun install` finish. - const proc = Bun.spawn(args, { - stdio: [ - "pipe", - "inherit", - "inherit", - ], - env: process.env, - }); - // Local safety timer — WireGuard has no HTTP deadline but we still want a ceiling. - const timeout = (timeoutSecs || 300) * 1000; - const timer = setTimeout(() => killWithTimeout(proc), timeout); - const exitCode = await proc.exited; - try { - proc.stdin!.end(); - } catch { - /* already closed */ - } - clearTimeout(timer); - if (exitCode !== 0) { - throw new Error(`run_server failed (exit ${exitCode}): ${cmd}`); - } -} - -/** Run a command and capture stdout. */ -export async function runServerCapture(cmd: string, timeoutSecs?: number): Promise { - const fullCmd = `export PATH="$HOME/.local/bin:$HOME/.bun/bin:$PATH" && ${cmd}`; - const flyCmd = getCmd(); - if (!flyCmd) { - throw new Error("flyctl not found in PATH — run `spawn fly` to reinstall"); - } - - const escapedCmd = fullCmd.replace(/'/g, "'\\''"); - const args = [ - flyCmd, - "ssh", - "console", - "-a", - flyAppName, - "-C", - `bash -c '${escapedCmd}'`, - ]; - - const proc = Bun.spawn(args, { - stdio: [ - "pipe", - "pipe", - "pipe", - ], - env: process.env, - }); - const timeout = (timeoutSecs || 300) * 1000; - const timer = setTimeout(() => killWithTimeout(proc), timeout); - - // Drain both pipes before awaiting exit to prevent pipe buffer deadlock - const [stdout] = await Promise.all([ - new Response(proc.stdout).text(), - new Response(proc.stderr).text(), - ]); - const exitCode = await proc.exited; - try { - proc.stdin!.end(); - } catch { - /* already closed */ - } - clearTimeout(timer); - - if (exitCode !== 0) { - throw new Error(`run_server_capture failed (exit ${exitCode})`); - } - return stdout.trim(); -} - -export async function uploadFile(localPath: string, remotePath: string): Promise { - if (!/^[a-zA-Z0-9/_.~-]+$/.test(remotePath)) { - logError(`Invalid remote path: ${remotePath}`); - throw new Error("Invalid remote path"); - } - const flyCmd = getCmd(); - if (!flyCmd) { - throw new Error("flyctl not found in PATH — run `spawn fly` to reinstall"); - } - const content: Buffer = readFileSync(localPath); - const b64 = content.toString("base64"); - - // Validate base64 only contains safe characters (defense-in-depth) - if (/[^A-Za-z0-9+/=]/.test(b64)) { - logError("upload_file: base64 output contains unexpected characters"); - throw new Error("Invalid base64"); - } - - // Pipe base64 data through stdin to avoid shell interpolation of file content. - // The remote command reads from stdin, so no data is embedded in the command string. - const proc = Bun.spawn( - [ - flyCmd, - "ssh", - "console", - "-a", - flyAppName, - "-C", - `base64 -d > '${remotePath}'`, - ], - { - stdio: [ - "pipe", - "ignore", - "ignore", - ], - env: process.env, - }, - ); - try { - proc.stdin!.write(b64); - proc.stdin!.end(); - } catch { - /* already closed */ - } - const exitCode = await proc.exited; - if (exitCode !== 0) { - throw new Error(`upload_file failed for ${remotePath}`); - } -} - -export async function interactiveSession(cmd: string): Promise { - const term = sanitizeTermValue(process.env.TERM || "xterm-256color"); - // Single-quote escaping prevents premature shell expansion of $variables in cmd - // (JSON.stringify double-quoting lets the shell expand $vars before the script runs) - const shellEscapedCmd = cmd.replace(/'/g, "'\\''"); - const fullCmd = `export TERM=${term} PATH="$HOME/.local/bin:$HOME/.bun/bin:$PATH" && exec bash -l -c '${shellEscapedCmd}'`; - // Shell-quote the command for -C - const escapedCmd = fullCmd.replace(/'/g, "'\\''"); - const flyCmd = getCmd(); - if (!flyCmd) { - throw new Error("flyctl not found in PATH — run `spawn fly` to reinstall"); - } - - const exitCode = spawnInteractive([ - flyCmd, - "ssh", - "console", - "-a", - flyAppName, - "--pty", - "-C", - `bash -c '${escapedCmd}'`, - ]); - - // Post-session summary - process.stderr.write("\n"); - logWarn(`Session ended. Your service '${flyAppName}' is still running.`); - logWarn("Remember to delete it when you're done to avoid ongoing charges."); - logWarn(""); - logWarn("Manage or delete it in your dashboard:"); - logWarn(` ${FLY_DASHBOARD_URL}`); - logWarn(""); - logInfo("To delete from CLI:"); - logInfo(" spawn delete"); - logInfo("To reconnect:"); - logInfo(` fly ssh console -a ${flyAppName}`); - - return exitCode; -} - -// ─── Retry + Wait Helpers ──────────────────────────────────────────────────── - -export async function runWithRetry( - maxAttempts: number, - sleepSec: number, - timeoutSecs: number, - cmd: string, -): Promise { - for (let attempt = 1; attempt <= maxAttempts; attempt++) { - try { - await runServer(cmd, timeoutSecs); - return; - } catch { - logWarn(`Command failed (attempt ${attempt}/${maxAttempts}): ${cmd}`); - if (attempt < maxAttempts) { - await sleep(sleepSec * 1000); - } - } - } - logError(`Command failed after ${maxAttempts} attempts: ${cmd}`); - throw new Error(`runWithRetry exhausted: ${cmd}`); -} - -export async function waitForSsh(maxAttempts = 20): Promise { - logStep("Waiting for SSH connectivity..."); - for (let attempt = 1; attempt <= maxAttempts; attempt++) { - try { - const output = await runServerCapture("echo ok", 15); - if (output.includes("ok")) { - logInfo("SSH is ready"); - return; - } - } catch { - // ignore - } - logStep(`SSH not ready yet (${attempt}/${maxAttempts})`); - await sleep(5000); - } - logError(`SSH connectivity failed after ${maxAttempts} attempts`); - logError(`The machine may need more time. Try: fly ssh console -a ${flyAppName}`); - throw new Error("SSH wait timeout"); -} - -export async function waitForCloudInit(tier: CloudInitTier = "full"): Promise { - await waitForSsh(); - - const packages = getPackagesForTier(tier); - logStep("Installing packages..."); - const setupScript = [ - `echo "==> Setting up workspace volume..."`, - `if [ -d /data ]; then mkdir -p /data/work && ln -sf /data/work /root/work && echo 'cd /root/work 2>/dev/null' >> ~/.bashrc; fi`, - `echo "==> Installing base packages..."`, - "export DEBIAN_FRONTEND=noninteractive", - `apt-get update -y && apt-get install -y --no-install-recommends ${packages.join(" ")} || true`, - ...(needsNode(tier) - ? [ - `echo "==> Installing Node.js 22..."`, - `${NODE_INSTALL_CMD} || true`, - ] - : []), - ...(needsBun(tier) - ? [ - `echo "==> Checking bun..."`, - `if ! command -v bun >/dev/null 2>&1 && [ ! -f "$HOME/.bun/bin/bun" ]; then curl -fsSL https://bun.sh/install | bash || true; fi`, - ] - : []), - `for rc in ~/.bashrc ~/.zshrc; do grep -q '.bun/bin' "$rc" 2>/dev/null || echo 'export PATH="$HOME/.local/bin:$HOME/.bun/bin:$PATH"' >> "$rc"; done`, - ].join("\n"); - - try { - await runWithRetry(3, 10, 300, setupScript); - } catch { - logWarn("Package install had errors, continuing..."); - } - logInfo("Base tools installed"); -} - -// ─── Server Name ───────────────────────────────────────────────────────────── - -export async function getServerName(): Promise { - // Check env var first - if (process.env.FLY_APP_NAME) { - const name = process.env.FLY_APP_NAME; - if (!validateServerName(name)) { - logError(`Invalid FLY_APP_NAME: '${name}'`); - throw new Error("Invalid server name"); - } - logInfo(`Using app name from environment: ${name}`); - return name; - } - - const kebab = process.env.SPAWN_NAME_KEBAB || (process.env.SPAWN_NAME ? toKebabCase(process.env.SPAWN_NAME) : ""); - return kebab || defaultSpawnName(); -} - -export async function promptSpawnName(): Promise { - if (process.env.SPAWN_NAME_KEBAB) { - return; - } - - let kebab: string; - if (process.env.SPAWN_NON_INTERACTIVE === "1") { - kebab = (process.env.SPAWN_NAME ? toKebabCase(process.env.SPAWN_NAME) : "") || defaultSpawnName(); - } else { - const derived = process.env.SPAWN_NAME ? toKebabCase(process.env.SPAWN_NAME) : ""; - const fallback = derived || defaultSpawnName(); - process.stderr.write("\n"); - const answer = await prompt(`Fly machine name [${fallback}]: `); - kebab = toKebabCase(answer || fallback) || defaultSpawnName(); - } - - process.env.SPAWN_NAME_DISPLAY = kebab; - process.env.SPAWN_NAME_KEBAB = kebab; - logInfo(`Using resource name: ${kebab}`); -} - -// ─── Lifecycle ─────────────────────────────────────────────────────────────── - -export async function destroyServer(appName?: string): Promise { - const name = appName || flyAppName; - if (!name) { - logError("destroy_server: no app name provided"); - throw new Error("No app name"); - } - - logStep(`Destroying Fly.io app '${name}'...`); - - const resp = await flyApi("GET", `/apps/${name}/machines`); - const machines = parseJsonRaw(resp); - const machineList = toObjectArray(Array.isArray(machines) ? machines : []); - const ids: string[] = machineList.map((m) => (isString(m.id) ? m.id : "")).filter(Boolean); - - for (const mid of ids) { - logStep(`Stopping machine ${mid}...`); - try { - await flyApi("POST", `/apps/${name}/machines/${mid}/stop`, "{}"); - } catch { - /* ignore */ - } - await sleep(2000); - logStep(`Destroying machine ${mid}...`); - try { - await flyApi("DELETE", `/apps/${name}/machines/${mid}?force=true`); - } catch { - /* ignore */ - } - } - - const delResp = await flyApi("DELETE", `/apps/${name}`); - if (delResp.includes('"error"')) { - const data = parseJsonObj(delResp); - logError(`Failed to delete app '${name}': ${data?.error || "Unknown error"}`); - throw new Error("App deletion failed"); - } - logInfo(`App '${name}' destroyed`); -} - -export async function listServers(): Promise { - const org = flyOrg || process.env.FLY_ORG || "personal"; - const resp = await flyApi("GET", `/apps?org_slug=${org}`); - const raw = parseJsonRaw(resp); - let apps: Record[] = []; - if (Array.isArray(raw)) { - apps = toObjectArray(raw); - } else { - const record = parseJsonObj(resp); - apps = record ? toObjectArray(record.apps) : []; - } - if (apps.length === 0) { - console.log("No apps found"); - return; - } - const pad = (s: string, n: number) => (s + " ".repeat(n)).slice(0, n); - console.log(pad("NAME", 25) + pad("ID", 20) + pad("STATUS", 12) + pad("NETWORK", 20)); - console.log("-".repeat(77)); - for (const a of apps) { - console.log( - pad(String(a.name ?? "N/A").slice(0, 24), 25) + - pad(String(a.id ?? "N/A").slice(0, 19), 20) + - pad(String(a.status ?? "N/A").slice(0, 11), 12) + - pad(String(a.network ?? "N/A").slice(0, 19), 20), - ); - } -} diff --git a/packages/cli/src/fly/main.ts b/packages/cli/src/fly/main.ts deleted file mode 100644 index 423ca3acb..000000000 --- a/packages/cli/src/fly/main.ts +++ /dev/null @@ -1,116 +0,0 @@ -#!/usr/bin/env bun -// fly/main.ts — Orchestrator: deploys an agent on Fly.io - -import { - ensureFlyCli, - ensureFlyToken, - promptOrg, - promptSpawnName, - createServer, - getServerName, - waitForCloudInit, - waitForSsh, - runServer, - uploadFile, - interactiveSession, - FLY_VM_TIERS, - DEFAULT_VM_TIER, -} from "./fly"; -import type { ServerOptions } from "./fly"; -import { resolveAgent } from "./agents"; -import { saveLaunchCmd } from "../history.js"; -import { runOrchestration } from "../shared/orchestrate"; -import type { CloudOrchestrator } from "../shared/orchestrate"; -import { selectFromList } from "../shared/ui"; - -async function promptVmOptions(): Promise { - if (process.env.FLY_VM_MEMORY) { - const memoryMb = Number.parseInt(process.env.FLY_VM_MEMORY, 10); - const tier = FLY_VM_TIERS.find((t) => t.memoryMb === memoryMb) || DEFAULT_VM_TIER; - return { - cpuKind: tier.cpuKind, - cpus: tier.cpus, - memoryMb: tier.memoryMb, - }; - } - - if (process.env.SPAWN_CUSTOM !== "1") { - return { - cpuKind: DEFAULT_VM_TIER.cpuKind, - cpus: DEFAULT_VM_TIER.cpus, - memoryMb: DEFAULT_VM_TIER.memoryMb, - }; - } - - if (process.env.SPAWN_NON_INTERACTIVE === "1") { - return { - cpuKind: DEFAULT_VM_TIER.cpuKind, - cpus: DEFAULT_VM_TIER.cpus, - memoryMb: DEFAULT_VM_TIER.memoryMb, - }; - } - - process.stderr.write("\n"); - const tierItems = FLY_VM_TIERS.map((t) => `${t.id}|${t.label}`); - const tierId = await selectFromList(tierItems, "VM size", DEFAULT_VM_TIER.id); - const selectedTier = FLY_VM_TIERS.find((t) => t.id === tierId) || DEFAULT_VM_TIER; - - return { - cpuKind: selectedTier.cpuKind, - cpus: selectedTier.cpus, - memoryMb: selectedTier.memoryMb, - }; -} - -async function main() { - const agentName = process.argv[2]; - if (!agentName) { - console.error("Usage: bun run fly/main.ts "); - console.error("Agents: claude, codex, openclaw, opencode, kilocode, zeroclaw"); - process.exit(1); - } - - const agent = resolveAgent(agentName); - - let serverOpts: ServerOptions; - - const cloud: CloudOrchestrator = { - cloudName: "fly", - cloudLabel: "Fly.io", - runner: { - runServer, - uploadFile, - }, - async authenticate() { - await promptSpawnName(); - await ensureFlyCli(); - await ensureFlyToken(); - await promptOrg(); - }, - async promptSize() { - serverOpts = await promptVmOptions(); - }, - getServerName, - async createServer(name: string) { - await createServer(name, serverOpts, agent.image); - }, - async waitForReady() { - if (agent.image) { - // Custom image already has packages baked in — just wait for SSH - await waitForSsh(); - } else { - await waitForCloudInit(agent.cloudInitTier); - } - }, - interactiveSession, - saveLaunchCmd, - }; - - await runOrchestration(cloud, agent, agentName); -} - -main().catch((err) => { - const msg = err && typeof err === "object" && "message" in err ? String(err.message) : String(err); - process.stderr.write(`\x1b[0;31mFatal: ${msg}\x1b[0m\n`); - process.exit(1); -}); diff --git a/packages/cli/src/security.ts b/packages/cli/src/security.ts index 0a3e7e255..2913368bb 100644 --- a/packages/cli/src/security.ts +++ b/packages/cli/src/security.ts @@ -27,7 +27,6 @@ const USERNAME_PATTERN = /^[a-z_][a-z0-9_-]*\$?$/; // Special connection sentinel values (not actual IPs) const CONNECTION_SENTINELS = [ "sprite-console", - "fly-ssh", "daytona-sandbox", "localhost", ]; @@ -173,7 +172,7 @@ export function validateScriptContent(script: string): void { * - Valid IPv4 addresses (e.g., "192.168.1.1") * - Valid IPv6 addresses (e.g., "::1", "2001:db8::1") * - Valid hostnames (e.g., "ssh.app.daytona.io") - * - Special sentinel values ("sprite-console", "fly-ssh", "daytona-sandbox", "localhost") + * - Special sentinel values ("sprite-console", "daytona-sandbox", "localhost") * * @param ip - The IP address or sentinel to validate * @throws Error if validation fails diff --git a/packages/cli/src/shared/agent-setup.ts b/packages/cli/src/shared/agent-setup.ts index 691271bb1..73340bc91 100644 --- a/packages/cli/src/shared/agent-setup.ts +++ b/packages/cli/src/shared/agent-setup.ts @@ -353,7 +353,7 @@ export async function setupOpenclawBatched( export async function startGateway(runner: CloudRunner): Promise { logStep("Starting OpenClaw gateway daemon..."); // Start the daemon AND wait for port 18789 in a single SSH session. - // The polling loop doubles as a keepalive for flyctl. + // The polling loop doubles as a keepalive for the SSH session. const script = "source ~/.spawnrc 2>/dev/null; " + "export PATH=$HOME/.npm-global/bin:$HOME/.bun/bin:$HOME/.local/bin:$PATH; " + diff --git a/packages/cli/src/shared/ui.ts b/packages/cli/src/shared/ui.ts index e095351dd..2155cf281 100644 --- a/packages/cli/src/shared/ui.ts +++ b/packages/cli/src/shared/ui.ts @@ -1,5 +1,5 @@ // shared/ui.ts — Logging, prompts, and browser opening -// @clack/prompts is bundled into fly.js at build time. +// @clack/prompts is bundled into cli.js at build time. import * as p from "@clack/prompts"; import { isString } from "@openrouter/spawn-shared"; diff --git a/packages/shared/src/parse.ts b/packages/shared/src/parse.ts index 8f68b8a20..06273eb2f 100644 --- a/packages/shared/src/parse.ts +++ b/packages/shared/src/parse.ts @@ -20,7 +20,7 @@ export function parseJsonWith/dev/null || log_warn "Failed to tear down ${app}" @@ -111,7 +111,7 @@ trap final_cleanup EXIT # --------------------------------------------------------------------------- # Main # --------------------------------------------------------------------------- -log_header "Spawn E2E Test Suite (Fly.io)" +log_header "Spawn E2E Test Suite (AWS Lightsail)" log_info "Agents: ${AGENTS_TO_TEST}" log_info "Parallel: ${PARALLEL_COUNT:-sequential}" if [ "${SKIP_INPUT_TEST}" -eq 1 ]; then diff --git a/sh/e2e/lib/cleanup.sh b/sh/e2e/lib/cleanup.sh index d52a48523..470489c3e 100644 --- a/sh/e2e/lib/cleanup.sh +++ b/sh/e2e/lib/cleanup.sh @@ -1,50 +1,53 @@ #!/bin/bash -# e2e/lib/cleanup.sh — Find and destroy stale e2e-* apps +# e2e/lib/cleanup.sh — Find and destroy stale e2e-* Lightsail instances set -eo pipefail # --------------------------------------------------------------------------- # cleanup_stale_apps # -# Lists all apps in the org, filters for e2e-* pattern, and tears down any +# Lists all Lightsail instances, filters for e2e-* pattern, and tears down any # older than 30 minutes (based on the unix timestamp embedded in the name). # --------------------------------------------------------------------------- cleanup_stale_apps() { - log_header "Cleaning up stale e2e apps" + log_header "Cleaning up stale e2e instances" local now now=$(date +%s) local max_age=1800 # 30 minutes in seconds - # List all apps via REST API - local apps_json - apps_json=$(fly_api GET "/apps?org_slug=personal" 2>/dev/null || true) + # List all instances via AWS CLI + local instances_json + instances_json=$(aws lightsail get-instances \ + --region "${AWS_REGION}" \ + --query 'instances[].name' \ + --output json 2>/dev/null || true) - if [ -z "${apps_json}" ] || [ "${apps_json}" = "null" ]; then - log_info "Could not list apps — skipping cleanup" + if [ -z "${instances_json}" ] || [ "${instances_json}" = "null" ] || [ "${instances_json}" = "[]" ]; then + log_info "Could not list instances or no instances found — skipping cleanup" return 0 fi - # Extract app names matching e2e-* pattern - local app_names - app_names=$(printf '%s' "${apps_json}" | jq -r '.apps[]?.name // empty' 2>/dev/null | grep '^e2e-' || true) + # Extract instance names matching e2e-* pattern + local instance_names + instance_names=$(printf '%s' "${instances_json}" | jq -r '.[]? // empty' 2>/dev/null | grep '^e2e-' || true) - if [ -z "${app_names}" ]; then - log_ok "No stale e2e apps found" + if [ -z "${instance_names}" ]; then + log_ok "No stale e2e instances found" return 0 fi local cleaned=0 local skipped=0 - for app_name in ${app_names}; do + for instance_name in ${instance_names}; do # Extract timestamp from name: e2e-AGENT-TIMESTAMP # The timestamp is the last dash-separated segment local ts - ts=$(printf '%s' "${app_name}" | sed 's/.*-//') + ts=$(printf '%s' "${instance_name}" | sed 's/.*-//') # Validate it looks like a unix timestamp (all digits, 10 chars) if ! printf '%s' "${ts}" | grep -qE '^[0-9]{10}$'; then - log_warn "Skipping ${app_name} — cannot parse timestamp" + log_warn "Skipping ${instance_name} — cannot parse timestamp" skipped=$((skipped + 1)) continue fi @@ -53,8 +56,8 @@ cleanup_stale_apps() { if [ "${age}" -gt "${max_age}" ]; then local age_str age_str=$(format_duration "${age}") - log_step "Destroying stale app ${app_name} (age: ${age_str})" - teardown_agent "${app_name}" || log_warn "Failed to tear down ${app_name}" + log_step "Destroying stale instance ${instance_name} (age: ${age_str})" + teardown_agent "${instance_name}" || log_warn "Failed to tear down ${instance_name}" cleaned=$((cleaned + 1)) else skipped=$((skipped + 1)) @@ -62,9 +65,9 @@ cleanup_stale_apps() { done if [ "${cleaned}" -gt 0 ]; then - log_ok "Cleaned ${cleaned} stale app(s)" + log_ok "Cleaned ${cleaned} stale instance(s)" fi if [ "${skipped}" -gt 0 ]; then - log_info "Skipped ${skipped} recent app(s)" + log_info "Skipped ${skipped} recent instance(s)" fi } diff --git a/sh/e2e/lib/common.sh b/sh/e2e/lib/common.sh index 97df251cb..9efa9b147 100644 --- a/sh/e2e/lib/common.sh +++ b/sh/e2e/lib/common.sh @@ -1,17 +1,16 @@ #!/bin/bash -# e2e/lib/common.sh — Constants, logging, env validation, Fly API helpers +# e2e/lib/common.sh — Constants, logging, env validation for AWS Lightsail E2E set -eo pipefail # --------------------------------------------------------------------------- # Constants # --------------------------------------------------------------------------- ALL_AGENTS="claude openclaw zeroclaw codex opencode kilocode" -FLY_API_BASE="https://api.machines.dev/v1" PROVISION_TIMEOUT="${PROVISION_TIMEOUT:-480}" INSTALL_WAIT="${INSTALL_WAIT:-120}" INPUT_TEST_TIMEOUT="${INPUT_TEST_TIMEOUT:-120}" -FLY_REGION="${FLY_REGION:-iad}" -FLY_VM_MEMORY="${FLY_VM_MEMORY:-2048}" +AWS_REGION="${AWS_REGION:-us-east-1}" +AWS_BUNDLE="${AWS_BUNDLE:-nano_3_0}" # Colors RED='\033[0;31m' @@ -22,7 +21,7 @@ CYAN='\033[0;36m' BOLD='\033[1m' NC='\033[0m' -# Tracked apps for cleanup on exit +# Tracked instances for cleanup on exit _TRACKED_APPS="" # --------------------------------------------------------------------------- @@ -59,8 +58,8 @@ require_env() { local missing=0 # Check required tools - if ! command -v flyctl >/dev/null 2>&1; then - log_err "flyctl not found. Install from https://fly.io/docs/flyctl/install/" + if ! command -v aws >/dev/null 2>&1; then + log_err "aws CLI not found. Install from https://docs.aws.amazon.com/cli/latest/userguide/getting-started-install.html" missing=1 fi @@ -80,21 +79,12 @@ require_env() { missing=1 fi - # Check / generate FLY_API_TOKEN - if [ -z "${FLY_API_TOKEN:-}" ]; then - log_info "FLY_API_TOKEN not set, generating via flyctl..." - FLY_API_TOKEN=$(flyctl tokens create org personal --expiry 8h 2>/dev/null || true) - if [ -z "${FLY_API_TOKEN:-}" ]; then - log_warn "Could not generate token. Falling back to flyctl stored credentials." - # Validate flyctl is authenticated - if ! flyctl auth whoami >/dev/null 2>&1; then - log_err "flyctl is not authenticated. Run: flyctl auth login" - missing=1 - fi - else - export FLY_API_TOKEN - log_ok "Generated FLY_API_TOKEN (expires in 8h)" - fi + # Validate AWS credentials + if ! aws sts get-caller-identity --region "${AWS_REGION}" >/dev/null 2>&1; then + log_err "AWS credentials are not valid. Run: aws configure" + missing=1 + else + log_ok "AWS credentials validated" fi if [ "${missing}" -eq 1 ]; then @@ -105,44 +95,6 @@ require_env() { return 0 } -# --------------------------------------------------------------------------- -# Fly API helper -# --------------------------------------------------------------------------- -# fly_api METHOD ENDPOINT [BODY] -# Calls the Fly Machines REST API. -fly_api() { - local method="$1" - local endpoint="$2" - local body="${3:-}" - local url="${FLY_API_BASE}${endpoint}" - local auth_header - - # Detect token format for auth header - local token="${FLY_API_TOKEN:-}" - if [ -z "${token}" ]; then - # If no token, try to get one from flyctl - token=$(flyctl auth token 2>/dev/null || true) - fi - - if [ -z "${token}" ]; then - log_err "No Fly API token available" - return 1 - fi - - # FlyV1 tokens start with FlyV1, otherwise use Bearer - case "${token}" in - FlyV1\ *) auth_header="Authorization: ${token}" ;; - *) auth_header="Authorization: Bearer ${token}" ;; - esac - - local curl_args=("-s" "-X" "${method}" "-H" "${auth_header}" "-H" "Content-Type: application/json") - if [ -n "${body}" ]; then - curl_args+=("-d" "${body}") - fi - - curl "${curl_args[@]}" "${url}" -} - # --------------------------------------------------------------------------- # Helpers # --------------------------------------------------------------------------- diff --git a/sh/e2e/lib/provision.sh b/sh/e2e/lib/provision.sh index 68e38dd7e..e2ee752ca 100644 --- a/sh/e2e/lib/provision.sh +++ b/sh/e2e/lib/provision.sh @@ -1,5 +1,5 @@ #!/bin/bash -# e2e/lib/provision.sh — Provision an agent VM via spawn CLI (headless) +# e2e/lib/provision.sh — Provision an agent VM via spawn CLI on AWS Lightsail (headless) set -eo pipefail # --------------------------------------------------------------------------- @@ -8,7 +8,7 @@ set -eo pipefail # Runs spawn in headless mode with a timeout. The provision process hangs on # the interactive SSH session (step 12 of the orchestration), so we kill it # after PROVISION_TIMEOUT seconds. The install itself usually succeeds; we -# verify via app existence and .spawnrc presence afterward. +# verify via instance existence and .spawnrc presence afterward. # --------------------------------------------------------------------------- provision_agent() { local agent="$1" @@ -34,28 +34,18 @@ provision_agent() { rm -f "${exit_file}" # Environment for headless provisioning - # FLY_API_TOKEN="" forces spawn to use flyctl stored credentials (see plan section 6) # MODEL_ID bypasses the interactive model selection prompt (required by openclaw) - # - # Validate flyctl is authenticated before proceeding with empty token fallback - if [ -z "${FLY_API_TOKEN:-}" ]; then - if ! flyctl auth whoami >/dev/null 2>&1; then - log_err "FLY_API_TOKEN is empty and flyctl is not authenticated. Run: flyctl auth login" - return 1 - fi - fi ( export SPAWN_NON_INTERACTIVE=1 export SPAWN_SKIP_GITHUB_AUTH=1 export SPAWN_SKIP_API_VALIDATION=1 export MODEL_ID="${MODEL_ID:-openrouter/auto}" - export FLY_APP_NAME="${app_name}" - export FLY_REGION="${FLY_REGION}" - export FLY_VM_MEMORY="${FLY_VM_MEMORY}" - export FLY_API_TOKEN="" + export AWS_LIGHTSAIL_INSTANCE_NAME="${app_name}" + export AWS_REGION="${AWS_REGION}" + export AWS_BUNDLE="${AWS_BUNDLE}" export OPENROUTER_API_KEY="${OPENROUTER_API_KEY}" - bun run "${cli_entry}" "${agent}" fly --headless --output json \ + bun run "${cli_entry}" "${agent}" aws --headless --output json \ > "${stdout_file}" 2> "${stderr_file}" printf '%s' "$?" > "${exit_file}" ) & @@ -84,22 +74,15 @@ provision_agent() { exit_code=$(cat "${exit_file}") fi - # Even if provision "failed" (timeout), the app may exist and install may have completed. - # Verify app existence via flyctl + REST API fallback. + # Even if provision "failed" (timeout), the instance may exist and install may have completed. + # Verify instance existence via AWS CLI. local app_exists=0 - if flyctl status -a "${app_name}" >/dev/null 2>&1; then + if aws lightsail get-instance --instance-name "${app_name}" --region "${AWS_REGION}" >/dev/null 2>&1; then app_exists=1 - else - # REST API fallback - local api_result - api_result=$(fly_api GET "/apps/${app_name}/machines" 2>/dev/null || true) - if printf '%s' "${api_result}" | jq -e '.[0].id' >/dev/null 2>&1; then - app_exists=1 - fi fi if [ "${app_exists}" -eq 0 ]; then - log_err "App ${app_name} does not exist after provisioning" + log_err "Instance ${app_name} does not exist after provisioning" if [ -f "${stderr_file}" ]; then log_err "Stderr tail:" tail -20 "${stderr_file}" >&2 || true @@ -107,14 +90,34 @@ provision_agent() { return 1 fi - log_ok "App ${app_name} exists" + log_ok "Instance ${app_name} exists" + + # Resolve instance public IP + local instance_ip + instance_ip=$(aws lightsail get-instance \ + --instance-name "${app_name}" \ + --region "${AWS_REGION}" \ + --query 'instance.publicIpAddress' \ + --output text 2>/dev/null || true) + + if [ -z "${instance_ip}" ] || [ "${instance_ip}" = "None" ]; then + log_err "Could not resolve public IP for ${app_name}" + return 1 + fi + + log_ok "Instance IP: ${instance_ip}" + + # Store IP in a file for verify/teardown to read + printf '%s' "${instance_ip}" > "${log_dir}/${app_name}.ip" # Wait for install to complete (.spawnrc is written near the end) log_step "Waiting for install to complete (polling .spawnrc, up to ${INSTALL_WAIT}s)..." local install_waited=0 local install_ok=0 while [ "${install_waited}" -lt "${INSTALL_WAIT}" ]; do - if fly_ssh "${app_name}" "test -f ~/.spawnrc" >/dev/null 2>&1; then + if ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o ConnectTimeout=5 \ + -o LogLevel=ERROR -o BatchMode=yes \ + "ubuntu@${instance_ip}" "test -f ~/.spawnrc" >/dev/null 2>&1; then install_ok=1 break fi diff --git a/sh/e2e/lib/teardown.sh b/sh/e2e/lib/teardown.sh index 45d242792..20cee5c22 100644 --- a/sh/e2e/lib/teardown.sh +++ b/sh/e2e/lib/teardown.sh @@ -1,61 +1,33 @@ #!/bin/bash -# e2e/lib/teardown.sh — Tear down a Fly.io app via REST API +# e2e/lib/teardown.sh — Tear down an AWS Lightsail instance set -eo pipefail # --------------------------------------------------------------------------- # teardown_agent APP_NAME # -# 1. List machines in the app -# 2. Stop each machine -# 3. Delete each machine (force) -# 4. Delete the app +# 1. Delete the Lightsail instance (with --force-delete-add-ons) +# 2. Verify deletion # --------------------------------------------------------------------------- teardown_agent() { local app="$1" log_step "Tearing down ${app}..." - # Get machines list - local machines_json - machines_json=$(fly_api GET "/apps/${app}/machines" 2>/dev/null || true) + # Delete the instance + aws lightsail delete-instance \ + --instance-name "${app}" \ + --region "${AWS_REGION}" \ + --force-delete-add-ons \ + >/dev/null 2>&1 || true - if [ -z "${machines_json}" ] || [ "${machines_json}" = "null" ]; then - log_warn "No machines response for ${app} — attempting app delete anyway" - fly_api DELETE "/apps/${app}" >/dev/null 2>&1 || true - untrack_app "${app}" - return 0 - fi - - # Extract machine IDs - local machine_ids - machine_ids=$(printf '%s' "${machines_json}" | jq -r '.[].id // empty' 2>/dev/null || true) - - if [ -n "${machine_ids}" ]; then - # Stop each machine - for mid in ${machine_ids}; do - log_step "Stopping machine ${mid}..." - fly_api POST "/apps/${app}/machines/${mid}/stop" '{}' >/dev/null 2>&1 || true - done - - # Brief wait for stop to propagate - sleep 2 - - # Force-delete each machine - for mid in ${machine_ids}; do - log_step "Deleting machine ${mid}..." - fly_api DELETE "/apps/${app}/machines/${mid}?force=true" >/dev/null 2>&1 || true - done - fi - - # Delete the app - log_step "Deleting app ${app}..." - fly_api DELETE "/apps/${app}" >/dev/null 2>&1 || true + # Brief wait for deletion to propagate + sleep 2 # Verify deletion - if flyctl status -a "${app}" >/dev/null 2>&1; then - log_warn "App ${app} may still exist (flyctl still reports it)" + if aws lightsail get-instance --instance-name "${app}" --region "${AWS_REGION}" >/dev/null 2>&1; then + log_warn "Instance ${app} may still exist (AWS still reports it)" else - log_ok "App ${app} torn down" + log_ok "Instance ${app} torn down" fi untrack_app "${app}" diff --git a/sh/e2e/lib/verify.sh b/sh/e2e/lib/verify.sh index 06768f4b8..49196cd3c 100644 --- a/sh/e2e/lib/verify.sh +++ b/sh/e2e/lib/verify.sh @@ -1,75 +1,80 @@ #!/bin/bash -# e2e/lib/verify.sh — SSH helpers and per-agent verification +# e2e/lib/verify.sh — SSH helpers and per-agent verification for AWS Lightsail set -eo pipefail # --------------------------------------------------------------------------- -# Machine ID cache (avoid repeated API calls) +# Instance IP cache (avoid repeated API calls) # --------------------------------------------------------------------------- -_FLY_MACHINE_ID="" -_FLY_MACHINE_APP="" +_AWS_INSTANCE_IP="" +_AWS_INSTANCE_APP="" # --------------------------------------------------------------------------- -# fly_ssh APP_NAME COMMAND +# aws_ssh APP_NAME COMMAND # -# Resolves machine ID, base64-encodes the command, and runs it via -# flyctl machine exec. Safety relies on two properties together: -# 1. Base64 output alphabet [A-Za-z0-9+/=] cannot contain single quotes -# 2. Single-quote wrapping in the exec command prevents shell expansion +# Resolves instance IP, then runs a command via SSH. # Returns the exit code of the remote command. # --------------------------------------------------------------------------- -fly_ssh() { +aws_ssh() { local app="$1" local cmd="$2" - # Resolve machine ID (cached per app) - if [ "${_FLY_MACHINE_APP}" != "${app}" ] || [ -z "${_FLY_MACHINE_ID}" ]; then - _FLY_MACHINE_ID=$(flyctl machines list -a "${app}" --json 2>/dev/null | jq -r '.[0].id') - _FLY_MACHINE_APP="${app}" - if [ -z "${_FLY_MACHINE_ID}" ] || [ "${_FLY_MACHINE_ID}" = "null" ]; then - log_err "Could not resolve machine ID for app ${app}" + # Resolve instance IP (cached per app) + if [ "${_AWS_INSTANCE_APP}" != "${app}" ] || [ -z "${_AWS_INSTANCE_IP}" ]; then + # Try reading from the IP file first (written by provision.sh) + if [ -n "${LOG_DIR:-}" ] && [ -f "${LOG_DIR}/${app}.ip" ]; then + _AWS_INSTANCE_IP=$(cat "${LOG_DIR}/${app}.ip") + else + _AWS_INSTANCE_IP=$(aws lightsail get-instance \ + --instance-name "${app}" \ + --region "${AWS_REGION}" \ + --query 'instance.publicIpAddress' \ + --output text 2>/dev/null || true) + fi + _AWS_INSTANCE_APP="${app}" + if [ -z "${_AWS_INSTANCE_IP}" ] || [ "${_AWS_INSTANCE_IP}" = "None" ]; then + log_err "Could not resolve IP for instance ${app}" return 1 fi fi - # Base64-encode command for safe embedding in single quotes. - # base64 output [A-Za-z0-9+/=] cannot break out of single-quote context. - # -w 0 is GNU coreutils (Linux); falls back to plain base64 (macOS/BSD). - local encoded_cmd - encoded_cmd=$(printf '%s' "${cmd}" | base64 -w 0 2>/dev/null || printf '%s' "${cmd}" | base64) - - flyctl machine exec "${_FLY_MACHINE_ID}" -a "${app}" --timeout 30 \ - "echo '${encoded_cmd}' | base64 -d | sh" + ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null \ + -o ConnectTimeout=10 -o LogLevel=ERROR -o BatchMode=yes \ + "ubuntu@${_AWS_INSTANCE_IP}" "${cmd}" } # --------------------------------------------------------------------------- -# fly_ssh_long APP_NAME COMMAND TIMEOUT +# aws_ssh_long APP_NAME COMMAND TIMEOUT # -# Same as fly_ssh() but with a configurable timeout for long-running commands +# Same as aws_ssh() but with a configurable timeout for long-running commands # like input tests that send prompts to agents. # --------------------------------------------------------------------------- -fly_ssh_long() { +aws_ssh_long() { local app="$1" local cmd="$2" local timeout="${3:-120}" - # Resolve machine ID (cached per app) - if [ "${_FLY_MACHINE_APP}" != "${app}" ] || [ -z "${_FLY_MACHINE_ID}" ]; then - _FLY_MACHINE_ID=$(flyctl machines list -a "${app}" --json 2>/dev/null | jq -r '.[0].id') - _FLY_MACHINE_APP="${app}" - if [ -z "${_FLY_MACHINE_ID}" ] || [ "${_FLY_MACHINE_ID}" = "null" ]; then - log_err "Could not resolve machine ID for app ${app}" + # Resolve instance IP (cached per app) + if [ "${_AWS_INSTANCE_APP}" != "${app}" ] || [ -z "${_AWS_INSTANCE_IP}" ]; then + if [ -n "${LOG_DIR:-}" ] && [ -f "${LOG_DIR}/${app}.ip" ]; then + _AWS_INSTANCE_IP=$(cat "${LOG_DIR}/${app}.ip") + else + _AWS_INSTANCE_IP=$(aws lightsail get-instance \ + --instance-name "${app}" \ + --region "${AWS_REGION}" \ + --query 'instance.publicIpAddress' \ + --output text 2>/dev/null || true) + fi + _AWS_INSTANCE_APP="${app}" + if [ -z "${_AWS_INSTANCE_IP}" ] || [ "${_AWS_INSTANCE_IP}" = "None" ]; then + log_err "Could not resolve IP for instance ${app}" return 1 fi fi - # Base64-encode command for safe embedding in single quotes. - # base64 output [A-Za-z0-9+/=] cannot break out of single-quote context. - # -w 0 is GNU coreutils (Linux); falls back to plain base64 (macOS/BSD). - local encoded_cmd - encoded_cmd=$(printf '%s' "${cmd}" | base64 -w 0 2>/dev/null || printf '%s' "${cmd}" | base64) - - flyctl machine exec "${_FLY_MACHINE_ID}" -a "${app}" --timeout "${timeout}" \ - "echo '${encoded_cmd}' | base64 -d | sh" + ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null \ + -o ConnectTimeout=10 -o LogLevel=ERROR -o BatchMode=yes \ + -o "ServerAliveInterval=15" -o "ServerAliveCountMax=$((timeout / 15 + 1))" \ + "ubuntu@${_AWS_INSTANCE_IP}" "timeout ${timeout} sh -c '${cmd}'" } # --------------------------------------------------------------------------- @@ -92,7 +97,7 @@ input_test_claude() { local app="$1" log_step "Running input test for claude..." - # Base64-encode prompt for safe embedding in single quotes below. + # Base64-encode prompt for safe embedding. # -w 0 is GNU coreutils (Linux); falls back to plain base64 (macOS/BSD). local encoded_prompt encoded_prompt=$(printf '%s' "${INPUT_TEST_PROMPT}" | base64 -w 0 2>/dev/null || printf '%s' "${INPUT_TEST_PROMPT}" | base64) @@ -100,10 +105,10 @@ input_test_claude() { remote_cmd="source ~/.spawnrc 2>/dev/null; \ export PATH=\$HOME/.claude/local/bin:\$HOME/.local/bin:\$HOME/.bun/bin:\$PATH; \ rm -rf /tmp/e2e-test && mkdir -p /tmp/e2e-test && cd /tmp/e2e-test && git init -q; \ - PROMPT=\$(echo '${encoded_prompt}' | base64 -d); claude -p \"\$PROMPT\" 2>/dev/null" + PROMPT=\$(printf '%s' '${encoded_prompt}' | base64 -d); claude -p \"\$PROMPT\" 2>/dev/null" local output - output=$(fly_ssh_long "${app}" "${remote_cmd}" "${INPUT_TEST_TIMEOUT}" 2>&1) || true + output=$(aws_ssh_long "${app}" "${remote_cmd}" "${INPUT_TEST_TIMEOUT}" 2>&1) || true if printf '%s' "${output}" | grep -q "${INPUT_TEST_MARKER}"; then log_ok "claude input test — marker found in response" @@ -120,17 +125,16 @@ input_test_codex() { local app="$1" log_step "Running input test for codex..." - # Base64-encode prompt for safe embedding in single quotes below. local encoded_prompt encoded_prompt=$(printf '%s' "${INPUT_TEST_PROMPT}" | base64 -w 0 2>/dev/null || printf '%s' "${INPUT_TEST_PROMPT}" | base64) local remote_cmd remote_cmd="source ~/.spawnrc 2>/dev/null; source ~/.zshrc 2>/dev/null; \ export PATH=\$HOME/.local/bin:\$HOME/.bun/bin:\$PATH; \ rm -rf /tmp/e2e-test && mkdir -p /tmp/e2e-test && cd /tmp/e2e-test && git init -q; \ - PROMPT=\$(echo '${encoded_prompt}' | base64 -d); codex -q \"\$PROMPT\" 2>/dev/null" + PROMPT=\$(printf '%s' '${encoded_prompt}' | base64 -d); codex -q \"\$PROMPT\" 2>/dev/null" local output - output=$(fly_ssh_long "${app}" "${remote_cmd}" "${INPUT_TEST_TIMEOUT}" 2>&1) || true + output=$(aws_ssh_long "${app}" "${remote_cmd}" "${INPUT_TEST_TIMEOUT}" 2>&1) || true if printf '%s' "${output}" | grep -q "${INPUT_TEST_MARKER}"; then log_ok "codex input test — marker found in response" @@ -150,21 +154,20 @@ input_test_openclaw() { # Pre-check: verify the gateway is running on :18789 log_step "Checking openclaw gateway on :18789..." - if ! fly_ssh "${app}" "curl -sf http://localhost:18789/health >/dev/null 2>&1 || ss -tlnp | grep -q 18789" >/dev/null 2>&1; then + if ! aws_ssh "${app}" "curl -sf http://localhost:18789/health >/dev/null 2>&1 || ss -tlnp | grep -q 18789" >/dev/null 2>&1; then log_warn "openclaw gateway not detected on :18789 — attempting test anyway" fi - # Base64-encode prompt for safe embedding in single quotes below. local encoded_prompt encoded_prompt=$(printf '%s' "${INPUT_TEST_PROMPT}" | base64 -w 0 2>/dev/null || printf '%s' "${INPUT_TEST_PROMPT}" | base64) local remote_cmd remote_cmd="source ~/.spawnrc 2>/dev/null; \ export PATH=\$HOME/.bun/bin:\$HOME/.local/bin:\$PATH; \ rm -rf /tmp/e2e-test && mkdir -p /tmp/e2e-test && cd /tmp/e2e-test && git init -q; \ - PROMPT=\$(echo '${encoded_prompt}' | base64 -d); openclaw -p \"\$PROMPT\" 2>/dev/null" + PROMPT=\$(printf '%s' '${encoded_prompt}' | base64 -d); openclaw -p \"\$PROMPT\" 2>/dev/null" local output - output=$(fly_ssh_long "${app}" "${remote_cmd}" "${INPUT_TEST_TIMEOUT}" 2>&1) || true + output=$(aws_ssh_long "${app}" "${remote_cmd}" "${INPUT_TEST_TIMEOUT}" 2>&1) || true if printf '%s' "${output}" | grep -q "${INPUT_TEST_MARKER}"; then log_ok "openclaw input test — marker found in response" @@ -181,16 +184,15 @@ input_test_zeroclaw() { local app="$1" log_step "Running input test for zeroclaw..." - # Base64-encode prompt for safe embedding in single quotes below. local encoded_prompt encoded_prompt=$(printf '%s' "${INPUT_TEST_PROMPT}" | base64 -w 0 2>/dev/null || printf '%s' "${INPUT_TEST_PROMPT}" | base64) local remote_cmd remote_cmd="source ~/.spawnrc 2>/dev/null; source ~/.cargo/env 2>/dev/null; \ rm -rf /tmp/e2e-test && mkdir -p /tmp/e2e-test && cd /tmp/e2e-test && git init -q; \ - PROMPT=\$(echo '${encoded_prompt}' | base64 -d); zeroclaw agent -p \"\$PROMPT\" 2>/dev/null" + PROMPT=\$(printf '%s' '${encoded_prompt}' | base64 -d); zeroclaw agent -p \"\$PROMPT\" 2>/dev/null" local output - output=$(fly_ssh_long "${app}" "${remote_cmd}" "${INPUT_TEST_TIMEOUT}" 2>&1) || true + output=$(aws_ssh_long "${app}" "${remote_cmd}" "${INPUT_TEST_TIMEOUT}" 2>&1) || true if printf '%s' "${output}" | grep -q "${INPUT_TEST_MARKER}"; then log_ok "zeroclaw input test — marker found in response" @@ -260,7 +262,7 @@ verify_common() { # 1. SSH connectivity log_step "Checking SSH connectivity..." - if fly_ssh "${app}" "echo e2e-ssh-ok" 2>/dev/null | grep -q "e2e-ssh-ok"; then + if aws_ssh "${app}" "echo e2e-ssh-ok" 2>/dev/null | grep -q "e2e-ssh-ok"; then log_ok "SSH connectivity" else log_err "SSH connectivity failed" @@ -269,7 +271,7 @@ verify_common() { # 2. .spawnrc exists log_step "Checking .spawnrc exists..." - if fly_ssh "${app}" "test -f ~/.spawnrc" >/dev/null 2>&1; then + if aws_ssh "${app}" "test -f ~/.spawnrc" >/dev/null 2>&1; then log_ok ".spawnrc exists" else log_err ".spawnrc not found" @@ -278,7 +280,7 @@ verify_common() { # 3. .spawnrc has OPENROUTER_API_KEY log_step "Checking OPENROUTER_API_KEY in .spawnrc..." - if fly_ssh "${app}" "grep -q OPENROUTER_API_KEY ~/.spawnrc" >/dev/null 2>&1; then + if aws_ssh "${app}" "grep -q OPENROUTER_API_KEY ~/.spawnrc" >/dev/null 2>&1; then log_ok "OPENROUTER_API_KEY present in .spawnrc" else log_err "OPENROUTER_API_KEY not found in .spawnrc" @@ -299,7 +301,7 @@ verify_claude() { # Binary check log_step "Checking claude binary..." - if fly_ssh "${app}" "PATH=\$HOME/.claude/local/bin:\$HOME/.local/bin:\$HOME/.bun/bin:\$PATH command -v claude" >/dev/null 2>&1; then + if aws_ssh "${app}" "PATH=\$HOME/.claude/local/bin:\$HOME/.local/bin:\$HOME/.bun/bin:\$PATH command -v claude" >/dev/null 2>&1; then log_ok "claude binary found" else log_err "claude binary not found" @@ -308,7 +310,7 @@ verify_claude() { # Config check log_step "Checking claude config..." - if fly_ssh "${app}" "test -f ~/.claude/settings.json" >/dev/null 2>&1; then + if aws_ssh "${app}" "test -f ~/.claude/settings.json" >/dev/null 2>&1; then log_ok "~/.claude/settings.json exists" else log_err "~/.claude/settings.json not found" @@ -317,7 +319,7 @@ verify_claude() { # Env check log_step "Checking claude env (openrouter base url)..." - if fly_ssh "${app}" "grep -q openrouter.ai ~/.spawnrc" >/dev/null 2>&1; then + if aws_ssh "${app}" "grep -q openrouter.ai ~/.spawnrc" >/dev/null 2>&1; then log_ok "openrouter.ai configured in .spawnrc" else log_err "openrouter.ai not found in .spawnrc" @@ -333,7 +335,7 @@ verify_openclaw() { # Binary check log_step "Checking openclaw binary..." - if fly_ssh "${app}" "PATH=\$HOME/.bun/bin:\$HOME/.local/bin:\$PATH command -v openclaw" >/dev/null 2>&1; then + if aws_ssh "${app}" "PATH=\$HOME/.bun/bin:\$HOME/.local/bin:\$PATH command -v openclaw" >/dev/null 2>&1; then log_ok "openclaw binary found" else log_err "openclaw binary not found" @@ -342,7 +344,7 @@ verify_openclaw() { # Env check log_step "Checking openclaw env (ANTHROPIC_API_KEY)..." - if fly_ssh "${app}" "grep -q ANTHROPIC_API_KEY ~/.spawnrc" >/dev/null 2>&1; then + if aws_ssh "${app}" "grep -q ANTHROPIC_API_KEY ~/.spawnrc" >/dev/null 2>&1; then log_ok "ANTHROPIC_API_KEY present in .spawnrc" else log_err "ANTHROPIC_API_KEY not found in .spawnrc" @@ -358,7 +360,7 @@ verify_zeroclaw() { # Binary check (requires cargo env) log_step "Checking zeroclaw binary..." - if fly_ssh "${app}" "source ~/.cargo/env 2>/dev/null; command -v zeroclaw" >/dev/null 2>&1; then + if aws_ssh "${app}" "source ~/.cargo/env 2>/dev/null; command -v zeroclaw" >/dev/null 2>&1; then log_ok "zeroclaw binary found" else log_err "zeroclaw binary not found" @@ -367,7 +369,7 @@ verify_zeroclaw() { # Env check: ZEROCLAW_PROVIDER log_step "Checking zeroclaw env (ZEROCLAW_PROVIDER)..." - if fly_ssh "${app}" "grep -q ZEROCLAW_PROVIDER ~/.spawnrc" >/dev/null 2>&1; then + if aws_ssh "${app}" "grep -q ZEROCLAW_PROVIDER ~/.spawnrc" >/dev/null 2>&1; then log_ok "ZEROCLAW_PROVIDER present in .spawnrc" else log_err "ZEROCLAW_PROVIDER not found in .spawnrc" @@ -376,7 +378,7 @@ verify_zeroclaw() { # Env check: provider is openrouter log_step "Checking zeroclaw uses openrouter..." - if fly_ssh "${app}" "grep ZEROCLAW_PROVIDER ~/.spawnrc | grep -q openrouter" >/dev/null 2>&1; then + if aws_ssh "${app}" "grep ZEROCLAW_PROVIDER ~/.spawnrc | grep -q openrouter" >/dev/null 2>&1; then log_ok "ZEROCLAW_PROVIDER set to openrouter" else log_err "ZEROCLAW_PROVIDER not set to openrouter" @@ -392,7 +394,7 @@ verify_codex() { # Binary check log_step "Checking codex binary..." - if fly_ssh "${app}" "source ~/.spawnrc 2>/dev/null; source ~/.zshrc 2>/dev/null; command -v codex" >/dev/null 2>&1; then + if aws_ssh "${app}" "source ~/.spawnrc 2>/dev/null; source ~/.zshrc 2>/dev/null; command -v codex" >/dev/null 2>&1; then log_ok "codex binary found" else log_err "codex binary not found" @@ -401,7 +403,7 @@ verify_codex() { # Config check log_step "Checking codex config..." - if fly_ssh "${app}" "test -f ~/.codex/config.toml" >/dev/null 2>&1; then + if aws_ssh "${app}" "test -f ~/.codex/config.toml" >/dev/null 2>&1; then log_ok "~/.codex/config.toml exists" else log_err "~/.codex/config.toml not found" @@ -410,7 +412,7 @@ verify_codex() { # Env check log_step "Checking codex env (OPENROUTER_API_KEY)..." - if fly_ssh "${app}" "grep -q OPENROUTER_API_KEY ~/.spawnrc" >/dev/null 2>&1; then + if aws_ssh "${app}" "grep -q OPENROUTER_API_KEY ~/.spawnrc" >/dev/null 2>&1; then log_ok "OPENROUTER_API_KEY present in .spawnrc" else log_err "OPENROUTER_API_KEY not found in .spawnrc" @@ -426,7 +428,7 @@ verify_opencode() { # Binary check log_step "Checking opencode binary..." - if fly_ssh "${app}" "PATH=\$HOME/.opencode/bin:\$PATH command -v opencode" >/dev/null 2>&1; then + if aws_ssh "${app}" "PATH=\$HOME/.opencode/bin:\$PATH command -v opencode" >/dev/null 2>&1; then log_ok "opencode binary found" else log_err "opencode binary not found" @@ -435,7 +437,7 @@ verify_opencode() { # Env check log_step "Checking opencode env (OPENROUTER_API_KEY)..." - if fly_ssh "${app}" "grep -q OPENROUTER_API_KEY ~/.spawnrc" >/dev/null 2>&1; then + if aws_ssh "${app}" "grep -q OPENROUTER_API_KEY ~/.spawnrc" >/dev/null 2>&1; then log_ok "OPENROUTER_API_KEY present in .spawnrc" else log_err "OPENROUTER_API_KEY not found in .spawnrc" @@ -451,7 +453,7 @@ verify_kilocode() { # Binary check log_step "Checking kilocode binary..." - if fly_ssh "${app}" "source ~/.spawnrc 2>/dev/null; source ~/.zshrc 2>/dev/null; command -v kilocode" >/dev/null 2>&1; then + if aws_ssh "${app}" "source ~/.spawnrc 2>/dev/null; source ~/.zshrc 2>/dev/null; command -v kilocode" >/dev/null 2>&1; then log_ok "kilocode binary found" else log_err "kilocode binary not found" @@ -460,7 +462,7 @@ verify_kilocode() { # Env check: KILO_PROVIDER_TYPE log_step "Checking kilocode env (KILO_PROVIDER_TYPE)..." - if fly_ssh "${app}" "grep -q KILO_PROVIDER_TYPE ~/.spawnrc" >/dev/null 2>&1; then + if aws_ssh "${app}" "grep -q KILO_PROVIDER_TYPE ~/.spawnrc" >/dev/null 2>&1; then log_ok "KILO_PROVIDER_TYPE present in .spawnrc" else log_err "KILO_PROVIDER_TYPE not found in .spawnrc" @@ -469,7 +471,7 @@ verify_kilocode() { # Env check: provider is openrouter log_step "Checking kilocode uses openrouter..." - if fly_ssh "${app}" "grep KILO_PROVIDER_TYPE ~/.spawnrc | grep -q openrouter" >/dev/null 2>&1; then + if aws_ssh "${app}" "grep KILO_PROVIDER_TYPE ~/.spawnrc | grep -q openrouter" >/dev/null 2>&1; then log_ok "KILO_PROVIDER_TYPE set to openrouter" else log_err "KILO_PROVIDER_TYPE not set to openrouter" @@ -490,9 +492,9 @@ verify_agent() { local app="$2" local total_failures=0 - # Reset machine ID cache for each agent - _FLY_MACHINE_ID="" - _FLY_MACHINE_APP="" + # Reset instance IP cache for each agent + _AWS_INSTANCE_IP="" + _AWS_INSTANCE_APP="" log_header "Verifying ${agent} (${app})" diff --git a/sh/fly/README.md b/sh/fly/README.md deleted file mode 100644 index ab9e67ee7..000000000 --- a/sh/fly/README.md +++ /dev/null @@ -1,82 +0,0 @@ -# Fly.io - -Fly.io Machines via REST API and flyctl CLI. [Fly.io](https://fly.io) - -## Architecture - -The Fly.io provider is implemented in TypeScript (Bun runtime). Each `.sh` agent -script is a thin shim that ensures bun is installed, downloads the TS sources if -running via `bash <(curl ...)`, and delegates to `main.ts`. - -``` -fly/ - main.ts # Orchestrator: auth → provision → install → launch - lib/ - fly.ts # Core provider: API client, auth, orgs, provisioning - agents.ts # Agent configs (all 6) + shared install/config helpers - oauth.ts # OpenRouter OAuth flow (Bun.serve), key validation - ui.ts # Logging (ANSI), prompts (readline), browser open - {agent}.sh # Thin bash shim → bun run main.ts {agent} -``` - -**No external dependencies** — all modules use built-in Bun/Node APIs only. -The `fly/` directory has no `package.json`. - -## Agents - -#### Claude Code - -```bash -bash <(curl -fsSL https://openrouter.ai/labs/spawn/fly/claude.sh) -``` - -#### OpenClaw - -```bash -bash <(curl -fsSL https://openrouter.ai/labs/spawn/fly/openclaw.sh) -``` - -#### ZeroClaw - -```bash -bash <(curl -fsSL https://openrouter.ai/labs/spawn/fly/zeroclaw.sh) -``` - -#### Codex CLI - -```bash -bash <(curl -fsSL https://openrouter.ai/labs/spawn/fly/codex.sh) -``` - -#### OpenCode - -```bash -bash <(curl -fsSL https://openrouter.ai/labs/spawn/fly/opencode.sh) -``` - -#### Kilo Code - -```bash -bash <(curl -fsSL https://openrouter.ai/labs/spawn/fly/kilocode.sh) -``` - -## Non-Interactive Mode - -```bash -FLY_APP_NAME=dev-mk1 \ -FLY_API_TOKEN=your-token \ -OPENROUTER_API_KEY=sk-or-v1-xxxxx \ - bash <(curl -fsSL https://openrouter.ai/labs/spawn/fly/claude.sh) -``` - -## Environment Variables - -| Variable | Description | Default | -|----------|-------------|---------| -| `FLY_API_TOKEN` | Fly.io API token | _(prompted or from flyctl auth)_ | -| `FLY_APP_NAME` | App name | _(prompted)_ | -| `FLY_REGION` | Deployment region | `iad` | -| `FLY_VM_SIZE` | VM size | `shared-cpu-1x` | -| `FLY_VM_MEMORY` | VM memory (MB) | `1024` | -| `FLY_ORG` | Organization slug | `personal` | -| `OPENROUTER_API_KEY` | OpenRouter API key | _(OAuth or prompted)_ | diff --git a/sh/fly/claude.sh b/sh/fly/claude.sh deleted file mode 100644 index 9878a9800..000000000 --- a/sh/fly/claude.sh +++ /dev/null @@ -1,29 +0,0 @@ -#!/bin/bash -set -eo pipefail - -# Thin shim: ensures bun is available, runs bundled fly.js (local or from GitHub release) - -_ensure_bun() { - if command -v bun &>/dev/null; then return 0; fi - printf '\033[0;36mInstalling bun...\033[0m\n' >&2 - curl -fsSL --show-error https://bun.sh/install | bash >/dev/null || { printf '\033[0;31mFailed to install bun\033[0m\n' >&2; exit 1; } - export PATH="$HOME/.bun/bin:$PATH" - command -v bun &>/dev/null || { printf '\033[0;31mbun not found after install\033[0m\n' >&2; exit 1; } -} - -_ensure_bun - -SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" 2>/dev/null && pwd)" - -# Local checkout — run from source -if [[ -n "$SCRIPT_DIR" && -f "$SCRIPT_DIR/../../packages/cli/src/fly/main.ts" ]]; then - exec bun run "$SCRIPT_DIR/../../packages/cli/src/fly/main.ts" claude "$@" -fi - -# Remote — download bundled fly.js from GitHub release -FLY_JS=$(mktemp) -trap 'rm -f "$FLY_JS"' EXIT -curl -fsSL "https://github.com/OpenRouterTeam/spawn/releases/download/fly-latest/fly.js" -o "$FLY_JS" \ - || { printf '\033[0;31mFailed to download fly.js\033[0m\n' >&2; exit 1; } - -exec bun run "$FLY_JS" claude "$@" diff --git a/sh/fly/codex.sh b/sh/fly/codex.sh deleted file mode 100644 index a821d4793..000000000 --- a/sh/fly/codex.sh +++ /dev/null @@ -1,29 +0,0 @@ -#!/bin/bash -set -eo pipefail - -# Thin shim: ensures bun is available, runs bundled fly.js (local or from GitHub release) - -_ensure_bun() { - if command -v bun &>/dev/null; then return 0; fi - printf '\033[0;36mInstalling bun...\033[0m\n' >&2 - curl -fsSL --show-error https://bun.sh/install | bash >/dev/null || { printf '\033[0;31mFailed to install bun\033[0m\n' >&2; exit 1; } - export PATH="$HOME/.bun/bin:$PATH" - command -v bun &>/dev/null || { printf '\033[0;31mbun not found after install\033[0m\n' >&2; exit 1; } -} - -_ensure_bun - -SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" 2>/dev/null && pwd)" - -# Local checkout — run from source -if [[ -n "$SCRIPT_DIR" && -f "$SCRIPT_DIR/../../packages/cli/src/fly/main.ts" ]]; then - exec bun run "$SCRIPT_DIR/../../packages/cli/src/fly/main.ts" codex "$@" -fi - -# Remote — download bundled fly.js from GitHub release -FLY_JS=$(mktemp) -trap 'rm -f "$FLY_JS"' EXIT -curl -fsSL "https://github.com/OpenRouterTeam/spawn/releases/download/fly-latest/fly.js" -o "$FLY_JS" \ - || { printf '\033[0;31mFailed to download fly.js\033[0m\n' >&2; exit 1; } - -exec bun run "$FLY_JS" codex "$@" diff --git a/sh/fly/kilocode.sh b/sh/fly/kilocode.sh deleted file mode 100644 index d0806ef12..000000000 --- a/sh/fly/kilocode.sh +++ /dev/null @@ -1,29 +0,0 @@ -#!/bin/bash -set -eo pipefail - -# Thin shim: ensures bun is available, runs bundled fly.js (local or from GitHub release) - -_ensure_bun() { - if command -v bun &>/dev/null; then return 0; fi - printf '\033[0;36mInstalling bun...\033[0m\n' >&2 - curl -fsSL --show-error https://bun.sh/install | bash >/dev/null || { printf '\033[0;31mFailed to install bun\033[0m\n' >&2; exit 1; } - export PATH="$HOME/.bun/bin:$PATH" - command -v bun &>/dev/null || { printf '\033[0;31mbun not found after install\033[0m\n' >&2; exit 1; } -} - -_ensure_bun - -SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" 2>/dev/null && pwd)" - -# Local checkout — run from source -if [[ -n "$SCRIPT_DIR" && -f "$SCRIPT_DIR/../../packages/cli/src/fly/main.ts" ]]; then - exec bun run "$SCRIPT_DIR/../../packages/cli/src/fly/main.ts" kilocode "$@" -fi - -# Remote — download bundled fly.js from GitHub release -FLY_JS=$(mktemp) -trap 'rm -f "$FLY_JS"' EXIT -curl -fsSL "https://github.com/OpenRouterTeam/spawn/releases/download/fly-latest/fly.js" -o "$FLY_JS" \ - || { printf '\033[0;31mFailed to download fly.js\033[0m\n' >&2; exit 1; } - -exec bun run "$FLY_JS" kilocode "$@" diff --git a/sh/fly/openclaw.sh b/sh/fly/openclaw.sh deleted file mode 100644 index 618a697d5..000000000 --- a/sh/fly/openclaw.sh +++ /dev/null @@ -1,29 +0,0 @@ -#!/bin/bash -set -eo pipefail - -# Thin shim: ensures bun is available, runs bundled fly.js (local or from GitHub release) - -_ensure_bun() { - if command -v bun &>/dev/null; then return 0; fi - printf '\033[0;36mInstalling bun...\033[0m\n' >&2 - curl -fsSL --show-error https://bun.sh/install | bash >/dev/null || { printf '\033[0;31mFailed to install bun\033[0m\n' >&2; exit 1; } - export PATH="$HOME/.bun/bin:$PATH" - command -v bun &>/dev/null || { printf '\033[0;31mbun not found after install\033[0m\n' >&2; exit 1; } -} - -_ensure_bun - -SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" 2>/dev/null && pwd)" - -# Local checkout — run from source -if [[ -n "$SCRIPT_DIR" && -f "$SCRIPT_DIR/../../packages/cli/src/fly/main.ts" ]]; then - exec bun run "$SCRIPT_DIR/../../packages/cli/src/fly/main.ts" openclaw "$@" -fi - -# Remote — download bundled fly.js from GitHub release -FLY_JS=$(mktemp) -trap 'rm -f "$FLY_JS"' EXIT -curl -fsSL "https://github.com/OpenRouterTeam/spawn/releases/download/fly-latest/fly.js" -o "$FLY_JS" \ - || { printf '\033[0;31mFailed to download fly.js\033[0m\n' >&2; exit 1; } - -exec bun run "$FLY_JS" openclaw "$@" diff --git a/sh/fly/opencode.sh b/sh/fly/opencode.sh deleted file mode 100644 index 46db362be..000000000 --- a/sh/fly/opencode.sh +++ /dev/null @@ -1,29 +0,0 @@ -#!/bin/bash -set -eo pipefail - -# Thin shim: ensures bun is available, runs bundled fly.js (local or from GitHub release) - -_ensure_bun() { - if command -v bun &>/dev/null; then return 0; fi - printf '\033[0;36mInstalling bun...\033[0m\n' >&2 - curl -fsSL --show-error https://bun.sh/install | bash >/dev/null || { printf '\033[0;31mFailed to install bun\033[0m\n' >&2; exit 1; } - export PATH="$HOME/.bun/bin:$PATH" - command -v bun &>/dev/null || { printf '\033[0;31mbun not found after install\033[0m\n' >&2; exit 1; } -} - -_ensure_bun - -SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" 2>/dev/null && pwd)" - -# Local checkout — run from source -if [[ -n "$SCRIPT_DIR" && -f "$SCRIPT_DIR/../../packages/cli/src/fly/main.ts" ]]; then - exec bun run "$SCRIPT_DIR/../../packages/cli/src/fly/main.ts" opencode "$@" -fi - -# Remote — download bundled fly.js from GitHub release -FLY_JS=$(mktemp) -trap 'rm -f "$FLY_JS"' EXIT -curl -fsSL "https://github.com/OpenRouterTeam/spawn/releases/download/fly-latest/fly.js" -o "$FLY_JS" \ - || { printf '\033[0;31mFailed to download fly.js\033[0m\n' >&2; exit 1; } - -exec bun run "$FLY_JS" opencode "$@" diff --git a/sh/fly/zeroclaw.sh b/sh/fly/zeroclaw.sh deleted file mode 100644 index 753014da3..000000000 --- a/sh/fly/zeroclaw.sh +++ /dev/null @@ -1,29 +0,0 @@ -#!/bin/bash -set -eo pipefail - -# Thin shim: ensures bun is available, runs bundled fly.js (local or from GitHub release) - -_ensure_bun() { - if command -v bun &>/dev/null; then return 0; fi - printf '\033[0;36mInstalling bun...\033[0m\n' >&2 - curl -fsSL --show-error https://bun.sh/install | bash >/dev/null || { printf '\033[0;31mFailed to install bun\033[0m\n' >&2; exit 1; } - export PATH="$HOME/.bun/bin:$PATH" - command -v bun &>/dev/null || { printf '\033[0;31mbun not found after install\033[0m\n' >&2; exit 1; } -} - -_ensure_bun - -SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" 2>/dev/null && pwd)" - -# Local checkout — run from source -if [[ -n "$SCRIPT_DIR" && -f "$SCRIPT_DIR/../../packages/cli/src/fly/main.ts" ]]; then - exec bun run "$SCRIPT_DIR/../../packages/cli/src/fly/main.ts" zeroclaw "$@" -fi - -# Remote — download bundled fly.js from GitHub release -FLY_JS=$(mktemp) -trap 'rm -f "$FLY_JS"' EXIT -curl -fsSL "https://github.com/OpenRouterTeam/spawn/releases/download/fly-latest/fly.js" -o "$FLY_JS" \ - || { printf '\033[0;31mFailed to download fly.js\033[0m\n' >&2; exit 1; } - -exec bun run "$FLY_JS" zeroclaw "$@" diff --git a/sh/test/fixtures/fly/_api_assertions.sh b/sh/test/fixtures/fly/_api_assertions.sh deleted file mode 100644 index 85bb4005a..000000000 --- a/sh/test/fixtures/fly/_api_assertions.sh +++ /dev/null @@ -1,5 +0,0 @@ -# Fly.io uses TypeScript (bun) for API calls via native fetch() and fly CLI -# for exec/ssh. The mock curl log doesn't capture fetch() calls. -# Assert fly CLI usage instead of curl-based API calls. -assert_log_contains "fly " "uses fly CLI" -assert_log_contains "bun " "uses bun runtime" diff --git a/sh/test/fixtures/fly/_env.sh b/sh/test/fixtures/fly/_env.sh deleted file mode 100644 index bceee4812..000000000 --- a/sh/test/fixtures/fly/_env.sh +++ /dev/null @@ -1,8 +0,0 @@ -export FLY_API_TOKEN="test-token-fly" -export FLY_APP_NAME="test-app" -export FLY_MACHINE_ID="test-machine-id" -export FLY_REGION="iad" -export FLY_VM_SIZE="shared-cpu-1x" -export FLY_VM_MEMORY="1024" -export FLY_ORG="personal" -export MODEL_ID="openrouter/auto"