diff --git a/cli/src/fly/agents.ts b/cli/src/fly/agents.ts index 88ad7725..3ffd2dc8 100644 --- a/cli/src/fly/agents.ts +++ b/cli/src/fly/agents.ts @@ -109,82 +109,48 @@ async function uploadConfigFile( // ─── Claude Code ───────────────────────────────────────────────────────────── async function installClaudeCode(): Promise { - const claudePath = - 'export PATH=$HOME/.npm-global/bin:$HOME/.claude/local/bin:$HOME/.local/bin:$HOME/.bun/bin:$PATH'; + logStep("Installing Claude Code..."); - // Clean up broken ~/.bash_profile from previous deployments - try { - await runServer( - "if [ -f ~/.bash_profile ] && grep -q 'spawn:env\\|Claude Code PATH\\|spawn:path' ~/.bash_profile 2>/dev/null; then rm -f ~/.bash_profile; fi", - ); - } catch { /* ignore */ } + // Batch the entire install into a single remote script to avoid multiple + // round-trips that each risked 408 deadline_exceeded via fly machine exec. + 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}`; - // Already installed? - try { - await runServerCapture(`${claudePath} && command -v claude`, 15); - logInfo("Claude Code already installed"); - await finalizeClaudeInstall(claudePath); - return; - } catch { /* not installed */ } + const script = [ + `export PATH="${claudePath}:$PATH"`, + // Clean up broken ~/.bash_profile from previous deployments + `if [ -f ~/.bash_profile ] && grep -q 'spawn:env\\|Claude Code PATH\\|spawn:path' ~/.bash_profile 2>/dev/null; then rm -f ~/.bash_profile; fi`, + // Already installed? + `if command -v claude >/dev/null 2>&1; then ${finalize}; exit 0; fi`, + // Method 1: curl installer + `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`, + // Ensure Node.js for npm method + `if ! command -v node >/dev/null 2>&1; then apt-get install -y nodejs npm 2>/dev/null && 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`, + // Method 2: npm + `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`, + // All methods failed + `exit 1`, + ].join('\n'); - // Method 1: curl installer - logStep("Installing Claude Code (method 1/2: curl installer)..."); try { - await runServer("curl -fsSL https://claude.ai/install.sh | bash"); - await runServerCapture(`${claudePath} && command -v claude`, 15); - logInfo("Claude Code installed via curl installer"); - await finalizeClaudeInstall(claudePath); - return; + await runServer(script, 300); + logInfo("Claude Code installed"); } catch { - logWarn("curl installer failed"); + logError("Claude Code installation failed"); + throw new Error("Claude Code install failed"); } - - // Ensure Node.js for npm/bun methods - try { - await runServerCapture(`${claudePath} && command -v node`, 15); - } catch { - logStep("Installing Node.js runtime (required for claude package)..."); - try { - await runServer( - "apt-get install -y 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", - ); - } catch { - logWarn("Could not install Node.js"); - } - } - - // Method 2: npm - logStep("Installing Claude Code (method 2/2: npm)..."); - try { - await runServer(`${claudePath} && npm install -g @anthropic-ai/claude-code`); - await runServerCapture(`${claudePath} && command -v claude`, 15); - logInfo("Claude Code installed via npm"); - await finalizeClaudeInstall(claudePath); - return; - } catch { - logWarn("npm install failed"); - } - - logError("Claude Code installation failed"); - throw new Error("Claude Code install failed"); -} - -async function finalizeClaudeInstall(claudePath: string): Promise { - logStep("Setting up Claude Code shell integration..."); - try { - await runServer(`${claudePath} && claude install --force`); - } catch { /* ignore */ } - try { - await runServer( - `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`, - ); - } catch { /* ignore */ } } function setupClaudeCodeConfig(apiKey: string): Promise { return (async () => { logStep("Configuring Claude Code..."); - await runServer("mkdir -p ~/.claude"); const escapedKey = jsonEscape(apiKey); const settingsJson = `{ @@ -200,14 +166,19 @@ function setupClaudeCodeConfig(apiKey: string): Promise { "dangerouslySkipPermissions": true } }`; - await uploadConfigFile(settingsJson, "$HOME/.claude/settings.json"); - const globalState = `{ "hasCompletedOnboarding": true, "bypassPermissionsModeAccepted": true }`; - await uploadConfigFile(globalState, "$HOME/.claude.json"); - await runServer("touch ~/.claude/CLAUDE.md"); + // Inline base64 file writes in a single remote call instead of + // separate mkdir + uploadFile + mv calls for each config file. + 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"); })(); } diff --git a/cli/src/fly/fly.ts b/cli/src/fly/fly.ts index add0b39a..22dc28da 100644 --- a/cli/src/fly/fly.ts +++ b/cli/src/fly/fly.ts @@ -610,23 +610,17 @@ export async function runServer( const fullCmd = `export PATH="$HOME/.local/bin:$HOME/.bun/bin:$PATH" && ${cmd}`; const flyCmd = getCmd()!; - const args = [flyCmd, "machine", "exec", flyMachineId, "--app", flyAppName, "--", "bash", "-c", fullCmd]; + const escapedCmd = fullCmd.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}'`]; - if (timeoutSecs) { - // Look for timeout/gtimeout - const timeoutBin = Bun.spawnSync(["which", "timeout"], { stdio: ["ignore", "pipe", "ignore"] }).exitCode === 0 - ? "timeout" - : Bun.spawnSync(["which", "gtimeout"], { stdio: ["ignore", "pipe", "ignore"] }).exitCode === 0 - ? "gtimeout" - : null; - - if (timeoutBin) { - args.unshift(timeoutBin, String(timeoutSecs)); - } - } - - const proc = Bun.spawn(args, { stdio: ["inherit", "inherit", "inherit"] }); + const proc = Bun.spawn(args, { stdio: ["inherit", "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(() => { try { proc.kill(); } catch {} }, timeout); const exitCode = await proc.exited; + clearTimeout(timer); if (exitCode !== 0) { throw new Error(`run_server failed (exit ${exitCode}): ${cmd}`); } @@ -640,18 +634,16 @@ export async function runServerCapture( const fullCmd = `export PATH="$HOME/.local/bin:$HOME/.bun/bin:$PATH" && ${cmd}`; const flyCmd = getCmd()!; - const args = [flyCmd, "machine", "exec", flyMachineId, "--app", flyAppName, "--", "bash", "-c", fullCmd]; + const escapedCmd = fullCmd.replace(/'/g, "'\\''"); + const args = [flyCmd, "ssh", "console", "-a", flyAppName, "-C", `bash -c '${escapedCmd}'`]; - const proc = Bun.spawn(args, { stdio: ["ignore", "pipe", "pipe"] }); - - let timer: ReturnType | undefined; - if (timeoutSecs) { - timer = setTimeout(() => proc.kill(), timeoutSecs * 1000); - } + const proc = Bun.spawn(args, { 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; - if (timer) clearTimeout(timer); + clearTimeout(timer); if (exitCode !== 0) throw new Error(`run_server_capture failed (exit ${exitCode})`); return stdout.trim(); @@ -667,13 +659,12 @@ export async function uploadFile( } const flyCmd = getCmd()!; const fs = require("fs"); - const content = fs.readFileSync(localPath); + const content: Buffer = fs.readFileSync(localPath); + const b64 = content.toString("base64"); const proc = Bun.spawn( - [flyCmd, "machine", "exec", flyMachineId, "--app", flyAppName, "--", "bash", "-c", `cat > ${remotePath}`], - { stdio: ["pipe", "ignore", "ignore"] }, + [flyCmd, "ssh", "console", "-a", flyAppName, "-C", `bash -c 'printf "%s" ${b64} | base64 -d > ${remotePath}'`], + { stdio: ["ignore", "ignore", "ignore"], env: process.env }, ); - proc.stdin!.write(content); - proc.stdin!.end(); const exitCode = await proc.exited; if (exitCode !== 0) throw new Error(`upload_file failed for ${remotePath}`); } @@ -686,7 +677,7 @@ export async function interactiveSession(cmd: string): Promise { const proc = Bun.spawn( [flyCmd, "ssh", "console", "-a", flyAppName, "--pty", "-C", `bash -c '${escapedCmd}'`], - { stdio: ["inherit", "inherit", "inherit"] }, + { stdio: ["inherit", "inherit", "inherit"], env: process.env }, ); const exitCode = await proc.exited; @@ -750,57 +741,25 @@ export async function waitForSsh(maxAttempts = 20): Promise { export async function waitForCloudInit(): Promise { await waitForSsh(); - logStep("Installing packages..."); - try { - await runWithRetry(3, 10, 300, "apt-get update -y && apt-get install -y curl unzip git"); - } catch { - logWarn("Package install failed, continuing anyway..."); - } + logStep("Installing packages (Node.js, bun)..."); + // Batch all package installs into a single remote script to avoid multiple + // round-trips (each of which was previously a separate fly machine exec call). + const setupScript = [ + `echo "==> Installing base packages..."`, + `apt-get update -y && apt-get install -y curl unzip git || true`, + `echo "==> Checking Node.js..."`, + `if ! command -v node >/dev/null 2>&1; then { curl -fsSL https://deb.nodesource.com/setup_22.x | bash - && apt-get install -y nodejs; } || apt-get install -y nodejs || true; fi`, + `echo "node: $(node --version 2>/dev/null || echo not installed)"`, + `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'); - logStep("Installing Node.js..."); try { - await runWithRetry( - 3, - 10, - 180, - "curl -fsSL https://deb.nodesource.com/setup_22.x | bash - && apt-get install -y nodejs", - ); + await runWithRetry(3, 10, 300, setupScript); } catch { - // ignore + logWarn("Package install had errors, continuing..."); } - - // Verify node - try { - await runServerCapture("which node && node --version", 15); - const ver = await runServerCapture("node --version", 10); - logInfo(`Node.js installed: ${ver}`); - } catch { - logWarn("Node.js not found after nodesource install, falling back to default Debian package..."); - try { - await runWithRetry(2, 5, 120, "apt-get install -y nodejs"); - const ver = await runServerCapture("node --version", 10); - logInfo(`Node.js installed from default Debian repos: ${ver}`); - } catch { - logError("Node.js is NOT installed — npm-based agents will not work"); - } - } - - logStep("Installing bun..."); - try { - await runWithRetry(2, 5, 120, "curl -fsSL https://bun.sh/install | bash"); - } catch { - // ignore - } - - // Add to PATH in shell configs - const pathLine = 'echo "export PATH=\\"\\$HOME/.local/bin:\\$HOME/.bun/bin:\\$PATH\\"" >> ~/.bashrc'; - const pathLineZsh = 'echo "export PATH=\\"\\$HOME/.local/bin:\\$HOME/.bun/bin:\\$PATH\\"" >> ~/.zshrc'; - try { - await runServer(pathLine, 30); - } catch { /* ignore */ } - try { - await runServer(pathLineZsh, 30); - } catch { /* ignore */ } logInfo("Base tools installed"); } diff --git a/cli/src/fly/main.ts b/cli/src/fly/main.ts index 2f47b64c..df131652 100644 --- a/cli/src/fly/main.ts +++ b/cli/src/fly/main.ts @@ -10,7 +10,6 @@ import { getServerName, waitForCloudInit, runServer, - uploadFile, interactiveSession, } from "./fly"; import { getOrPromptApiKey, getModelIdInteractive } from "./oauth"; @@ -76,29 +75,19 @@ async function main() { await agent.install(); // 8. Inject environment variables via .spawnrc + // Inline base64 write + shell hook in a single remote call instead of + // separate uploadFile + mv + 2× shell hook calls. logStep("Setting up environment variables..."); const envContent = generateEnvConfig(agent.envVars(apiKey)); - const fs = require("fs"); - const os = require("os"); - const path = require("path"); - const tmpFile = path.join(os.tmpdir(), `spawn_env_${Date.now()}`); - fs.writeFileSync(tmpFile, envContent, { mode: 0o600 }); - - const tempRemote = `/tmp/spawn_env_${Date.now()}`; + const envB64 = Buffer.from(envContent).toString("base64"); try { - await uploadFile(tmpFile, tempRemote); await runServer( - `cp '${tempRemote}' ~/.spawnrc && chmod 600 ~/.spawnrc; rm -f '${tempRemote}'`, + `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`, ); - // Hook .spawnrc into shell configs - await runServer( - "grep -q 'source ~/.spawnrc' ~/.bashrc 2>/dev/null || echo '[ -f ~/.spawnrc ] && source ~/.spawnrc' >> ~/.bashrc", - ).catch(() => logWarn("Could not hook .spawnrc into .bashrc")); - await runServer( - "grep -q 'source ~/.spawnrc' ~/.zshrc 2>/dev/null || echo '[ -f ~/.spawnrc ] && source ~/.spawnrc' >> ~/.zshrc", - ).catch(() => logWarn("Could not hook .spawnrc into .zshrc")); - } finally { - try { fs.unlinkSync(tmpFile); } catch { /* ignore */ } + } catch { + logWarn("Environment setup had errors"); } // GitHub CLI setup