From 5a6c2092c0b01e6690c173d4aa1995aefa0494f1 Mon Sep 17 00:00:00 2001 From: spawn-bot Date: Fri, 27 Feb 2026 04:49:47 +0000 Subject: [PATCH 1/3] feat!: remove Fly.io cloud provider support MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Drop Fly.io as a supported cloud provider. Sprite (which uses Fly.io infrastructure internally) is retained. - Delete packages/cli/src/fly/ module, sh/fly/ scripts, fixtures/fly/ - Remove fly cloud entry and 6 fly matrix entries from manifest.json - Remove fly imports, destroy cases, and connection handlers from commands.ts - Remove fly-ssh sentinel from security.ts - Port E2E test suite from Fly.io to AWS Lightsail (fly-e2e.sh → aws-e2e.sh) - Update README (7 clouds, 42 combinations), CLAUDE.md, and skill prompts - Clean up fly references in build config, gitignore, icon sources - Bump CLI version to 0.11.0 Co-Authored-By: Claude Opus 4.6 --- .../skills/setup-agent-team/qa-e2e-prompt.md | 22 +- .../setup-agent-team/qa-fixtures-prompt.md | 5 - .../setup-agent-team/qa-quality-prompt.md | 21 +- .claude/skills/setup-agent-team/qa.sh | 2 +- .github/workflows/cli-release.yml | 2 +- .github/workflows/fly-docker.yml | 34 - CLAUDE.md | 14 +- README.md | 18 +- assets/clouds/.sources.json | 1 - assets/clouds/fly.png | Bin 29874 -> 0 bytes fixtures/fly/_metadata.json | 9 - fixtures/fly/apps.json | 13 - fixtures/fly/create_server.json | 16 - manifest.json | 24 - packages/cli/.gitignore | 1 - packages/cli/build-clouds.ts | 2 +- packages/cli/package.json | 2 +- .../cli/src/__tests__/custom-flag.test.ts | 21 - packages/cli/src/__tests__/fly.test.ts | 268 ---- .../security-connection-validation.test.ts | 1 - packages/cli/src/commands.ts | 58 +- packages/cli/src/fly/agents.ts | 63 - packages/cli/src/fly/fly.ts | 1239 ----------------- packages/cli/src/fly/main.ts | 116 -- packages/cli/src/security.ts | 3 +- packages/cli/src/shared/agent-setup.ts | 2 +- packages/cli/src/shared/ui.ts | 2 +- packages/shared/src/parse.ts | 2 +- sh/e2e/{fly-e2e.sh => aws-e2e.sh} | 22 +- sh/e2e/lib/cleanup.sh | 43 +- sh/e2e/lib/common.sh | 72 +- sh/e2e/lib/provision.sh | 61 +- sh/e2e/lib/teardown.sh | 56 +- sh/e2e/lib/verify.sh | 154 +- sh/fly/README.md | 82 -- sh/fly/claude.sh | 29 - sh/fly/codex.sh | 29 - sh/fly/docker/openclaw.Dockerfile | 32 - sh/fly/kilocode.sh | 29 - sh/fly/openclaw.sh | 29 - sh/fly/opencode.sh | 29 - sh/fly/zeroclaw.sh | 29 - sh/test/fixtures/fly/_api_assertions.sh | 5 - sh/test/fixtures/fly/_env.sh | 8 - 44 files changed, 217 insertions(+), 2453 deletions(-) delete mode 100644 .github/workflows/fly-docker.yml delete mode 100644 assets/clouds/fly.png delete mode 100644 fixtures/fly/_metadata.json delete mode 100644 fixtures/fly/apps.json delete mode 100644 fixtures/fly/create_server.json delete mode 100644 packages/cli/src/__tests__/fly.test.ts delete mode 100644 packages/cli/src/fly/agents.ts delete mode 100644 packages/cli/src/fly/fly.ts delete mode 100644 packages/cli/src/fly/main.ts rename sh/e2e/{fly-e2e.sh => aws-e2e.sh} (93%) delete mode 100644 sh/fly/README.md delete mode 100644 sh/fly/claude.sh delete mode 100644 sh/fly/codex.sh delete mode 100644 sh/fly/docker/openclaw.Dockerfile delete mode 100644 sh/fly/kilocode.sh delete mode 100644 sh/fly/openclaw.sh delete mode 100644 sh/fly/opencode.sh delete mode 100644 sh/fly/zeroclaw.sh delete mode 100644 sh/test/fixtures/fly/_api_assertions.sh delete mode 100644 sh/test/fixtures/fly/_env.sh 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/fly-docker.yml deleted file mode 100644 index 52c3c1e7f..000000000 --- a/.github/workflows/fly-docker.yml +++ /dev/null @@ -1,34 +0,0 @@ -name: Build Fly Docker Images - -on: - push: - branches: [main] - paths: - - "fly/docker/openclaw.Dockerfile" - schedule: - # Daily: pick up new openclaw releases - - cron: "0 6 * * *" - workflow_dispatch: - -permissions: - packages: write - contents: read - -jobs: - build-openclaw: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - - uses: docker/login-action@v3 - with: - registry: ghcr.io - username: ${{ github.actor }} - password: ${{ secrets.GITHUB_TOKEN }} - - - uses: docker/build-push-action@v6 - with: - context: . - file: fly/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 44196ea52e3735e196e4b51422b6179015dbb5d7..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 29874 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 { }); }); -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..685b583b2 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,22 +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)}...`); @@ -2898,29 +2871,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) { @@ -3377,7 +3327,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/docker/openclaw.Dockerfile b/sh/fly/docker/openclaw.Dockerfile deleted file mode 100644 index b97aa779c..000000000 --- a/sh/fly/docker/openclaw.Dockerfile +++ /dev/null @@ -1,32 +0,0 @@ -FROM ubuntu:24.04 - -ENV DEBIAN_FRONTEND=noninteractive - -# Base packages (matches waitForCloudInit in fly.ts) -RUN apt-get update -y && \ - apt-get install -y --no-install-recommends \ - curl git ca-certificates build-essential unzip xz-utils zsh && \ - rm -rf /var/lib/apt/lists/* - -# Node.js 22 via apt + n (matches fly.ts cloud-init strategy) -RUN apt-get update -y && \ - apt-get install -y --no-install-recommends nodejs npm && \ - npm install -g n && n 22 && \ - ln -sf /usr/local/bin/node /usr/bin/node && \ - ln -sf /usr/local/bin/npm /usr/bin/npm && \ - ln -sf /usr/local/bin/npx /usr/bin/npx && \ - rm -rf /var/lib/apt/lists/* - -# Bun -RUN curl -fsSL https://bun.sh/install | bash -ENV PATH="/root/.bun/bin:/root/.local/bin:${PATH}" - -# OpenClaw via npm (Node runtime needs standard node_modules layout) -RUN npm install -g openclaw -# Ensure tools are on PATH for all shells -RUN for rc in /root/.bashrc /root/.zshrc; do \ - grep -q '.bun/bin' "$rc" 2>/dev/null || \ - echo 'export PATH="$HOME/.local/bin:$HOME/.bun/bin:$PATH"' >> "$rc"; \ - done - -CMD ["/bin/sleep", "inf"] 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" From a4e5a7b4388d89312c6b7e4968c34509e84b5c76 Mon Sep 17 00:00:00 2001 From: spawn-bot Date: Fri, 27 Feb 2026 04:52:20 +0000 Subject: [PATCH 2/3] chore: restore Docker image build under sh/docker/ Move openclaw Dockerfile from sh/fly/docker/ to sh/docker/ and rename workflow from fly-docker.yml to docker.yml with updated paths. Co-Authored-By: Claude Opus 4.6 --- .github/workflows/docker.yml | 34 ++++++++++++++++++++++++++++++++++ sh/docker/openclaw.Dockerfile | 32 ++++++++++++++++++++++++++++++++ 2 files changed, 66 insertions(+) create mode 100644 .github/workflows/docker.yml create mode 100644 sh/docker/openclaw.Dockerfile diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml new file mode 100644 index 000000000..787bae43d --- /dev/null +++ b/.github/workflows/docker.yml @@ -0,0 +1,34 @@ +name: Build Docker Images + +on: + push: + branches: [main] + paths: + - "sh/docker/**" + schedule: + # Daily: pick up new openclaw releases + - cron: "0 6 * * *" + workflow_dispatch: + +permissions: + packages: write + contents: read + +jobs: + build-openclaw: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - uses: docker/build-push-action@v6 + with: + context: . + file: sh/docker/openclaw.Dockerfile + push: true + tags: ghcr.io/openrouterteam/spawn-openclaw:latest diff --git a/sh/docker/openclaw.Dockerfile b/sh/docker/openclaw.Dockerfile new file mode 100644 index 000000000..324d818c2 --- /dev/null +++ b/sh/docker/openclaw.Dockerfile @@ -0,0 +1,32 @@ +FROM ubuntu:24.04 + +ENV DEBIAN_FRONTEND=noninteractive + +# Base packages +RUN apt-get update -y && \ + apt-get install -y --no-install-recommends \ + curl git ca-certificates build-essential unzip xz-utils zsh && \ + rm -rf /var/lib/apt/lists/* + +# Node.js 22 via apt + n +RUN apt-get update -y && \ + apt-get install -y --no-install-recommends nodejs npm && \ + npm install -g n && n 22 && \ + ln -sf /usr/local/bin/node /usr/bin/node && \ + ln -sf /usr/local/bin/npm /usr/bin/npm && \ + ln -sf /usr/local/bin/npx /usr/bin/npx && \ + rm -rf /var/lib/apt/lists/* + +# Bun +RUN curl -fsSL https://bun.sh/install | bash +ENV PATH="/root/.bun/bin:/root/.local/bin:${PATH}" + +# OpenClaw via npm (Node runtime needs standard node_modules layout) +RUN npm install -g openclaw +# Ensure tools are on PATH for all shells +RUN for rc in /root/.bashrc /root/.zshrc; do \ + grep -q '.bun/bin' "$rc" 2>/dev/null || \ + echo 'export PATH="$HOME/.local/bin:$HOME/.bun/bin:$PATH"' >> "$rc"; \ + done + +CMD ["/bin/sleep", "inf"] From eaedef0366ffea1076197c08d72fc7b45b0443e1 Mon Sep 17 00:00:00 2001 From: spawn-bot Date: Fri, 27 Feb 2026 04:58:08 +0000 Subject: [PATCH 3/3] style: fix extra blank lines in commands.ts Co-Authored-By: Claude Opus 4.6 --- packages/cli/src/commands.ts | 2 -- 1 file changed, 2 deletions(-) diff --git a/packages/cli/src/commands.ts b/packages/cli/src/commands.ts index 685b583b2..d9e2820f5 100644 --- a/packages/cli/src/commands.ts +++ b/packages/cli/src/commands.ts @@ -2786,7 +2786,6 @@ async function cmdConnect(connection: VMConnection): Promise { ); } - // Handle SSH connections p.log.step(`Connecting to ${pc.bold(connection.ip)}...`); const sshCmd = `ssh ${connection.user}@${connection.ip}`; @@ -2871,7 +2870,6 @@ async function cmdEnterAgent(connection: VMConnection, agentKey: string, manifes ); } - // 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)}...`);