diff --git a/cli/package.json b/cli/package.json index c1b8359a..07ad4c0d 100644 --- a/cli/package.json +++ b/cli/package.json @@ -1,6 +1,6 @@ { "name": "@openrouter/spawn", - "version": "0.6.0", + "version": "0.6.1", "type": "module", "bin": { "spawn": "cli.js" diff --git a/cli/src/gcp/agents.ts b/cli/src/gcp/agents.ts new file mode 100644 index 00000000..7ec4c95f --- /dev/null +++ b/cli/src/gcp/agents.ts @@ -0,0 +1,385 @@ +// gcp/agents.ts — Agent configs for GCP Compute Engine deployment + +import { writeFileSync, unlinkSync } from "fs"; +import { tmpdir } from "os"; +import { join } from "path"; +import { + logInfo, + logWarn, + logError, + logStep, + prompt, + jsonEscape, +} from "../shared/ui"; +import { + runServer, + uploadFile, + runWithRetry, +} from "./gcp"; + +// ─── Types ─────────────────────────────────────────────────────────────────── + +export interface AgentConfig { + name: string; + modelPrompt?: boolean; + modelDefault?: string; + preProvision?: () => Promise; + install: () => Promise; + envVars: (apiKey: string) => string[]; + configure?: (apiKey: string, modelId?: string) => Promise; + preLaunch?: () => Promise; + launchCmd: () => string; +} + +// ─── Shared Helpers ────────────────────────────────────────────────────────── + +export function generateEnvConfig(pairs: string[]): string { + const lines = [ + "", + "# [spawn:env]", + "export IS_SANDBOX='1'", + ]; + for (const pair of pairs) { + const eqIdx = pair.indexOf("="); + if (eqIdx === -1) continue; + const key = pair.slice(0, eqIdx); + const value = pair.slice(eqIdx + 1); + if (!/^[A-Z_][A-Z0-9_]*$/.test(key)) { + logError(`SECURITY: Invalid environment variable name rejected: ${key}`); + continue; + } + const escaped = value.replace(/'/g, "'\\''"); + lines.push(`export ${key}='${escaped}'`); + } + return lines.join("\n") + "\n"; +} + +async function installAgent(agentName: string, installCmd: string): Promise { + logStep(`Installing ${agentName}...`); + try { + await runServer(installCmd); + } catch { + logError(`${agentName} installation failed`); + throw new Error(`${agentName} install failed`); + } + logInfo(`${agentName} installation completed`); +} + +async function uploadConfigFile( + content: string, + remotePath: string, +): Promise { + const tmpFile = join(tmpdir(), `spawn_config_${Date.now()}_${Math.random().toString(36).slice(2)}`); + writeFileSync(tmpFile, content, { mode: 0o600 }); + + const tempRemote = `/tmp/spawn_config_${Date.now()}`; + // Prevent command injection: escape remotePath for single-quoted shell context. + // $HOME prefix is handled by splitting into a safe shell variable reference + single-quoted suffix. + let shellPath: string; + if (remotePath.startsWith("$HOME/")) { + const suffix = remotePath.slice(6).replace(/'/g, "'\\''"); + shellPath = `"$HOME"/'${suffix}'`; + } else { + const escaped = remotePath.replace(/'/g, "'\\''"); + shellPath = `'${escaped}'`; + } + try { + await uploadFile(tmpFile, tempRemote); + await runServer( + `_RPATH=${shellPath} && mkdir -p "$(dirname "$_RPATH")" && chmod 600 '${tempRemote}' && mv '${tempRemote}' "$_RPATH"`, + ); + } finally { + try { unlinkSync(tmpFile); } catch { /* ignore */ } + } +} + +// ─── Claude Code ───────────────────────────────────────────────────────────── + +async function installClaudeCode(): Promise { + logStep("Installing Claude Code..."); + + const claudePath = '$HOME/.npm-global/bin:$HOME/.claude/local/bin:$HOME/.local/bin:$HOME/.bun/bin'; + const pathSetup = `for rc in ~/.bashrc ~/.zshrc; do grep -q '.claude/local/bin' "$rc" 2>/dev/null || printf '\\n# Claude Code PATH\\nexport PATH="$HOME/.claude/local/bin:$HOME/.local/bin:$HOME/.bun/bin:$PATH"\\n' >> "$rc"; done`; + const finalize = `claude install --force 2>/dev/null || true; ${pathSetup}`; + + const script = [ + `export PATH="${claudePath}:$PATH"`, + `if [ -f ~/.bash_profile ] && grep -q 'spawn:env\\|Claude Code PATH\\|spawn:path' ~/.bash_profile 2>/dev/null; then rm -f ~/.bash_profile; fi`, + `if command -v claude >/dev/null 2>&1; then ${finalize}; exit 0; fi`, + `echo "==> Installing Claude Code (method 1/2: curl installer)..."`, + `curl -fsSL https://claude.ai/install.sh | bash || true`, + `export PATH="${claudePath}:$PATH"`, + `if command -v claude >/dev/null 2>&1; then ${finalize}; exit 0; fi`, + `if ! command -v node >/dev/null 2>&1; then 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 || true; fi`, + `echo "==> Installing Claude Code (method 2/2: npm)..."`, + `npm install -g @anthropic-ai/claude-code || true`, + `export PATH="${claudePath}:$PATH"`, + `if command -v claude >/dev/null 2>&1; then ${finalize}; exit 0; fi`, + `exit 1`, + ].join('\n'); + + try { + await runServer(script, 300); + logInfo("Claude Code installed"); + } catch { + logError("Claude Code installation failed"); + throw new Error("Claude Code install failed"); + } +} + +async function setupClaudeCodeConfig(apiKey: string): Promise { + logStep("Configuring Claude Code..."); + + const escapedKey = jsonEscape(apiKey); + const settingsJson = `{ + "theme": "dark", + "editor": "vim", + "env": { + "CLAUDE_CODE_ENABLE_TELEMETRY": "0", + "ANTHROPIC_BASE_URL": "https://openrouter.ai/api", + "ANTHROPIC_AUTH_TOKEN": ${escapedKey} + }, + "permissions": { + "defaultMode": "bypassPermissions", + "dangerouslySkipPermissions": true + } +}`; + const globalState = `{ + "hasCompletedOnboarding": true, + "bypassPermissionsModeAccepted": true +}`; + + const settingsB64 = Buffer.from(settingsJson).toString("base64"); + const stateB64 = Buffer.from(globalState).toString("base64"); + + await runServer( + `mkdir -p ~/.claude && printf '%s' '${settingsB64}' | base64 -d > ~/.claude/settings.json && chmod 600 ~/.claude/settings.json && printf '%s' '${stateB64}' | base64 -d > ~/.claude.json && chmod 600 ~/.claude.json && touch ~/.claude/CLAUDE.md`, + ); + logInfo("Claude Code configured"); +} + +// ─── GitHub Auth ───────────────────────────────────────────────────────────── + +let githubAuthRequested = false; +let githubToken = ""; + +async function promptGithubAuth(): Promise { + if (process.env.SPAWN_SKIP_GITHUB_AUTH) return; + process.stderr.write("\n"); + const choice = await prompt("Set up GitHub CLI (gh) on this machine? (y/N): "); + if (/^[Yy]$/.test(choice)) { + githubAuthRequested = true; + if (process.env.GITHUB_TOKEN) { + githubToken = process.env.GITHUB_TOKEN; + } else { + try { + const result = Bun.spawnSync(["gh", "auth", "token"], { + stdio: ["ignore", "pipe", "ignore"], + }); + if (result.exitCode === 0) { + githubToken = new TextDecoder().decode(result.stdout).trim(); + } + } catch { /* ignore */ } + } + } +} + +export async function offerGithubAuth(): Promise { + if (process.env.SPAWN_SKIP_GITHUB_AUTH) return; + if (!githubAuthRequested) return; + + let ghCmd = "curl -fsSL https://raw.githubusercontent.com/OpenRouterTeam/spawn/main/shared/github-auth.sh | bash"; + let localTmpFile = ""; + if (githubToken) { + const escaped = githubToken.replace(/'/g, "'\\''"); + localTmpFile = join(tmpdir(), `gh_token_${Date.now()}_${Math.random().toString(36).slice(2)}`); + writeFileSync(localTmpFile, `export GITHUB_TOKEN='${escaped}'`, { mode: 0o600 }); + const remoteTmpFile = `/tmp/gh_token_${Date.now()}`; + try { + await uploadFile(localTmpFile, remoteTmpFile); + ghCmd = `. ${remoteTmpFile} && rm -f ${remoteTmpFile} && ${ghCmd}`; + } catch { + try { unlinkSync(localTmpFile); } catch { /* ignore */ } + localTmpFile = ""; + } + } + + logStep("Installing and authenticating GitHub CLI..."); + try { + await runServer(ghCmd); + } catch { + logWarn("GitHub CLI setup failed (non-fatal, continuing)"); + } finally { + if (localTmpFile) { + try { unlinkSync(localTmpFile); } catch { /* ignore */ } + } + } +} + +// ─── Codex CLI Config ──────────────────────────────────────────────────────── + +async function setupCodexConfig(apiKey: string): Promise { + logStep("Configuring Codex CLI for OpenRouter..."); + const config = `model = "openai/gpt-5-codex" +model_provider = "openrouter" + +[model_providers.openrouter] +name = "OpenRouter" +base_url = "https://openrouter.ai/api/v1" +env_key = "OPENROUTER_API_KEY" +wire_api = "chat" +`; + await uploadConfigFile(config, "$HOME/.codex/config.toml"); +} + +// ─── OpenClaw Config ───────────────────────────────────────────────────────── + +async function setupOpenclawConfig( + apiKey: string, + modelId: string, +): Promise { + logStep("Configuring openclaw..."); + await runServer("mkdir -p ~/.openclaw"); + + const gatewayToken = crypto.randomUUID().replace(/-/g, ""); + const escapedKey = jsonEscape(apiKey); + const escapedToken = jsonEscape(gatewayToken); + const escapedModel = jsonEscape(modelId); + + const config = `{ + "env": { + "OPENROUTER_API_KEY": ${escapedKey} + }, + "gateway": { + "mode": "local", + "auth": { + "token": ${escapedToken} + } + }, + "agents": { + "defaults": { + "model": { + "primary": ${escapedModel} + } + } + } +}`; + await uploadConfigFile(config, "$HOME/.openclaw/openclaw.json"); +} + +async function startGateway(): Promise { + logStep("Starting OpenClaw gateway daemon..."); + await runServer( + `source ~/.spawnrc 2>/dev/null; export PATH=$(npm prefix -g 2>/dev/null)/bin:$HOME/.bun/bin:$HOME/.local/bin:$PATH; ` + + `if command -v setsid >/dev/null 2>&1; then setsid openclaw gateway > /tmp/openclaw-gateway.log 2>&1 < /dev/null & ` + + `else nohup openclaw gateway > /tmp/openclaw-gateway.log 2>&1 < /dev/null & fi`, + ); + logInfo("OpenClaw gateway started"); +} + +// ─── OpenCode Install Command ──────────────────────────────────────────────── + +function openCodeInstallCmd(): string { + return 'OC_ARCH=$(uname -m); case "$OC_ARCH" in aarch64) OC_ARCH=arm64;; x86_64) OC_ARCH=x64;; esac; OC_OS=$(uname -s | tr A-Z a-z); mkdir -p /tmp/opencode-install "$HOME/.opencode/bin" && curl -fsSL -o /tmp/opencode-install/oc.tar.gz "https://github.com/anomalyco/opencode/releases/latest/download/opencode-${OC_OS}-${OC_ARCH}.tar.gz" && tar xzf /tmp/opencode-install/oc.tar.gz -C /tmp/opencode-install && mv /tmp/opencode-install/opencode "$HOME/.opencode/bin/" && rm -rf /tmp/opencode-install && grep -q ".opencode/bin" "$HOME/.bashrc" 2>/dev/null || echo \'export PATH="$HOME/.opencode/bin:$PATH"\' >> "$HOME/.bashrc"; grep -q ".opencode/bin" "$HOME/.zshrc" 2>/dev/null || echo \'export PATH="$HOME/.opencode/bin:$PATH"\' >> "$HOME/.zshrc" 2>/dev/null; export PATH="$HOME/.opencode/bin:$PATH"'; +} + +// ─── Agent Definitions ─────────────────────────────────────────────────────── + +export const agents: Record = { + claude: { + name: "Claude Code", + preProvision: promptGithubAuth, + install: installClaudeCode, + envVars: (apiKey) => [ + `OPENROUTER_API_KEY=${apiKey}`, + "ANTHROPIC_BASE_URL=https://openrouter.ai/api", + `ANTHROPIC_AUTH_TOKEN=${apiKey}`, + "ANTHROPIC_API_KEY=", + "CLAUDE_CODE_SKIP_ONBOARDING=1", + "CLAUDE_CODE_ENABLE_TELEMETRY=0", + ], + configure: (apiKey) => setupClaudeCodeConfig(apiKey), + launchCmd: () => + "source ~/.spawnrc 2>/dev/null; export PATH=$HOME/.claude/local/bin:$HOME/.local/bin:$HOME/.bun/bin:$PATH; claude", + }, + + codex: { + name: "Codex CLI", + install: () => installAgent("Codex CLI", "npm install -g @openai/codex"), + envVars: (apiKey) => [`OPENROUTER_API_KEY=${apiKey}`], + configure: (apiKey) => setupCodexConfig(apiKey), + launchCmd: () => + "source ~/.spawnrc 2>/dev/null; source ~/.zshrc 2>/dev/null; codex", + }, + + openclaw: { + name: "OpenClaw", + modelPrompt: true, + modelDefault: "openrouter/auto", + install: () => + installAgent( + "openclaw", + 'source ~/.bashrc 2>/dev/null; bun install -g openclaw || npm install -g openclaw', + ), + envVars: (apiKey) => [ + `OPENROUTER_API_KEY=${apiKey}`, + `ANTHROPIC_API_KEY=${apiKey}`, + "ANTHROPIC_BASE_URL=https://openrouter.ai/api", + ], + configure: (apiKey, modelId) => + setupOpenclawConfig(apiKey, modelId || "openrouter/auto"), + preLaunch: startGateway, + launchCmd: () => + "source ~/.spawnrc 2>/dev/null; source ~/.zshrc 2>/dev/null; openclaw tui", + }, + + opencode: { + name: "OpenCode", + install: () => installAgent("OpenCode", openCodeInstallCmd()), + envVars: (apiKey) => [`OPENROUTER_API_KEY=${apiKey}`], + launchCmd: () => + "source ~/.spawnrc 2>/dev/null; source ~/.zshrc 2>/dev/null; opencode", + }, + + kilocode: { + name: "Kilo Code", + install: () => installAgent("Kilo Code", "npm install -g @kilocode/cli"), + envVars: (apiKey) => [ + `OPENROUTER_API_KEY=${apiKey}`, + "KILO_PROVIDER_TYPE=openrouter", + `KILO_OPEN_ROUTER_API_KEY=${apiKey}`, + ], + launchCmd: () => + "source ~/.spawnrc 2>/dev/null; source ~/.zshrc 2>/dev/null; kilocode", + }, + + zeroclaw: { + name: "ZeroClaw", + install: () => + installAgent( + "ZeroClaw", + "curl -LsSf https://raw.githubusercontent.com/zeroclaw-labs/zeroclaw/a117be64fdaa31779204beadf2942c8aef57d0e5/scripts/install.sh | bash -s -- --install-rust --install-system-deps", + ), + envVars: (apiKey) => [ + `OPENROUTER_API_KEY=${apiKey}`, + "ZEROCLAW_PROVIDER=openrouter", + ], + configure: async (apiKey) => { + await runServer( + `source ~/.spawnrc 2>/dev/null; export PATH="$HOME/.cargo/bin:$PATH"; zeroclaw onboard --api-key "\${OPENROUTER_API_KEY}" --provider openrouter`, + ); + }, + launchCmd: () => + "source ~/.cargo/env 2>/dev/null; source ~/.spawnrc 2>/dev/null; zeroclaw agent", + }, +}; + +export function resolveAgent(name: string): AgentConfig { + const agent = agents[name.toLowerCase()]; + if (!agent) { + logError(`Unknown agent: ${name}`); + logError(`Available agents: ${Object.keys(agents).join(", ")}`); + throw new Error(`Unknown agent: ${name}`); + } + return agent; +} diff --git a/cli/src/gcp/gcp.ts b/cli/src/gcp/gcp.ts new file mode 100644 index 00000000..d27c0d1c --- /dev/null +++ b/cli/src/gcp/gcp.ts @@ -0,0 +1,726 @@ +// gcp/gcp.ts — Core GCP Compute Engine provider: gcloud CLI wrapper, auth, provisioning, SSH + +import { existsSync, mkdirSync, readFileSync, writeFileSync } from "fs"; +import { + logInfo, + logWarn, + logError, + logStep, + prompt, + selectFromList, + jsonEscape, + validateServerName, + toKebabCase, +} from "../shared/ui"; + +const DASHBOARD_URL = "https://console.cloud.google.com/compute/instances"; + +// ─── Machine Type Tiers ───────────────────────────────────────────────────── + +export interface MachineTypeTier { + id: string; + label: string; +} + +export const MACHINE_TYPES: MachineTypeTier[] = [ + { id: "e2-micro", label: "Shared CPU \u00b7 2 vCPU \u00b7 1 GB RAM (~$7/mo)" }, + { id: "e2-small", label: "Shared CPU \u00b7 2 vCPU \u00b7 2 GB RAM (~$14/mo)" }, + { id: "e2-medium", label: "Shared CPU \u00b7 2 vCPU \u00b7 4 GB RAM (~$28/mo)" }, + { id: "e2-standard-2", label: "2 vCPU \u00b7 8 GB RAM (~$49/mo)" }, + { id: "e2-standard-4", label: "4 vCPU \u00b7 16 GB RAM (~$98/mo)" }, + { id: "n2-standard-2", label: "2 vCPU \u00b7 8 GB RAM, higher perf (~$72/mo)" }, + { id: "n2-standard-4", label: "4 vCPU \u00b7 16 GB RAM, higher perf (~$144/mo)" }, + { id: "c4-standard-2", label: "2 vCPU \u00b7 8 GB RAM, latest gen (~$82/mo)" }, +]; + +export const DEFAULT_MACHINE_TYPE = "e2-medium"; + +// ─── Zone Options ──────────────────────────────────────────────────────────── + +export interface ZoneOption { + id: string; + label: string; +} + +export const ZONES: ZoneOption[] = [ + { id: "us-central1-a", label: "Iowa, US" }, + { id: "us-east1-b", label: "South Carolina, US" }, + { id: "us-east4-a", label: "N. Virginia, US" }, + { id: "us-west1-a", label: "Oregon, US" }, + { id: "us-west2-a", label: "Los Angeles, US" }, + { id: "northamerica-northeast1-a", label: "Montreal, Canada" }, + { id: "europe-west1-b", label: "Belgium" }, + { id: "europe-west4-a", label: "Netherlands" }, + { id: "europe-west6-a", label: "Zurich, Switzerland" }, + { id: "asia-east1-a", label: "Taiwan" }, + { id: "asia-southeast1-a", label: "Singapore" }, + { id: "australia-southeast1-a", label: "Sydney, Australia" }, +]; + +export const DEFAULT_ZONE = "us-central1-a"; + +// ─── State ────────────────────────────────────────────────────────────────── + +let gcpProject = ""; +let gcpZone = ""; +let gcpInstanceName = ""; +let gcpServerIp = ""; +let gcpUsername = ""; + +export function getState() { + return { gcpProject, gcpZone, gcpInstanceName, gcpServerIp, gcpUsername }; +} + +// ─── Helpers ──────────────────────────────────────────────────────────────── + +function sleep(ms: number): Promise { + return new Promise((r) => setTimeout(r, ms)); +} + +// ─── gcloud CLI Wrapper ───────────────────────────────────────────────────── + +function getGcloudCmd(): string | null { + if (Bun.spawnSync(["which", "gcloud"], { stdio: ["ignore", "pipe", "ignore"] }).exitCode === 0) { + return "gcloud"; + } + // Check common install locations + const paths = [ + `${process.env.HOME}/google-cloud-sdk/bin/gcloud`, + "/usr/lib/google-cloud-sdk/bin/gcloud", + "/snap/bin/gcloud", + ]; + for (const p of paths) { + if (existsSync(p)) return p; + } + return null; +} + +/** Run a gcloud command and return stdout. */ +function gcloudSync(args: string[]): { stdout: string; stderr: string; exitCode: number } { + const cmd = getGcloudCmd()!; + const proc = Bun.spawnSync([cmd, ...args], { + stdio: ["ignore", "pipe", "pipe"], + env: process.env, + }); + return { + stdout: new TextDecoder().decode(proc.stdout).trim(), + stderr: new TextDecoder().decode(proc.stderr).trim(), + exitCode: proc.exitCode, + }; +} + +/** Run a gcloud command asynchronously and return stdout. */ +async function gcloud(args: string[]): Promise<{ stdout: string; stderr: string; exitCode: number }> { + const cmd = getGcloudCmd()!; + const proc = Bun.spawn([cmd, ...args], { + stdio: ["ignore", "pipe", "pipe"], + env: process.env, + }); + const [stdout, stderr] = await Promise.all([ + new Response(proc.stdout).text(), + new Response(proc.stderr).text(), + ]); + const exitCode = await proc.exited; + return { stdout: stdout.trim(), stderr: stderr.trim(), exitCode }; +} + +/** Run a gcloud command interactively (inheriting stdio). */ +async function gcloudInteractive(args: string[]): Promise { + const cmd = getGcloudCmd()!; + const proc = Bun.spawn([cmd, ...args], { + stdio: ["inherit", "inherit", "inherit"], + env: process.env, + }); + return proc.exited; +} + +// ─── CLI Installation ─────────────────────────────────────────────────────── + +export async function ensureGcloudCli(): Promise { + if (getGcloudCmd()) { + logInfo("gcloud CLI available"); + return; + } + + logStep("Installing Google Cloud SDK..."); + + if (process.platform === "darwin") { + // Try Homebrew on macOS + const brewCheck = Bun.spawnSync(["which", "brew"], { stdio: ["ignore", "pipe", "ignore"] }); + if (brewCheck.exitCode === 0) { + const proc = Bun.spawn(["brew", "install", "--cask", "google-cloud-sdk"], { + stdio: ["ignore", "inherit", "inherit"], + }); + if ((await proc.exited) === 0) { + // Source the path + const prefix = new TextDecoder().decode( + Bun.spawnSync(["brew", "--prefix"], { stdio: ["ignore", "pipe", "ignore"] }).stdout + ).trim(); + const pathInc = `${prefix}/share/google-cloud-sdk/path.bash.inc`; + if (existsSync(pathInc)) { + // Add gcloud to PATH + const sdkBin = `${prefix}/share/google-cloud-sdk/bin`; + if (!process.env.PATH?.includes(sdkBin)) { + process.env.PATH = `${sdkBin}:${process.env.PATH}`; + } + } + if (getGcloudCmd()) { + logInfo("Google Cloud SDK installed via Homebrew"); + return; + } + } + } + } + + // Linux / macOS without brew: use Google's installer + const proc = Bun.spawn( + ["bash", "-c", [ + `_gcp_tmp=$(mktemp -d)`, + `curl -fsSL "https://dl.google.com/dl/cloudsdk/channels/rapid/downloads/google-cloud-cli-linux-x86_64.tar.gz" -o "$_gcp_tmp/gcloud.tar.gz"`, + `tar -xzf "$_gcp_tmp/gcloud.tar.gz" -C "$HOME"`, + `"$HOME/google-cloud-sdk/install.sh" --quiet --path-update true`, + `rm -rf "$_gcp_tmp"`, + ].join(" && ")], + { stdio: ["ignore", "inherit", "pipe"] }, + ); + const exitCode = await proc.exited; + if (exitCode !== 0) { + logError("Failed to install Google Cloud SDK"); + logError("Install manually: https://cloud.google.com/sdk/docs/install"); + throw new Error("gcloud install failed"); + } + + // Add to PATH + const sdkBin = `${process.env.HOME}/google-cloud-sdk/bin`; + if (!process.env.PATH?.includes(sdkBin)) { + process.env.PATH = `${sdkBin}:${process.env.PATH}`; + } + + if (!getGcloudCmd()) { + logError("gcloud not found after install. You may need to restart your shell."); + throw new Error("gcloud not in PATH"); + } + logInfo("Google Cloud SDK installed"); +} + +// ─── Authentication ───────────────────────────────────────────────────────── + +export async function authenticate(): Promise { + // Check for active account + const result = gcloudSync([ + "auth", "list", "--filter=status:ACTIVE", "--format=value(account)", + ]); + const activeAccount = result.stdout.split("\n")[0]?.trim(); + + if (activeAccount && activeAccount.includes("@")) { + logInfo(`Authenticated as: ${activeAccount}`); + return; + } + + logWarn("No active Google Cloud account -- launching gcloud auth login..."); + const exitCode = await gcloudInteractive(["auth", "login"]); + if (exitCode !== 0) { + logError("Authentication failed. You can also set credentials via:"); + logError(" export GOOGLE_APPLICATION_CREDENTIALS=/path/to/key.json"); + throw new Error("gcloud auth failed"); + } + logInfo("Authenticated with Google Cloud"); +} + +// ─── Project Resolution ───────────────────────────────────────────────────── + +export async function resolveProject(): Promise { + // 1. Env var + if (process.env.GCP_PROJECT) { + gcpProject = process.env.GCP_PROJECT; + logInfo(`Using GCP project from environment: ${gcpProject}`); + return; + } + + // 2. gcloud config + const configResult = gcloudSync(["config", "get-value", "project"]); + let project = configResult.stdout; + if (project === "(unset)") project = ""; + + // 3. Confirm or pick + if (project && process.env.SPAWN_NON_INTERACTIVE !== "1") { + const confirm = await prompt(`Use project '${project}'? [Y/n]: `); + if (/^[nN]/.test(confirm)) { + project = ""; + } + } + + if (!project) { + logInfo("Fetching your GCP projects..."); + const listResult = await gcloud([ + "projects", "list", + "--filter=lifecycleState=ACTIVE", + "--format=value(projectId,name)", + ]); + + if (listResult.exitCode !== 0 || !listResult.stdout) { + logError("Failed to list GCP projects"); + logError("Set one before retrying:"); + logError(" export GCP_PROJECT=your-project-id"); + throw new Error("No GCP project"); + } + + const items = listResult.stdout.split("\n") + .filter((l) => l.trim()) + .map((line) => { + const parts = line.split("\t"); + return `${parts[0]}|${parts[1] || parts[0]}`; + }); + + if (items.length === 0) { + logError("No active GCP projects found"); + logError("Create one at: https://console.cloud.google.com/projectcreate"); + throw new Error("No GCP projects"); + } + + project = await selectFromList(items, "GCP projects", items[0].split("|")[0]); + } + + if (!project) { + logError("No GCP project selected"); + logError("Set one before retrying:"); + logError(" export GCP_PROJECT=your-project-id"); + throw new Error("No GCP project"); + } + + gcpProject = project; + logInfo(`Using GCP project: ${gcpProject}`); +} + +// ─── Interactive Pickers ──────────────────────────────────────────────────── + +export async function promptMachineType(): Promise { + if (process.env.GCP_MACHINE_TYPE) { + logInfo(`Using machine type from environment: ${process.env.GCP_MACHINE_TYPE}`); + return process.env.GCP_MACHINE_TYPE; + } + + if (process.env.SPAWN_NON_INTERACTIVE === "1") { + return DEFAULT_MACHINE_TYPE; + } + + process.stderr.write("\n"); + const items = MACHINE_TYPES.map((t) => `${t.id}|${t.label}`); + return selectFromList(items, "GCP machine types", DEFAULT_MACHINE_TYPE); +} + +export async function promptZone(): Promise { + if (process.env.GCP_ZONE) { + logInfo(`Using zone from environment: ${process.env.GCP_ZONE}`); + return process.env.GCP_ZONE; + } + + if (process.env.SPAWN_NON_INTERACTIVE === "1") { + return DEFAULT_ZONE; + } + + process.stderr.write("\n"); + const items = ZONES.map((z) => `${z.id}|${z.label}`); + return selectFromList(items, "GCP zones", DEFAULT_ZONE); +} + +// ─── SSH Key ──────────────────────────────────────────────────────────────── + +function ensureSshKey(): string { + const keyPath = `${process.env.HOME}/.ssh/id_ed25519`; + const pubKeyPath = `${keyPath}.pub`; + + if (!existsSync(pubKeyPath)) { + logStep("Generating SSH key..."); + mkdirSync(`${process.env.HOME}/.ssh`, { recursive: true }); + const result = Bun.spawnSync( + ["ssh-keygen", "-t", "ed25519", "-f", keyPath, "-N", "", "-q"], + { stdio: ["ignore", "ignore", "ignore"] }, + ); + if (result.exitCode !== 0) { + logError("Failed to generate SSH key"); + throw new Error("SSH keygen failed"); + } + } + + const pubKey = readFileSync(pubKeyPath, "utf-8").trim(); + logInfo("SSH key ready"); + return pubKey; +} + +// ─── Username ─────────────────────────────────────────────────────────────── + +function resolveUsername(): string { + if (gcpUsername) return gcpUsername; + const result = Bun.spawnSync(["whoami"], { stdio: ["ignore", "pipe", "ignore"] }); + const username = new TextDecoder().decode(result.stdout).trim(); + if (!/^[a-zA-Z0-9_-]+$/.test(username)) { + logError("Invalid username detected"); + throw new Error("Invalid username"); + } + gcpUsername = username; + return username; +} + +// ─── Server Name ──────────────────────────────────────────────────────────── + +export async function getServerName(): Promise { + if (process.env.GCP_INSTANCE_NAME) { + const name = process.env.GCP_INSTANCE_NAME; + if (!validateServerName(name)) { + logError(`Invalid GCP_INSTANCE_NAME: '${name}'`); + throw new Error("Invalid server name"); + } + logInfo(`Using instance name from environment: ${name}`); + return name; + } + + const kebab = process.env.SPAWN_NAME_KEBAB + || (process.env.SPAWN_NAME ? toKebabCase(process.env.SPAWN_NAME) : ""); + const defaultName = kebab || "spawn"; + + if (process.env.SPAWN_NON_INTERACTIVE === "1") { + return defaultName; + } + + const answer = await prompt(`Enter instance name [${defaultName}]: `); + const name = answer || defaultName; + + if (!validateServerName(name)) { + logError(`Invalid instance name: '${name}'`); + throw new Error("Invalid server name"); + } + return name; +} + +/** Prompt for a spawn display name, derive kebab-case resource name. */ +export async function promptSpawnName(): Promise { + if (process.env.SPAWN_NAME_KEBAB) return; + + let displayName: string; + if (process.env.SPAWN_NAME) { + displayName = process.env.SPAWN_NAME; + logInfo(`Spawn name: ${displayName}`); + } else if (process.env.SPAWN_NON_INTERACTIVE === "1") { + displayName = "spawn"; + } else { + process.stderr.write("\n"); + displayName = await prompt('Spawn name (e.g. "My Dev Box"): '); + if (!displayName) displayName = "spawn"; + } + + let kebab = toKebabCase(displayName) || "spawn"; + + if (process.env.SPAWN_NON_INTERACTIVE !== "1") { + const confirmed = await prompt(`Resource name [${kebab}]: `); + if (confirmed) { + kebab = toKebabCase(confirmed) || "spawn"; + } + } + + process.env.SPAWN_NAME_DISPLAY = displayName; + process.env.SPAWN_NAME_KEBAB = kebab; + logInfo(`Using resource name: ${kebab}`); +} + +// ─── Cloud Init Startup Script ────────────────────────────────────────────── + +function getStartupScript(username: string): string { + return `#!/bin/bash +export DEBIAN_FRONTEND=noninteractive +apt-get update -y +apt-get install -y --no-install-recommends curl unzip git zsh nodejs npm ca-certificates +# Upgrade Node.js to v22 LTS +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 +# Install Bun and Claude Code as the login user +su - "${username}" -c 'curl -fsSL https://bun.sh/install | bash' || true +su - "${username}" -c 'curl -fsSL https://claude.ai/install.sh | bash' || true +# Configure npm global prefix +su - "${username}" -c 'mkdir -p ~/.npm-global/bin && npm config set prefix ~/.npm-global' +# Configure PATH for all users +echo 'export PATH="\${HOME}/.npm-global/bin:\${HOME}/.claude/local/bin:\${HOME}/.local/bin:\${HOME}/.bun/bin:\${PATH}"' >> /etc/profile.d/spawn.sh +chmod +x /etc/profile.d/spawn.sh +touch /tmp/.cloud-init-complete +`; +} + +// ─── Provisioning ─────────────────────────────────────────────────────────── + +export async function createInstance( + name: string, + zone: string, + machineType: string, +): Promise { + const username = resolveUsername(); + const pubKey = ensureSshKey(); + + logStep(`Creating GCP instance '${name}' (type: ${machineType}, zone: ${zone})...`); + + // Write startup script to a temp file + const tmpFile = `/tmp/spawn_startup_${Date.now()}.sh`; + writeFileSync(tmpFile, getStartupScript(username)); + + const args = [ + "compute", "instances", "create", name, + `--zone=${zone}`, + `--machine-type=${machineType}`, + "--image-family=ubuntu-2404-lts-amd64", + "--image-project=ubuntu-os-cloud", + `--metadata-from-file=startup-script=${tmpFile}`, + `--metadata=ssh-keys=${username}:${pubKey}`, + `--project=${gcpProject}`, + "--quiet", + ]; + + let result = await gcloud(args); + + // Auto-reauth on expired tokens + if (result.exitCode !== 0 && /reauthentication|refresh.*auth|token.*expired|credentials.*invalid/i.test(result.stderr)) { + logWarn("Auth tokens expired -- running gcloud auth login..."); + const reauth = await gcloudInteractive(["auth", "login"]); + if (reauth === 0) { + await gcloudInteractive(["config", "set", "project", gcpProject]); + logInfo("Re-authenticated, retrying instance creation..."); + result = await gcloud(args); + } + } + + // Clean up temp file + try { Bun.spawnSync(["rm", "-f", tmpFile]); } catch { /* ignore */ } + + if (result.exitCode !== 0) { + logError("Failed to create GCP instance"); + if (result.stderr) logError(`gcloud error: ${result.stderr}`); + logWarn("Common issues:"); + logWarn(" - Billing not enabled (enable at https://console.cloud.google.com/billing)"); + logWarn(" - Compute Engine API not enabled (enable at https://console.cloud.google.com/apis)"); + logWarn(" - Instance quota exceeded (try different GCP_ZONE)"); + logWarn(" - Machine type unavailable (try different GCP_MACHINE_TYPE or GCP_ZONE)"); + throw new Error("Instance creation failed"); + } + + // Get external IP + const ipResult = gcloudSync([ + "compute", "instances", "describe", name, + `--zone=${zone}`, + `--project=${gcpProject}`, + "--format=get(networkInterfaces[0].accessConfigs[0].natIP)", + ]); + + gcpInstanceName = name; + gcpZone = zone; + gcpServerIp = ipResult.stdout; + + logInfo(`Instance created: IP=${gcpServerIp}`); + + // Save connection info + const dir = `${process.env.HOME}/.spawn`; + mkdirSync(dir, { recursive: true }); + const zoneEscaped = jsonEscape(zone); + const projectEscaped = jsonEscape(gcpProject); + const json = JSON.stringify({ + ip: gcpServerIp, + user: username, + server_name: name, + cloud: "gcp", + zone, + project: gcpProject, + }); + writeFileSync(`${dir}/last-connection.json`, json + "\n"); +} + +// ─── SSH Operations ───────────────────────────────────────────────────────── + +const SSH_OPTS = "-o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o LogLevel=ERROR"; + +export async function waitForSsh(maxAttempts = 30): Promise { + logStep("Waiting for SSH connectivity..."); + const username = resolveUsername(); + + for (let attempt = 1; attempt <= maxAttempts; attempt++) { + try { + const proc = Bun.spawn( + ["ssh", ...SSH_OPTS.split(" "), "-o", "ConnectTimeout=5", `${username}@${gcpServerIp}`, "echo ok"], + { stdio: ["ignore", "pipe", "pipe"] }, + ); + const stdout = await new Response(proc.stdout).text(); + const exitCode = await proc.exited; + if (exitCode === 0 && stdout.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`); + throw new Error("SSH wait timeout"); +} + +export async function waitForCloudInit(maxAttempts = 60): Promise { + await waitForSsh(); + + logStep("Waiting for startup script completion..."); + const username = resolveUsername(); + + for (let attempt = 1; attempt <= maxAttempts; attempt++) { + try { + const proc = Bun.spawn( + ["ssh", ...SSH_OPTS.split(" "), "-o", "ConnectTimeout=5", `${username}@${gcpServerIp}`, "test -f /tmp/.cloud-init-complete"], + { stdio: ["ignore", "pipe", "pipe"] }, + ); + if ((await proc.exited) === 0) { + logInfo("Startup script completed"); + return; + } + } catch { + // ignore + } + logStep(`Startup script running (${attempt}/${maxAttempts})`); + await sleep(5000); + } + logWarn("Startup script may not have completed, continuing..."); +} + +export async function runServer(cmd: string, timeoutSecs?: number): Promise { + const username = resolveUsername(); + const fullCmd = `export PATH="$HOME/.npm-global/bin:$HOME/.claude/local/bin:$HOME/.local/bin:$HOME/.bun/bin:$PATH" && ${cmd}`; + + const proc = Bun.spawn( + ["ssh", ...SSH_OPTS.split(" "), `${username}@${gcpServerIp}`, `bash -c ${shellQuote(fullCmd)}`], + { stdio: ["ignore", "inherit", "inherit"], env: process.env }, + ); + const timeout = (timeoutSecs || 300) * 1000; + const timer = setTimeout(() => { try { proc.kill(); } catch {} }, timeout); + const exitCode = await proc.exited; + clearTimeout(timer); + if (exitCode !== 0) { + throw new Error(`run_server failed (exit ${exitCode}): ${cmd}`); + } +} + +export async function runServerCapture(cmd: string, timeoutSecs?: number): Promise { + const username = resolveUsername(); + const fullCmd = `export PATH="$HOME/.npm-global/bin:$HOME/.claude/local/bin:$HOME/.local/bin:$HOME/.bun/bin:$PATH" && ${cmd}`; + + const proc = Bun.spawn( + ["ssh", ...SSH_OPTS.split(" "), `${username}@${gcpServerIp}`, `bash -c ${shellQuote(fullCmd)}`], + { stdio: ["ignore", "pipe", "pipe"], env: process.env }, + ); + const timeout = (timeoutSecs || 300) * 1000; + const timer = setTimeout(() => { try { proc.kill(); } catch {} }, timeout); + const stdout = await new Response(proc.stdout).text(); + const exitCode = await proc.exited; + 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 { + const username = resolveUsername(); + // Expand $HOME on remote side + const expandedPath = remotePath.replace(/^\$HOME/, `~`); + + const proc = Bun.spawn( + ["scp", ...SSH_OPTS.split(" "), localPath, `${username}@${gcpServerIp}:${expandedPath}`], + { stdio: ["ignore", "inherit", "inherit"], env: process.env }, + ); + const exitCode = await proc.exited; + if (exitCode !== 0) throw new Error(`upload_file failed for ${remotePath}`); +} + +export async function interactiveSession(cmd: string): Promise { + const username = resolveUsername(); + const fullCmd = `export PATH="$HOME/.npm-global/bin:$HOME/.claude/local/bin:$HOME/.local/bin:$HOME/.bun/bin:$PATH" && ${cmd}`; + + const proc = Bun.spawn( + ["ssh", ...SSH_OPTS.split(" "), "-t", `${username}@${gcpServerIp}`, `bash -c ${shellQuote(fullCmd)}`], + { stdio: ["inherit", "inherit", "inherit"], env: process.env }, + ); + const exitCode = await proc.exited; + + // Post-session summary + process.stderr.write("\n"); + logWarn(`Session ended. Your GCP instance '${gcpInstanceName}' 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(` ${DASHBOARD_URL}`); + logWarn(""); + logInfo("To delete from CLI:"); + logInfo(" spawn delete"); + logInfo("To reconnect:"); + logInfo(` gcloud compute ssh ${gcpInstanceName} --zone=${gcpZone} --project=${gcpProject}`); + + return exitCode; +} + +// ─── Retry Helper ─────────────────────────────────────────────────────────── + +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}`); +} + +// ─── Lifecycle ────────────────────────────────────────────────────────────── + +export async function destroyInstance(name?: string): Promise { + const instanceName = name || gcpInstanceName; + const zone = gcpZone || process.env.GCP_ZONE || DEFAULT_ZONE; + + if (!instanceName) { + logError("destroy: no instance name provided"); + throw new Error("No instance name"); + } + + logStep(`Destroying GCP instance '${instanceName}'...`); + const result = await gcloud([ + "compute", "instances", "delete", instanceName, + `--zone=${zone}`, + `--project=${gcpProject}`, + "--quiet", + ]); + + if (result.exitCode !== 0) { + logError(`Failed to destroy GCP instance '${instanceName}'`); + logWarn("The instance may still be running and incurring charges."); + logWarn(`Delete it manually: ${DASHBOARD_URL}`); + throw new Error("Instance deletion failed"); + } + logInfo(`Instance '${instanceName}' destroyed`); +} + +// ─── Connection Tracking ──────────────────────────────────────────────────── + +export function saveLaunchCmd(launchCmd: string): void { + const connFile = `${process.env.HOME}/.spawn/last-connection.json`; + try { + const data = JSON.parse(readFileSync(connFile, "utf-8")); + data.launch_cmd = launchCmd; + writeFileSync(connFile, JSON.stringify(data) + "\n"); + } catch { + // non-fatal + } +} + +// ─── Shell Quoting ────────────────────────────────────────────────────────── + +function shellQuote(s: string): string { + return "'" + s.replace(/'/g, "'\\''") + "'"; +} diff --git a/cli/src/gcp/main.ts b/cli/src/gcp/main.ts new file mode 100644 index 00000000..8968fb89 --- /dev/null +++ b/cli/src/gcp/main.ts @@ -0,0 +1,131 @@ +#!/usr/bin/env bun +// gcp/main.ts — Orchestrator: deploys an agent on GCP Compute Engine + +import { + ensureGcloudCli, + authenticate, + resolveProject, + promptSpawnName, + promptMachineType, + promptZone, + getServerName, + createInstance, + waitForCloudInit, + runServer, + interactiveSession, + saveLaunchCmd, +} from "./gcp"; +import { getOrPromptApiKey, getModelIdInteractive } from "../shared/oauth"; +import { + resolveAgent, + generateEnvConfig, + offerGithubAuth, +} from "./agents"; +import { logInfo, logStep, logWarn } from "../shared/ui"; + +async function main() { + const agentName = process.argv[2]; + if (!agentName) { + console.error("Usage: bun run gcp/main.ts "); + console.error("Agents: claude, codex, openclaw, opencode, kilocode, zeroclaw"); + process.exit(1); + } + + const agent = resolveAgent(agentName); + logInfo(`${agent.name} on GCP Compute Engine`); + process.stderr.write("\n"); + + // 1. Authenticate with cloud provider + await promptSpawnName(); + await ensureGcloudCli(); + await authenticate(); + await resolveProject(); + + // 2. Pre-provision hooks + if (agent.preProvision) { + try { + await agent.preProvision(); + } catch { + // non-fatal + } + } + + // 3. Get API key (before provisioning so user isn't waiting) + const apiKey = await getOrPromptApiKey(agentName, "gcp"); + + // 4. Model selection (if agent needs it) + let modelId: string | undefined; + if (agent.modelPrompt) { + modelId = await getModelIdInteractive( + agent.modelDefault || "openrouter/auto", + agent.name, + ); + } + + // 5. Machine type and zone selection + const machineType = await promptMachineType(); + const zone = await promptZone(); + + // 6. Provision server + const serverName = await getServerName(); + await createInstance(serverName, zone, machineType); + + // 7. Wait for readiness + await waitForCloudInit(); + + // 8. Install agent + await agent.install(); + + // 9. Inject environment variables via .spawnrc + logStep("Setting up environment variables..."); + const envContent = generateEnvConfig(agent.envVars(apiKey)); + const envB64 = Buffer.from(envContent).toString("base64"); + try { + await runServer( + `printf '%s' '${envB64}' | base64 -d > ~/.spawnrc && chmod 600 ~/.spawnrc; ` + + `grep -q 'source ~/.spawnrc' ~/.bashrc 2>/dev/null || echo '[ -f ~/.spawnrc ] && source ~/.spawnrc' >> ~/.bashrc; ` + + `grep -q 'source ~/.spawnrc' ~/.zshrc 2>/dev/null || echo '[ -f ~/.spawnrc ] && source ~/.spawnrc' >> ~/.zshrc`, + ); + } catch { + logWarn("Environment setup had errors"); + } + + // GitHub CLI setup + await offerGithubAuth(); + + // 10. Agent-specific configuration + if (agent.configure) { + try { + await agent.configure(apiKey, modelId); + } catch { + logWarn("Agent configuration failed (continuing with defaults)"); + } + } + + // 11. Pre-launch hooks (e.g. OpenClaw gateway) + if (agent.preLaunch) { + await agent.preLaunch(); + } + + // 12. Launch interactive session + logInfo(`${agent.name} is ready`); + process.stderr.write("\n"); + logInfo("GCP instance setup completed successfully!"); + process.stderr.write("\n"); + logStep("Starting agent..."); + await new Promise((r) => setTimeout(r, 1000)); + + const launchCmd = agent.launchCmd(); + saveLaunchCmd(launchCmd); + const exitCode = await interactiveSession(launchCmd); + process.exit(exitCode); +} + +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/gcp/claude.sh b/gcp/claude.sh index 071854a2..11b1a002 100755 --- a/gcp/claude.sh +++ b/gcp/claude.sh @@ -1,30 +1,29 @@ #!/bin/bash set -eo pipefail -# Source common functions - try local file first, fall back to remote +# Thin shim: ensures bun is available, runs bundled gcp.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 https://bun.sh/install | bash >/dev/null 2>&1 || { 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)" -# shellcheck source=gcp/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/gcp/lib/common.sh)" + +# Local checkout — run from source +if [[ -n "$SCRIPT_DIR" && -f "$SCRIPT_DIR/../cli/src/gcp/main.ts" ]]; then + exec bun run "$SCRIPT_DIR/../cli/src/gcp/main.ts" claude "$@" fi -log_info "Claude Code on GCP Compute Engine" -echo "" +# Remote — download bundled gcp.js from GitHub release +GCP_JS=$(mktemp) +trap 'rm -f "$GCP_JS"' EXIT +curl -fsSL "https://github.com/OpenRouterTeam/spawn/releases/download/gcp-latest/gcp.js" -o "$GCP_JS" \ + || { printf '\033[0;31mFailed to download gcp.js\033[0m\n' >&2; exit 1; } -agent_pre_provision() { prompt_github_auth; } -agent_install() { install_claude_code cloud_run; } -agent_env_vars() { - generate_env_config \ - "OPENROUTER_API_KEY=${OPENROUTER_API_KEY}" \ - "ANTHROPIC_BASE_URL=https://openrouter.ai/api" \ - "ANTHROPIC_AUTH_TOKEN=${OPENROUTER_API_KEY}" \ - "ANTHROPIC_API_KEY=" \ - "CLAUDE_CODE_SKIP_ONBOARDING=1" \ - "CLAUDE_CODE_ENABLE_TELEMETRY=0" -} -agent_configure() { setup_claude_code_config "${OPENROUTER_API_KEY}" cloud_upload cloud_run; } -agent_launch_cmd() { echo 'source ~/.spawnrc 2>/dev/null; export PATH=$HOME/.claude/local/bin:$HOME/.local/bin:$HOME/.bun/bin:$PATH; claude'; } - -spawn_agent "Claude Code" "claude" "gcp" +exec bun run "$GCP_JS" claude "$@" diff --git a/gcp/codex.sh b/gcp/codex.sh index b968eba5..d36727d7 100755 --- a/gcp/codex.sh +++ b/gcp/codex.sh @@ -1,25 +1,29 @@ #!/bin/bash set -eo pipefail +# Thin shim: ensures bun is available, runs bundled gcp.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 https://bun.sh/install | bash >/dev/null 2>&1 || { 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)" -# shellcheck source=gcp/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/gcp/lib/common.sh)" + +# Local checkout — run from source +if [[ -n "$SCRIPT_DIR" && -f "$SCRIPT_DIR/../cli/src/gcp/main.ts" ]]; then + exec bun run "$SCRIPT_DIR/../cli/src/gcp/main.ts" codex "$@" fi -log_info "Codex CLI on GCP Compute Engine" -echo "" +# Remote — download bundled gcp.js from GitHub release +GCP_JS=$(mktemp) +trap 'rm -f "$GCP_JS"' EXIT +curl -fsSL "https://github.com/OpenRouterTeam/spawn/releases/download/gcp-latest/gcp.js" -o "$GCP_JS" \ + || { printf '\033[0;31mFailed to download gcp.js\033[0m\n' >&2; exit 1; } -agent_install() { install_agent "Codex CLI" "npm install -g @openai/codex" cloud_run; } -agent_env_vars() { - generate_env_config \ - "OPENROUTER_API_KEY=${OPENROUTER_API_KEY}" -} -agent_configure() { - setup_codex_config "${OPENROUTER_API_KEY}" cloud_upload cloud_run -} -agent_launch_cmd() { echo 'source ~/.spawnrc 2>/dev/null; source ~/.zshrc 2>/dev/null; codex'; } - -spawn_agent "Codex CLI" "codex" "gcp" +exec bun run "$GCP_JS" codex "$@" diff --git a/gcp/kilocode.sh b/gcp/kilocode.sh index 4c6343e3..dfd7281d 100755 --- a/gcp/kilocode.sh +++ b/gcp/kilocode.sh @@ -1,25 +1,29 @@ #!/bin/bash -# shellcheck disable=SC2154 set -eo pipefail +# Thin shim: ensures bun is available, runs bundled gcp.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 https://bun.sh/install | bash >/dev/null 2>&1 || { 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)" -# shellcheck source=gcp/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/gcp/lib/common.sh)" + +# Local checkout — run from source +if [[ -n "$SCRIPT_DIR" && -f "$SCRIPT_DIR/../cli/src/gcp/main.ts" ]]; then + exec bun run "$SCRIPT_DIR/../cli/src/gcp/main.ts" kilocode "$@" fi -log_info "Kilo Code on GCP Compute Engine" -echo "" +# Remote — download bundled gcp.js from GitHub release +GCP_JS=$(mktemp) +trap 'rm -f "$GCP_JS"' EXIT +curl -fsSL "https://github.com/OpenRouterTeam/spawn/releases/download/gcp-latest/gcp.js" -o "$GCP_JS" \ + || { printf '\033[0;31mFailed to download gcp.js\033[0m\n' >&2; exit 1; } -agent_install() { install_agent "Kilo Code" "npm install -g @kilocode/cli" cloud_run; } -agent_env_vars() { - generate_env_config \ - "OPENROUTER_API_KEY=${OPENROUTER_API_KEY}" \ - "KILO_PROVIDER_TYPE=openrouter" \ - "KILO_OPEN_ROUTER_API_KEY=${OPENROUTER_API_KEY}" -} -agent_launch_cmd() { echo 'source ~/.spawnrc 2>/dev/null; source ~/.zshrc 2>/dev/null; kilocode'; } - -spawn_agent "Kilo Code" "kilocode" "gcp" +exec bun run "$GCP_JS" kilocode "$@" diff --git a/gcp/openclaw.sh b/gcp/openclaw.sh index 73c55e79..e2a07da9 100755 --- a/gcp/openclaw.sh +++ b/gcp/openclaw.sh @@ -1,33 +1,29 @@ #!/bin/bash set -eo pipefail -# Source common functions - try local file first, fall back to remote +# Thin shim: ensures bun is available, runs bundled gcp.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 https://bun.sh/install | bash >/dev/null 2>&1 || { 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)" -# shellcheck source=gcp/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/gcp/lib/common.sh)" + +# Local checkout — run from source +if [[ -n "$SCRIPT_DIR" && -f "$SCRIPT_DIR/../cli/src/gcp/main.ts" ]]; then + exec bun run "$SCRIPT_DIR/../cli/src/gcp/main.ts" openclaw "$@" fi -log_info "OpenClaw on GCP Compute Engine" -echo "" +# Remote — download bundled gcp.js from GitHub release +GCP_JS=$(mktemp) +trap 'rm -f "$GCP_JS"' EXIT +curl -fsSL "https://github.com/OpenRouterTeam/spawn/releases/download/gcp-latest/gcp.js" -o "$GCP_JS" \ + || { printf '\033[0;31mFailed to download gcp.js\033[0m\n' >&2; exit 1; } -AGENT_MODEL_PROMPT=1 -AGENT_MODEL_DEFAULT="openrouter/auto" - -agent_install() { install_agent "openclaw" "source ~/.bashrc && bun install -g openclaw" cloud_run; } -agent_env_vars() { - generate_env_config \ - "OPENROUTER_API_KEY=${OPENROUTER_API_KEY}" \ - "ANTHROPIC_API_KEY=${OPENROUTER_API_KEY}" \ - "ANTHROPIC_BASE_URL=https://openrouter.ai/api" -} -agent_configure() { setup_openclaw_config "${OPENROUTER_API_KEY}" "${MODEL_ID}" cloud_upload cloud_run; } -agent_pre_launch() { - start_openclaw_gateway cloud_run - wait_for_openclaw_gateway cloud_run -} -agent_launch_cmd() { echo 'source ~/.spawnrc 2>/dev/null; source ~/.zshrc 2>/dev/null; openclaw tui'; } - -spawn_agent "OpenClaw" "openclaw" "gcp" +exec bun run "$GCP_JS" openclaw "$@" diff --git a/gcp/opencode.sh b/gcp/opencode.sh index 5ea32973..eae56130 100755 --- a/gcp/opencode.sh +++ b/gcp/opencode.sh @@ -1,19 +1,29 @@ #!/bin/bash set -eo pipefail +# Thin shim: ensures bun is available, runs bundled gcp.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 https://bun.sh/install | bash >/dev/null 2>&1 || { 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)" -# shellcheck source=gcp/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/gcp/lib/common.sh)" + +# Local checkout — run from source +if [[ -n "$SCRIPT_DIR" && -f "$SCRIPT_DIR/../cli/src/gcp/main.ts" ]]; then + exec bun run "$SCRIPT_DIR/../cli/src/gcp/main.ts" opencode "$@" fi -log_info "OpenCode on GCP Compute Engine" -echo "" +# Remote — download bundled gcp.js from GitHub release +GCP_JS=$(mktemp) +trap 'rm -f "$GCP_JS"' EXIT +curl -fsSL "https://github.com/OpenRouterTeam/spawn/releases/download/gcp-latest/gcp.js" -o "$GCP_JS" \ + || { printf '\033[0;31mFailed to download gcp.js\033[0m\n' >&2; exit 1; } -agent_install() { install_agent "OpenCode" "$(opencode_install_cmd)" cloud_run; } -agent_env_vars() { generate_env_config "OPENROUTER_API_KEY=${OPENROUTER_API_KEY}"; } -agent_launch_cmd() { echo 'source ~/.spawnrc 2>/dev/null; source ~/.zshrc 2>/dev/null; opencode'; } - -spawn_agent "OpenCode" "opencode" "gcp" +exec bun run "$GCP_JS" opencode "$@" diff --git a/gcp/zeroclaw.sh b/gcp/zeroclaw.sh index 3f7db1d4..d043ded0 100644 --- a/gcp/zeroclaw.sh +++ b/gcp/zeroclaw.sh @@ -1,37 +1,29 @@ #!/bin/bash set -eo pipefail -# Source common functions - try local file first, fall back to remote +# Thin shim: ensures bun is available, runs bundled gcp.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 https://bun.sh/install | bash >/dev/null 2>&1 || { 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)" -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/gcp/lib/common.sh)" + +# Local checkout — run from source +if [[ -n "$SCRIPT_DIR" && -f "$SCRIPT_DIR/../cli/src/gcp/main.ts" ]]; then + exec bun run "$SCRIPT_DIR/../cli/src/gcp/main.ts" zeroclaw "$@" fi -log_info "ZeroClaw on GCP Compute Engine" -echo "" -log_warn "Note: ZeroClaw is built from Rust source and may take 5-10 minutes to compile." -echo "" +# Remote — download bundled gcp.js from GitHub release +GCP_JS=$(mktemp) +trap 'rm -f "$GCP_JS"' EXIT +curl -fsSL "https://github.com/OpenRouterTeam/spawn/releases/download/gcp-latest/gcp.js" -o "$GCP_JS" \ + || { printf '\033[0;31mFailed to download gcp.js\033[0m\n' >&2; exit 1; } -agent_install() { - install_agent "ZeroClaw" \ - "curl -LsSf https://raw.githubusercontent.com/zeroclaw-labs/zeroclaw/a117be64fdaa31779204beadf2942c8aef57d0e5/scripts/install.sh | bash -s -- --install-rust --install-system-deps" \ - cloud_run -} - -agent_env_vars() { - generate_env_config \ - "OPENROUTER_API_KEY=${OPENROUTER_API_KEY}" \ - "ZEROCLAW_PROVIDER=openrouter" -} - -agent_configure() { - cloud_run 'source ~/.spawnrc 2>/dev/null; export PATH="$HOME/.cargo/bin:$PATH"; zeroclaw onboard --api-key "${OPENROUTER_API_KEY}" --provider openrouter' -} - -agent_launch_cmd() { - echo 'source ~/.cargo/env 2>/dev/null; source ~/.spawnrc 2>/dev/null; zeroclaw agent' -} - -spawn_agent "ZeroClaw" "zeroclaw" "gcp" +exec bun run "$GCP_JS" zeroclaw "$@"