From ef2748069fe9bc6dea21a257cbc74c0106dc36e8 Mon Sep 17 00:00:00 2001 From: A <258483684+la14-1@users.noreply.github.com> Date: Sun, 22 Feb 2026 19:22:17 -0800 Subject: [PATCH] fix: use child_process.spawn for interactive sessions to fix TTY passthrough (#1780) Bun.spawn() doesn't properly restore TTY state after @clack/prompts manipulates stdin raw mode during provisioning. This causes laggy/broken keyboard input in SSH sessions launched via `spawn run`. Node's child_process.spawn() with stdio: "inherit" does a clean FD handoff, matching the already-working pattern in runInteractiveCommand() used by `spawn ls` resume. Co-authored-by: lab <6723574+louisgv@users.noreply.github.com> Co-authored-by: Claude Opus 4.6 (1M context) --- cli/src/aws/aws.ts | 22 +++++++++------------- cli/src/daytona/daytona.ts | 14 +++++++------- cli/src/digitalocean/digitalocean.ts | 25 ++++++++----------------- cli/src/fly/fly.ts | 22 +++++++++------------- cli/src/gcp/gcp.ts | 22 +++++++++------------- cli/src/hetzner/hetzner.ts | 25 ++++++++----------------- cli/src/local/local.ts | 23 ++++++++--------------- cli/src/sprite/sprite.ts | 14 +++++++------- 8 files changed, 65 insertions(+), 102 deletions(-) diff --git a/cli/src/aws/aws.ts b/cli/src/aws/aws.ts index 27c0c676..c220e9d8 100644 --- a/cli/src/aws/aws.ts +++ b/cli/src/aws/aws.ts @@ -1,6 +1,7 @@ // aws/aws.ts — Core AWS Lightsail provider: auth, provisioning, SSH execution import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import { spawn } from "node:child_process"; import { createHash, createHmac } from "node:crypto"; import { logInfo, @@ -1033,23 +1034,18 @@ export async function interactiveSession(cmd: string): Promise { const term = sanitizeTermValue(process.env.TERM || "xterm-256color"); const fullCmd = `export TERM=${term} PATH="$HOME/.npm-global/bin:$HOME/.claude/local/bin:$HOME/.local/bin:$HOME/.bun/bin:$PATH" && exec bash -l -c ${JSON.stringify(cmd)}`; const escapedCmd = fullCmd.replace(/'/g, "'\\''"); - const proc = Bun.spawn( - [ - "ssh", + const exitCode = await new Promise((resolve, reject) => { + const child = spawn("ssh", [ ...SSH_OPTS, "-t", `${SSH_USER}@${instanceIp}`, `bash -c '${escapedCmd}'`, - ], - { - stdio: [ - "inherit", - "inherit", - "inherit", - ], - }, - ); - const exitCode = await proc.exited; + ], { + stdio: "inherit", + }); + child.on("close", (code) => resolve(code ?? 0)); + child.on("error", reject); + }); // Post-session summary process.stderr.write("\n"); diff --git a/cli/src/daytona/daytona.ts b/cli/src/daytona/daytona.ts index 8ea08146..f1efde70 100644 --- a/cli/src/daytona/daytona.ts +++ b/cli/src/daytona/daytona.ts @@ -1,6 +1,7 @@ // daytona/daytona.ts — Core Daytona provider: API, SSH, provisioning, execution import { readFileSync, writeFileSync, mkdirSync } from "node:fs"; +import { spawn } from "node:child_process"; import { logInfo, logWarn, @@ -519,14 +520,13 @@ export async function interactiveSession(cmd: string): Promise { fullCmd, ]; - const proc = Bun.spawn(args, { - stdio: [ - "inherit", - "inherit", - "inherit", - ], + const exitCode = await new Promise((resolve, reject) => { + const child = spawn(args[0], args.slice(1), { + stdio: "inherit", + }); + child.on("close", (code) => resolve(code ?? 0)); + child.on("error", reject); }); - const exitCode = await proc.exited; // Post-session summary process.stderr.write("\n"); diff --git a/cli/src/digitalocean/digitalocean.ts b/cli/src/digitalocean/digitalocean.ts index d6a4e4a3..5b247eb2 100644 --- a/cli/src/digitalocean/digitalocean.ts +++ b/cli/src/digitalocean/digitalocean.ts @@ -1,6 +1,7 @@ // digitalocean/digitalocean.ts — Core DigitalOcean provider: API, auth, SSH, provisioning import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import { spawn } from "node:child_process"; import * as v from "valibot"; import { logInfo, @@ -1034,23 +1035,13 @@ export async function interactiveSession(cmd: string, ip?: string): Promise((resolve, reject) => { + const child = spawn("ssh", [...SSH_OPTS, "-t", `root@${serverIp}`, fullCmd], { + stdio: "inherit", + }); + child.on("close", (code) => resolve(code ?? 0)); + child.on("error", reject); + }); // Post-session summary process.stderr.write("\n"); diff --git a/cli/src/fly/fly.ts b/cli/src/fly/fly.ts index dedf5465..e45c7f03 100644 --- a/cli/src/fly/fly.ts +++ b/cli/src/fly/fly.ts @@ -1,6 +1,7 @@ // fly/lib/fly.ts — Core Fly.io provider: API, auth, orgs, provisioning, execution import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import { spawn } from "node:child_process"; import { logInfo, logWarn, @@ -1062,9 +1063,8 @@ export async function interactiveSession(cmd: string): Promise { const escapedCmd = fullCmd.replace(/'/g, "'\\''"); const flyCmd = getCmd()!; - const proc = Bun.spawn( - [ - flyCmd, + const exitCode = await new Promise((resolve, reject) => { + const child = spawn(flyCmd, [ "ssh", "console", "-a", @@ -1072,17 +1072,13 @@ export async function interactiveSession(cmd: string): Promise { "--pty", "-C", `bash -c '${escapedCmd}'`, - ], - { - stdio: [ - "inherit", - "inherit", - "inherit", - ], + ], { + stdio: "inherit", env: process.env, - }, - ); - const exitCode = await proc.exited; + }); + child.on("close", (code) => resolve(code ?? 0)); + child.on("error", reject); + }); // Post-session summary process.stderr.write("\n"); diff --git a/cli/src/gcp/gcp.ts b/cli/src/gcp/gcp.ts index a2228428..a7c8a17b 100644 --- a/cli/src/gcp/gcp.ts +++ b/cli/src/gcp/gcp.ts @@ -1,6 +1,7 @@ // gcp/gcp.ts — Core GCP Compute Engine provider: gcloud CLI wrapper, auth, provisioning, SSH import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import { spawn } from "node:child_process"; import { logInfo, logWarn, @@ -919,24 +920,19 @@ export async function interactiveSession(cmd: string): Promise { const term = sanitizeTermValue(process.env.TERM || "xterm-256color"); const fullCmd = `export TERM=${term} PATH="$HOME/.npm-global/bin:$HOME/.claude/local/bin:$HOME/.local/bin:$HOME/.bun/bin:$PATH" && exec bash -l -c ${JSON.stringify(cmd)}`; - const proc = Bun.spawn( - [ - "ssh", + const exitCode = await new Promise((resolve, reject) => { + const child = spawn("ssh", [ ...SSH_OPTS, "-t", `${username}@${gcpServerIp}`, `bash -c ${shellQuote(fullCmd)}`, - ], - { - stdio: [ - "inherit", - "inherit", - "inherit", - ], + ], { + stdio: "inherit", env: process.env, - }, - ); - const exitCode = await proc.exited; + }); + child.on("close", (code) => resolve(code ?? 0)); + child.on("error", reject); + }); // Post-session summary process.stderr.write("\n"); diff --git a/cli/src/hetzner/hetzner.ts b/cli/src/hetzner/hetzner.ts index b7dc53af..ac35fae4 100644 --- a/cli/src/hetzner/hetzner.ts +++ b/cli/src/hetzner/hetzner.ts @@ -1,6 +1,7 @@ // hetzner/hetzner.ts — Core Hetzner Cloud provider: API, auth, SSH, provisioning import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import { spawn } from "node:child_process"; import { logInfo, logWarn, @@ -634,23 +635,13 @@ export async function interactiveSession(cmd: string, ip?: string): Promise((resolve, reject) => { + const child = spawn("ssh", [...SSH_OPTS, "-t", `root@${serverIp}`, fullCmd], { + stdio: "inherit", + }); + child.on("close", (code) => resolve(code ?? 0)); + child.on("error", reject); + }); // Post-session summary process.stderr.write("\n"); diff --git a/cli/src/local/local.ts b/cli/src/local/local.ts index 4fb2edba..e9e5267a 100644 --- a/cli/src/local/local.ts +++ b/cli/src/local/local.ts @@ -2,6 +2,7 @@ import { copyFileSync, mkdirSync, readFileSync } from "node:fs"; import { dirname } from "node:path"; +import { spawn } from "node:child_process"; // ─── Execution ─────────────────────────────────────────────────────────────── @@ -68,22 +69,14 @@ export function uploadFile(localPath: string, remotePath: string): void { /** Launch an interactive shell session locally. */ export async function interactiveSession(cmd: string): Promise { - const proc = Bun.spawn( - [ - "bash", - "-c", - cmd, - ], - { - stdio: [ - "inherit", - "inherit", - "inherit", - ], + return new Promise((resolve, reject) => { + const child = spawn("bash", ["-c", cmd], { + stdio: "inherit", env: process.env, - }, - ); - return proc.exited; + }); + child.on("close", (code) => resolve(code ?? 0)); + child.on("error", reject); + }); } // ─── Connection Tracking ───────────────────────────────────────────────────── diff --git a/cli/src/sprite/sprite.ts b/cli/src/sprite/sprite.ts index e6831a73..b0a3e53e 100644 --- a/cli/src/sprite/sprite.ts +++ b/cli/src/sprite/sprite.ts @@ -1,6 +1,7 @@ // sprite/sprite.ts — Core Sprite provider: CLI installation, auth, provisioning, execution import { existsSync, readFileSync, writeFileSync, mkdirSync } from "node:fs"; +import { spawn } from "node:child_process"; import { logInfo, logWarn, @@ -592,14 +593,13 @@ export async function interactiveSession(cmd: string): Promise { cmd, ]; - const proc = Bun.spawn(args, { - stdio: [ - "inherit", - "inherit", - "inherit", - ], + const exitCode = await new Promise((resolve, reject) => { + const child = spawn(args[0], args.slice(1), { + stdio: "inherit", + }); + child.on("close", (code) => resolve(code ?? 0)); + child.on("error", reject); }); - const exitCode = await proc.exited; // Post-session summary process.stderr.write("\n");