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) <noreply@anthropic.com>
This commit is contained in:
A 2026-02-22 19:22:17 -08:00 committed by GitHub
parent 0843c5e708
commit ef2748069f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 65 additions and 102 deletions

View file

@ -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<number> {
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<number>((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");

View file

@ -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<number> {
fullCmd,
];
const proc = Bun.spawn(args, {
stdio: [
"inherit",
"inherit",
"inherit",
],
const exitCode = await new Promise<number>((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");

View file

@ -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<numb
const term = sanitizeTermValue(process.env.TERM || "xterm-256color");
const fullCmd = `export TERM=${term} PATH="$HOME/.local/bin:$HOME/.bun/bin:$PATH" && exec bash -l -c ${JSON.stringify(cmd)}`;
const proc = Bun.spawn(
[
"ssh",
...SSH_OPTS,
"-t",
`root@${serverIp}`,
fullCmd,
],
{
stdio: [
"inherit",
"inherit",
"inherit",
],
},
);
const exitCode = await proc.exited;
const exitCode = await new Promise<number>((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");

View file

@ -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<number> {
const escapedCmd = fullCmd.replace(/'/g, "'\\''");
const flyCmd = getCmd()!;
const proc = Bun.spawn(
[
flyCmd,
const exitCode = await new Promise<number>((resolve, reject) => {
const child = spawn(flyCmd, [
"ssh",
"console",
"-a",
@ -1072,17 +1072,13 @@ export async function interactiveSession(cmd: string): Promise<number> {
"--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");

View file

@ -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<number> {
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<number>((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");

View file

@ -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<numb
const term = sanitizeTermValue(process.env.TERM || "xterm-256color");
const fullCmd = `export TERM=${term} PATH="$HOME/.local/bin:$HOME/.bun/bin:$PATH" && exec bash -l -c ${JSON.stringify(cmd)}`;
const proc = Bun.spawn(
[
"ssh",
...SSH_OPTS,
"-t",
`root@${serverIp}`,
fullCmd,
],
{
stdio: [
"inherit",
"inherit",
"inherit",
],
},
);
const exitCode = await proc.exited;
const exitCode = await new Promise<number>((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");

View file

@ -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<number> {
const proc = Bun.spawn(
[
"bash",
"-c",
cmd,
],
{
stdio: [
"inherit",
"inherit",
"inherit",
],
return new Promise<number>((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 ─────────────────────────────────────────────────────

View file

@ -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<number> {
cmd,
];
const proc = Bun.spawn(args, {
stdio: [
"inherit",
"inherit",
"inherit",
],
const exitCode = await new Promise<number>((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");