feat: prioritize clouds with CLI installed + hcloud CLI integration (#1375)

* fix: auto-run gcloud auth login on expired GCP tokens

Instead of telling users to run `gcloud auth login` manually, just
run it automatically when auth check fails or instance creation hits
a reauthentication error, then retry.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat: prioritize clouds with CLI installed + hcloud CLI integration

When selecting a cloud provider, clouds are now sorted in 3 tiers:
1. Credentials detected (env vars set) — top priority
2. CLI installed (e.g., gcloud, hcloud, aws) — middle priority
3. Neither — default order

Also adds hcloud CLI-first support for Hetzner operations (server
create/delete/list, SSH key management, auth) with automatic fallback
to the existing REST API when hcloud is not available.

Closes #1370

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* refactor: rename aws-lightsail to aws across the project

Simplifies the cloud key from "aws-lightsail" to "aws" — AWS should
have a single entry regardless of the underlying service used.

Renames the directory, updates manifest.json matrix keys, CLI map,
test fixtures, README, and all agent scripts.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: lab <6723574+louisgv@users.noreply.github.com>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
A 2026-02-16 20:12:35 -08:00 committed by GitHub
parent d452fdea37
commit c4eccbd72f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
26 changed files with 296 additions and 155 deletions

View file

@ -48,7 +48,7 @@ We are currently shipping with **9 curated clouds** (sorted by price):
3. **hetzner** — ~€3.29/mo (CX22)
4. **ovh** — ~€3.50/mo (d2-2)
5. **fly** — free tier (3 shared-cpu VMs)
6. **aws-lightsail** — $3.50/mo (nano)
6. **aws** — $3.50/mo (nano)
7. **daytona** — pay-per-second sandboxes
8. **digitalocean** — $4/mo (Basic droplet)
9. **gcp** — $7.11/mo (e2-micro)

View file

@ -155,7 +155,7 @@ If an agent fails to install or launch on a cloud:
## Matrix
| | [Local Machine](local/) | [Oracle Cloud Infrastructure](oracle/) | [Hetzner Cloud](hetzner/) | [OVHcloud](ovh/) | [Fly.io](fly/) | [AWS Lightsail](aws-lightsail/) | [Daytona](daytona/) | [DigitalOcean](digitalocean/) | [GCP Compute Engine](gcp/) | [Sprite](sprite/) |
| | [Local Machine](local/) | [Oracle Cloud Infrastructure](oracle/) | [Hetzner Cloud](hetzner/) | [OVHcloud](ovh/) | [Fly.io](fly/) | [AWS Lightsail](aws/) | [Daytona](daytona/) | [DigitalOcean](digitalocean/) | [GCP Compute Engine](gcp/) | [Sprite](sprite/) |
|---|---|---|---|---|---|---|---|---|---|---|
| [**Claude Code**](https://claude.ai) | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ |
| [**OpenClaw**](https://github.com/OpenRouterTeam/openclaw) | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ |

View file

@ -1,93 +0,0 @@
# AWS Lightsail
AWS Lightsail instances via AWS CLI. [AWS Lightsail](https://aws.amazon.com/lightsail/)
> Uses 'ubuntu' user instead of 'root'. Requires AWS CLI installed and configured.
## Agents
#### Claude Code
```bash
bash <(curl -fsSL https://openrouter.ai/labs/spawn/aws-lightsail/claude.sh)
```
#### OpenClaw
```bash
bash <(curl -fsSL https://openrouter.ai/labs/spawn/aws-lightsail/openclaw.sh)
```
#### NanoClaw
```bash
bash <(curl -fsSL https://openrouter.ai/labs/spawn/aws-lightsail/nanoclaw.sh)
```
#### Aider
```bash
bash <(curl -fsSL https://openrouter.ai/labs/spawn/aws-lightsail/aider.sh)
```
#### Goose
```bash
bash <(curl -fsSL https://openrouter.ai/labs/spawn/aws-lightsail/goose.sh)
```
#### Codex CLI
```bash
bash <(curl -fsSL https://openrouter.ai/labs/spawn/aws-lightsail/codex.sh)
```
#### Open Interpreter
```bash
bash <(curl -fsSL https://openrouter.ai/labs/spawn/aws-lightsail/interpreter.sh)
```
#### Gemini CLI
```bash
bash <(curl -fsSL https://openrouter.ai/labs/spawn/aws-lightsail/gemini.sh)
```
#### Amazon Q CLI
```bash
bash <(curl -fsSL https://openrouter.ai/labs/spawn/aws-lightsail/amazonq.sh)
```
#### Cline
```bash
bash <(curl -fsSL https://openrouter.ai/labs/spawn/aws-lightsail/cline.sh)
```
#### gptme
```bash
bash <(curl -fsSL https://openrouter.ai/labs/spawn/aws-lightsail/gptme.sh)
```
#### OpenCode
```bash
bash <(curl -fsSL https://openrouter.ai/labs/spawn/aws-lightsail/opencode.sh)
```
#### Plandex
```bash
bash <(curl -fsSL https://openrouter.ai/labs/spawn/aws-lightsail/plandex.sh)
```
## Non-Interactive Mode
```bash
LIGHTSAIL_SERVER_NAME=dev-mk1 \
OPENROUTER_API_KEY=sk-or-v1-xxxxx \
bash <(curl -fsSL https://openrouter.ai/labs/spawn/aws-lightsail/claude.sh)
```

93
aws/README.md Normal file
View file

@ -0,0 +1,93 @@
# AWS Lightsail
AWS Lightsail instances via AWS CLI. [AWS Lightsail](https://aws.amazon.com/lightsail/)
> Uses 'ubuntu' user instead of 'root'. Requires AWS CLI installed and configured.
## Agents
#### Claude Code
```bash
bash <(curl -fsSL https://openrouter.ai/labs/spawn/aws/claude.sh)
```
#### OpenClaw
```bash
bash <(curl -fsSL https://openrouter.ai/labs/spawn/aws/openclaw.sh)
```
#### NanoClaw
```bash
bash <(curl -fsSL https://openrouter.ai/labs/spawn/aws/nanoclaw.sh)
```
#### Aider
```bash
bash <(curl -fsSL https://openrouter.ai/labs/spawn/aws/aider.sh)
```
#### Goose
```bash
bash <(curl -fsSL https://openrouter.ai/labs/spawn/aws/goose.sh)
```
#### Codex CLI
```bash
bash <(curl -fsSL https://openrouter.ai/labs/spawn/aws/codex.sh)
```
#### Open Interpreter
```bash
bash <(curl -fsSL https://openrouter.ai/labs/spawn/aws/interpreter.sh)
```
#### Gemini CLI
```bash
bash <(curl -fsSL https://openrouter.ai/labs/spawn/aws/gemini.sh)
```
#### Amazon Q CLI
```bash
bash <(curl -fsSL https://openrouter.ai/labs/spawn/aws/amazonq.sh)
```
#### Cline
```bash
bash <(curl -fsSL https://openrouter.ai/labs/spawn/aws/cline.sh)
```
#### gptme
```bash
bash <(curl -fsSL https://openrouter.ai/labs/spawn/aws/gptme.sh)
```
#### OpenCode
```bash
bash <(curl -fsSL https://openrouter.ai/labs/spawn/aws/opencode.sh)
```
#### Plandex
```bash
bash <(curl -fsSL https://openrouter.ai/labs/spawn/aws/plandex.sh)
```
## Non-Interactive Mode
```bash
LIGHTSAIL_SERVER_NAME=dev-mk1 \
OPENROUTER_API_KEY=sk-or-v1-xxxxx \
bash <(curl -fsSL https://openrouter.ai/labs/spawn/aws/claude.sh)
```

View file

@ -3,11 +3,11 @@ set -eo pipefail
# Source common functions - try local file first, fall back to remote
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" 2>/dev/null && pwd)"
# shellcheck source=aws-lightsail/lib/common.sh
# shellcheck source=aws/lib/common.sh
if [[ -f "${SCRIPT_DIR}/lib/common.sh" ]]; then
source "${SCRIPT_DIR}/lib/common.sh"
else
eval "$(curl -fsSL https://raw.githubusercontent.com/OpenRouterTeam/spawn/main/aws-lightsail/lib/common.sh)"
eval "$(curl -fsSL https://raw.githubusercontent.com/OpenRouterTeam/spawn/main/aws/lib/common.sh)"
fi
log_info "Aider on AWS Lightsail"

View file

@ -3,11 +3,11 @@
set -eo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" 2>/dev/null && pwd)"
# shellcheck source=aws-lightsail/lib/common.sh
# shellcheck source=aws/lib/common.sh
if [[ -f "${SCRIPT_DIR}/lib/common.sh" ]]; then
source "${SCRIPT_DIR}/lib/common.sh"
else
eval "$(curl -fsSL https://raw.githubusercontent.com/OpenRouterTeam/spawn/main/aws-lightsail/lib/common.sh)"
eval "$(curl -fsSL https://raw.githubusercontent.com/OpenRouterTeam/spawn/main/aws/lib/common.sh)"
fi
log_info "Amazon Q on AWS Lightsail"

View file

@ -3,11 +3,11 @@ set -eo pipefail
# Source common functions - try local file first, fall back to remote
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" 2>/dev/null && pwd)"
# shellcheck source=aws-lightsail/lib/common.sh
# shellcheck source=aws/lib/common.sh
if [[ -f "${SCRIPT_DIR}/lib/common.sh" ]]; then
source "${SCRIPT_DIR}/lib/common.sh"
else
eval "$(curl -fsSL https://raw.githubusercontent.com/OpenRouterTeam/spawn/main/aws-lightsail/lib/common.sh)"
eval "$(curl -fsSL https://raw.githubusercontent.com/OpenRouterTeam/spawn/main/aws/lib/common.sh)"
fi
log_info "Claude Code on AWS Lightsail"

View file

@ -3,11 +3,11 @@
set -eo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" 2>/dev/null && pwd)"
# shellcheck source=aws-lightsail/lib/common.sh
# shellcheck source=aws/lib/common.sh
if [[ -f "${SCRIPT_DIR}/lib/common.sh" ]]; then
source "${SCRIPT_DIR}/lib/common.sh"
else
eval "$(curl -fsSL https://raw.githubusercontent.com/OpenRouterTeam/spawn/main/aws-lightsail/lib/common.sh)"
eval "$(curl -fsSL https://raw.githubusercontent.com/OpenRouterTeam/spawn/main/aws/lib/common.sh)"
fi
log_info "Cline on AWS Lightsail"

View file

@ -2,11 +2,11 @@
set -eo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" 2>/dev/null && pwd)"
# shellcheck source=aws-lightsail/lib/common.sh
# shellcheck source=aws/lib/common.sh
if [[ -f "${SCRIPT_DIR}/lib/common.sh" ]]; then
source "${SCRIPT_DIR}/lib/common.sh"
else
eval "$(curl -fsSL https://raw.githubusercontent.com/OpenRouterTeam/spawn/main/aws-lightsail/lib/common.sh)"
eval "$(curl -fsSL https://raw.githubusercontent.com/OpenRouterTeam/spawn/main/aws/lib/common.sh)"
fi
log_info "Codex CLI on AWS Lightsail"

View file

@ -3,11 +3,11 @@
set -eo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" 2>/dev/null && pwd)"
# shellcheck source=aws-lightsail/lib/common.sh
# shellcheck source=aws/lib/common.sh
if [[ -f "${SCRIPT_DIR}/lib/common.sh" ]]; then
source "${SCRIPT_DIR}/lib/common.sh"
else
eval "$(curl -fsSL https://raw.githubusercontent.com/OpenRouterTeam/spawn/main/aws-lightsail/lib/common.sh)"
eval "$(curl -fsSL https://raw.githubusercontent.com/OpenRouterTeam/spawn/main/aws/lib/common.sh)"
fi
log_info "Continue on AWS Lightsail"

View file

@ -3,11 +3,11 @@
set -eo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" 2>/dev/null && pwd)"
# shellcheck source=aws-lightsail/lib/common.sh
# shellcheck source=aws/lib/common.sh
if [[ -f "${SCRIPT_DIR}/lib/common.sh" ]]; then
source "${SCRIPT_DIR}/lib/common.sh"
else
eval "$(curl -fsSL https://raw.githubusercontent.com/OpenRouterTeam/spawn/main/aws-lightsail/lib/common.sh)"
eval "$(curl -fsSL https://raw.githubusercontent.com/OpenRouterTeam/spawn/main/aws/lib/common.sh)"
fi
log_info "Gemini CLI on AWS Lightsail"

View file

@ -3,11 +3,11 @@ set -eo pipefail
# Source common functions - try local file first, fall back to remote
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" 2>/dev/null && pwd)"
# shellcheck source=aws-lightsail/lib/common.sh
# shellcheck source=aws/lib/common.sh
if [[ -f "${SCRIPT_DIR}/lib/common.sh" ]]; then
source "${SCRIPT_DIR}/lib/common.sh"
else
eval "$(curl -fsSL https://raw.githubusercontent.com/OpenRouterTeam/spawn/main/aws-lightsail/lib/common.sh)"
eval "$(curl -fsSL https://raw.githubusercontent.com/OpenRouterTeam/spawn/main/aws/lib/common.sh)"
fi
log_info "Goose on AWS Lightsail"

View file

@ -6,7 +6,7 @@ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" 2>/dev/null && pwd)"
if [[ -f "$SCRIPT_DIR/lib/common.sh" ]]; then
source "$SCRIPT_DIR/lib/common.sh"
else
eval "$(curl -fsSL https://raw.githubusercontent.com/OpenRouterTeam/spawn/main/aws-lightsail/lib/common.sh)"
eval "$(curl -fsSL https://raw.githubusercontent.com/OpenRouterTeam/spawn/main/aws/lib/common.sh)"
fi
log_info "gptme on AWS Lightsail"

View file

@ -2,11 +2,11 @@
set -eo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" 2>/dev/null && pwd)"
# shellcheck source=aws-lightsail/lib/common.sh
# shellcheck source=aws/lib/common.sh
if [[ -f "${SCRIPT_DIR}/lib/common.sh" ]]; then
source "${SCRIPT_DIR}/lib/common.sh"
else
eval "$(curl -fsSL https://raw.githubusercontent.com/OpenRouterTeam/spawn/main/aws-lightsail/lib/common.sh)"
eval "$(curl -fsSL https://raw.githubusercontent.com/OpenRouterTeam/spawn/main/aws/lib/common.sh)"
fi
log_info "Open Interpreter on AWS Lightsail"

View file

@ -3,11 +3,11 @@
set -eo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" 2>/dev/null && pwd)"
# shellcheck source=aws-lightsail/lib/common.sh
# shellcheck source=aws/lib/common.sh
if [[ -f "${SCRIPT_DIR}/lib/common.sh" ]]; then
source "${SCRIPT_DIR}/lib/common.sh"
else
eval "$(curl -fsSL https://raw.githubusercontent.com/OpenRouterTeam/spawn/main/aws-lightsail/lib/common.sh)"
eval "$(curl -fsSL https://raw.githubusercontent.com/OpenRouterTeam/spawn/main/aws/lib/common.sh)"
fi
log_info "Kilo Code on AWS Lightsail"

View file

@ -170,7 +170,7 @@ create_server() {
_wait_for_lightsail_instance "${name}"
save_vm_connection "${LIGHTSAIL_SERVER_IP}" "ubuntu" "" "$name" "aws-lightsail"
save_vm_connection "${LIGHTSAIL_SERVER_IP}" "ubuntu" "" "$name" "aws"
}
# Lightsail uses 'ubuntu' user, not 'root'

View file

@ -3,11 +3,11 @@ set -eo pipefail
# Source common functions - try local file first, fall back to remote
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" 2>/dev/null && pwd)"
# shellcheck source=aws-lightsail/lib/common.sh
# shellcheck source=aws/lib/common.sh
if [[ -f "${SCRIPT_DIR}/lib/common.sh" ]]; then
source "${SCRIPT_DIR}/lib/common.sh"
else
eval "$(curl -fsSL https://raw.githubusercontent.com/OpenRouterTeam/spawn/main/aws-lightsail/lib/common.sh)"
eval "$(curl -fsSL https://raw.githubusercontent.com/OpenRouterTeam/spawn/main/aws/lib/common.sh)"
fi
log_info "NanoClaw on AWS Lightsail"

View file

@ -3,11 +3,11 @@ set -eo pipefail
# Source common functions - try local file first, fall back to remote
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" 2>/dev/null && pwd)"
# shellcheck source=aws-lightsail/lib/common.sh
# shellcheck source=aws/lib/common.sh
if [[ -f "${SCRIPT_DIR}/lib/common.sh" ]]; then
source "${SCRIPT_DIR}/lib/common.sh"
else
eval "$(curl -fsSL https://raw.githubusercontent.com/OpenRouterTeam/spawn/main/aws-lightsail/lib/common.sh)"
eval "$(curl -fsSL https://raw.githubusercontent.com/OpenRouterTeam/spawn/main/aws/lib/common.sh)"
fi
log_info "OpenClaw on AWS Lightsail"

View file

@ -2,11 +2,11 @@
set -eo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" 2>/dev/null && pwd)"
# shellcheck source=aws-lightsail/lib/common.sh
# shellcheck source=aws/lib/common.sh
if [[ -f "${SCRIPT_DIR}/lib/common.sh" ]]; then
source "${SCRIPT_DIR}/lib/common.sh"
else
eval "$(curl -fsSL https://raw.githubusercontent.com/OpenRouterTeam/spawn/main/aws-lightsail/lib/common.sh)"
eval "$(curl -fsSL https://raw.githubusercontent.com/OpenRouterTeam/spawn/main/aws/lib/common.sh)"
fi
log_info "OpenCode on AWS Lightsail"

View file

@ -3,11 +3,11 @@ set -eo pipefail
# Source common functions - try local file first, fall back to remote
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" 2>/dev/null && pwd)"
# shellcheck source=aws-lightsail/lib/common.sh
# shellcheck source=aws/lib/common.sh
if [[ -f "${SCRIPT_DIR}/lib/common.sh" ]]; then
source "${SCRIPT_DIR}/lib/common.sh"
else
eval "$(curl -fsSL https://raw.githubusercontent.com/OpenRouterTeam/spawn/main/aws-lightsail/lib/common.sh)"
eval "$(curl -fsSL https://raw.githubusercontent.com/OpenRouterTeam/spawn/main/aws/lib/common.sh)"
fi
log_info "Plandex on AWS Lightsail"

View file

@ -1,6 +1,6 @@
{
"name": "@openrouter/spawn",
"version": "0.3.0",
"version": "0.3.1",
"type": "module",
"bin": {
"spawn": "cli.js"

View file

@ -315,7 +315,7 @@ describe("SAFE_PROVIDER_RE - provider name validation", () => {
});
it("should accept name with hyphens", () => {
expect(SAFE_PROVIDER_RE.test("aws-lightsail")).toBe(true);
expect(SAFE_PROVIDER_RE.test("aws")).toBe(true);
});
it("should accept name with underscores", () => {

View file

@ -330,18 +330,40 @@ function validateImplementation(manifest: Manifest, cloud: string, agent: string
// ── Interactive ────────────────────────────────────────────────────────────────
/** Sort clouds by credential availability and build hint overrides for the picker */
/** Map of cloud keys to their CLI tool names */
const CLOUD_CLI_MAP: Record<string, string> = {
gcp: "gcloud",
aws: "aws",
oracle: "oci",
fly: "flyctl",
sprite: "sprite",
hetzner: "hcloud",
digitalocean: "doctl",
};
/** Check if the relevant CLI tool for a cloud provider is installed */
export function hasCloudCli(cloud: string): boolean {
const cli = CLOUD_CLI_MAP[cloud];
if (!cli) return false;
return Bun.which(cli) !== null;
}
/** Sort clouds by credential/CLI availability and build hint overrides for the picker.
* Three tiers: credentials set > CLI installed > neither. */
export function prioritizeCloudsByCredentials(
clouds: string[],
manifest: Manifest
): { sortedClouds: string[]; hintOverrides: Record<string, string>; credCount: number } {
): { sortedClouds: string[]; hintOverrides: Record<string, string>; credCount: number; cliCount: number } {
const withCreds: string[] = [];
const withoutCreds: string[] = [];
const withCli: string[] = [];
const rest: string[] = [];
for (const c of clouds) {
if (hasCloudCredentials(manifest.clouds[c].auth)) {
withCreds.push(c);
} else if (hasCloudCli(c)) {
withCli.push(c);
} else {
withoutCreds.push(c);
rest.push(c);
}
}
@ -349,8 +371,11 @@ export function prioritizeCloudsByCredentials(
for (const c of withCreds) {
hintOverrides[c] = `credentials detected -- ${manifest.clouds[c].description}`;
}
for (const c of withCli) {
hintOverrides[c] = `CLI installed -- ${manifest.clouds[c].description}`;
}
return { sortedClouds: [...withCreds, ...withoutCreds], hintOverrides, credCount: withCreds.length };
return { sortedClouds: [...withCreds, ...withCli, ...rest], hintOverrides, credCount: withCreds.length, cliCount: withCli.length };
}
/** Build hint overrides for the agent picker showing cloud count and credential readiness */
@ -399,10 +424,13 @@ function getAndValidateCloudChoices(
process.exit(1);
}
const { sortedClouds, hintOverrides, credCount } = prioritizeCloudsByCredentials(clouds, manifest);
const { sortedClouds, hintOverrides, credCount, cliCount } = prioritizeCloudsByCredentials(clouds, manifest);
if (credCount > 0) {
p.log.info(`${credCount} cloud${credCount > 1 ? "s" : ""} with credentials detected (shown first)`);
}
if (cliCount > 0) {
p.log.info(`${cliCount} cloud${cliCount > 1 ? "s" : ""} with CLI installed`);
}
return { clouds: sortedClouds, hintOverrides, credCount };
}
@ -1518,7 +1546,7 @@ function buildDeleteScript(cloud: string, connection: VMConnection): string {
const project = connection.metadata?.project || "";
return `${sourceLib}\nensure_gcloud\nexport GCP_ZONE="${zone}"\nexport GCP_PROJECT="${project}"\ndestroy_server "${id}"`;
}
case "aws-lightsail":
case "aws":
return `${sourceLib}\nensure_aws_cli\ndestroy_server "${id}"`;
case "oracle":
return `${sourceLib}\nensure_oci_cli\ndestroy_server "${id}"`;

View file

@ -20,6 +20,16 @@ fi
# Hetzner Cloud specific functions
# ============================================================
# Detect hcloud CLI availability
_hcloud_cli_available() {
command -v hcloud &>/dev/null
}
# Check if hcloud CLI has an active context (authenticated)
_hcloud_cli_authenticated() {
_hcloud_cli_available && hcloud context active &>/dev/null 2>&1
}
readonly HETZNER_API_BASE="https://api.hetzner.cloud/v1"
SPAWN_DASHBOARD_URL="https://console.hetzner.cloud/"
# SSH_OPTS is now defined in shared/common.sh
@ -48,8 +58,27 @@ test_hcloud_token() {
return 0
}
# Ensure HCLOUD_TOKEN is available (env var → config file → prompt+save)
# Ensure Hetzner auth is available (hcloud CLI → env var → config file → prompt+save)
ensure_hcloud_token() {
# If hcloud CLI is available, prefer it for authentication
if _hcloud_cli_available; then
if _hcloud_cli_authenticated; then
log_info "Using hcloud CLI (context: $(hcloud context active))"
# Export token from CLI context for API fallback compatibility
if [[ -z "${HCLOUD_TOKEN:-}" ]]; then
HCLOUD_TOKEN=$(hcloud context active 2>/dev/null | xargs -I{} grep -A1 "{}" ~/.config/hcloud/cli.toml 2>/dev/null | grep token | sed 's/.*= *"\(.*\)"/\1/' || true)
if [[ -n "${HCLOUD_TOKEN:-}" ]]; then
export HCLOUD_TOKEN
fi
fi
return 0
else
log_info "hcloud CLI found but no active context"
log_info "Run: hcloud context create myproject"
log_info "Falling back to API token authentication..."
fi
fi
ensure_api_token_with_provider \
"Hetzner Cloud" \
"HCLOUD_TOKEN" \
@ -60,6 +89,16 @@ ensure_hcloud_token() {
# Check if SSH key is registered with Hetzner
hetzner_check_ssh_key() {
if _hcloud_cli_available && [[ -n "${HCLOUD_TOKEN:-}" || "$(_hcloud_cli_authenticated && echo y)" == "y" ]]; then
# Use hcloud CLI to check by fingerprint
local fingerprint="$1"
local result
result=$(hcloud ssh-key list -o json 2>/dev/null || true)
if [[ -n "$result" ]] && printf '%s' "$result" | jq -e --arg fp "$fingerprint" '.[] | select(.fingerprint == $fp)' &>/dev/null; then
return 0
fi
return 1
fi
check_ssh_key_by_fingerprint hetzner_api "/ssh_keys" "$1"
}
@ -67,6 +106,16 @@ hetzner_check_ssh_key() {
hetzner_register_ssh_key() {
local key_name="$1"
local pub_path="$2"
if _hcloud_cli_available && [[ -n "${HCLOUD_TOKEN:-}" || "$(_hcloud_cli_authenticated && echo y)" == "y" ]]; then
# Use hcloud CLI to register
if hcloud ssh-key create --name "$key_name" --public-key-from-file "$pub_path" &>/dev/null; then
return 0
fi
# Fall through to API on CLI failure
log_warn "hcloud ssh-key create failed, falling back to API"
fi
local pub_key
pub_key=$(cat "$pub_path")
local json_pub_key json_name
@ -362,7 +411,7 @@ _hetzner_resolve_server_type() {
printf '%s' "$validated_type"
}
# Create a Hetzner server with cloud-init
# Create a Hetzner server with cloud-init (hcloud CLI preferred, API fallback)
create_server() {
local name="$1"
@ -384,6 +433,38 @@ create_server() {
log_step "Creating Hetzner server '$name' (type: $server_type, location: $location)..."
# Try hcloud CLI first
if _hcloud_cli_available && [[ -n "${HCLOUD_TOKEN:-}" || "$(_hcloud_cli_authenticated && echo y)" == "y" ]]; then
local userdata_file
userdata_file=$(mktemp)
get_cloud_init_userdata > "$userdata_file"
local cli_response
if cli_response=$(hcloud server create \
--name "$name" \
--type "$server_type" \
--location "$location" \
--image "$image" \
--ssh-key all \
--user-data-from-file "$userdata_file" \
-o json 2>&1); then
rm -f "$userdata_file"
HETZNER_SERVER_ID=$(printf '%s' "$cli_response" | jq -r '.server.id')
HETZNER_SERVER_IP=$(printf '%s' "$cli_response" | jq -r '.server.public_net.ipv4.ip')
if [[ -n "$HETZNER_SERVER_ID" && "$HETZNER_SERVER_ID" != "null" && -n "$HETZNER_SERVER_IP" && "$HETZNER_SERVER_IP" != "null" ]]; then
export HETZNER_SERVER_ID HETZNER_SERVER_IP
log_info "Server created via hcloud CLI: ID=$HETZNER_SERVER_ID, IP=$HETZNER_SERVER_IP"
save_vm_connection "${HETZNER_SERVER_IP}" "root" "${HETZNER_SERVER_ID}" "$name" "hetzner"
return 0
fi
fi
rm -f "$userdata_file"
log_warn "hcloud CLI server create failed, falling back to API"
fi
# Fallback: REST API
# Get all SSH key IDs
local ssh_keys_response
ssh_keys_response=$(hetzner_api GET "/ssh_keys")
@ -426,11 +507,21 @@ run_server() { ssh_run_server "$@"; }
upload_file() { ssh_upload_file "$@"; }
interactive_session() { ssh_interactive_session "$@"; }
# Destroy a Hetzner server
# Destroy a Hetzner server (hcloud CLI preferred, API fallback)
destroy_server() {
local server_id="$1"
log_step "Destroying server $server_id..."
# Try hcloud CLI first
if _hcloud_cli_available && [[ -n "${HCLOUD_TOKEN:-}" || "$(_hcloud_cli_authenticated && echo y)" == "y" ]]; then
if hcloud server delete "$server_id" 2>/dev/null; then
log_info "Server $server_id destroyed via hcloud CLI"
return 0
fi
log_warn "hcloud CLI delete failed, falling back to API"
fi
local response
response=$(hetzner_api DELETE "/servers/$server_id")
@ -446,8 +537,30 @@ destroy_server() {
log_info "Server $server_id destroyed"
}
# List all Hetzner servers
# List all Hetzner servers (hcloud CLI preferred, API fallback)
list_servers() {
# Try hcloud CLI first
if _hcloud_cli_available && [[ -n "${HCLOUD_TOKEN:-}" || "$(_hcloud_cli_authenticated && echo y)" == "y" ]]; then
local cli_response
if cli_response=$(hcloud server list -o json 2>/dev/null); then
local count
count=$(printf '%s' "$cli_response" | jq 'length')
if [[ "$count" -eq 0 ]]; then
printf 'No servers found\n'
return 0
fi
printf '%-25s %-12s %-12s %-16s %-10s\n' "NAME" "ID" "STATUS" "IP" "TYPE"
printf '%s\n' "---------------------------------------------------------------------------"
printf '%s' "$cli_response" | jq -r \
'.[] | "\(.name)|\(.id)|\(.status)|\(.public_net.ipv4.ip // "N/A")|\(.server_type.name)"' \
| while IFS='|' read -r name sid status ip stype; do
printf '%-25s %-12s %-12s %-16s %-10s\n' "$name" "$sid" "$status" "$ip" "$stype"
done
return 0
fi
fi
# Fallback: REST API
local response
response=$(hetzner_api GET "/servers")

View file

@ -316,7 +316,7 @@
},
"notes": "Uses Machines API for provisioning and flyctl SSH for exec. Docker-based, pay-per-second pricing. Requires flyctl CLI."
},
"aws-lightsail": {
"aws": {
"name": "AWS Lightsail",
"description": "AWS Lightsail instances via AWS CLI",
"url": "https://aws.amazon.com/lightsail/",
@ -412,25 +412,25 @@
"sprite/interpreter": "implemented",
"hetzner/interpreter": "implemented",
"digitalocean/interpreter": "implemented",
"aws-lightsail/claude": "implemented",
"aws-lightsail/openclaw": "implemented",
"aws-lightsail/nanoclaw": "implemented",
"aws-lightsail/aider": "implemented",
"aws-lightsail/goose": "implemented",
"aws-lightsail/codex": "implemented",
"aws-lightsail/interpreter": "implemented",
"aws/claude": "implemented",
"aws/openclaw": "implemented",
"aws/nanoclaw": "implemented",
"aws/aider": "implemented",
"aws/goose": "implemented",
"aws/codex": "implemented",
"aws/interpreter": "implemented",
"sprite/gemini": "implemented",
"hetzner/gemini": "implemented",
"digitalocean/gemini": "implemented",
"aws-lightsail/gemini": "implemented",
"aws/gemini": "implemented",
"sprite/amazonq": "implemented",
"hetzner/amazonq": "implemented",
"digitalocean/amazonq": "implemented",
"aws-lightsail/amazonq": "implemented",
"aws/amazonq": "implemented",
"sprite/cline": "implemented",
"hetzner/cline": "implemented",
"digitalocean/cline": "implemented",
"aws-lightsail/cline": "implemented",
"aws/cline": "implemented",
"gcp/claude": "implemented",
"gcp/openclaw": "implemented",
"gcp/nanoclaw": "implemented",
@ -444,7 +444,7 @@
"sprite/gptme": "implemented",
"hetzner/gptme": "implemented",
"digitalocean/gptme": "implemented",
"aws-lightsail/gptme": "implemented",
"aws/gptme": "implemented",
"gcp/gptme": "implemented",
"fly/claude": "implemented",
"fly/aider": "implemented",
@ -460,7 +460,7 @@
"sprite/opencode": "implemented",
"hetzner/opencode": "implemented",
"digitalocean/opencode": "implemented",
"aws-lightsail/opencode": "implemented",
"aws/opencode": "implemented",
"gcp/opencode": "implemented",
"fly/opencode": "implemented",
"daytona/claude": "implemented",
@ -478,7 +478,7 @@
"sprite/plandex": "implemented",
"hetzner/plandex": "implemented",
"digitalocean/plandex": "implemented",
"aws-lightsail/plandex": "implemented",
"aws/plandex": "implemented",
"gcp/plandex": "implemented",
"fly/plandex": "implemented",
"daytona/plandex": "implemented",
@ -498,7 +498,7 @@
"sprite/kilocode": "implemented",
"hetzner/kilocode": "implemented",
"digitalocean/kilocode": "implemented",
"aws-lightsail/kilocode": "implemented",
"aws/kilocode": "implemented",
"gcp/kilocode": "implemented",
"fly/kilocode": "implemented",
"daytona/kilocode": "implemented",
@ -520,7 +520,7 @@
"sprite/continue": "implemented",
"hetzner/continue": "implemented",
"digitalocean/continue": "implemented",
"aws-lightsail/continue": "implemented",
"aws/continue": "implemented",
"gcp/continue": "implemented",
"fly/continue": "implemented",
"daytona/continue": "implemented",

View file

@ -781,7 +781,7 @@ list_clouds() {
total_count=$(echo "$ALL_RECORDABLE_CLOUDS" | wc -w | tr -d ' ')
printf '%b\n' " ${ready_count}/${total_count} clouds have credentials set"
printf '\n'
printf " CLI-based clouds (not recordable): sprite, gcp, fly, daytona, aws-lightsail, oracle, local\n"
printf " CLI-based clouds (not recordable): sprite, gcp, fly, daytona, aws, oracle, local\n"
}
# --- Main ---