From 2da911e4a02e1da58f5df969ba86a4d2ebf38f46 Mon Sep 17 00:00:00 2001 From: Manoj Naik <68473696+ManojINaik@users.noreply.github.com> Date: Tue, 6 Jan 2026 02:03:03 +0530 Subject: [PATCH] fix: prevent /copy crash on Windows by skipping /dev/tty (#15657) Co-authored-by: Jack Wotherspoon --- .../cli/src/ui/utils/commandUtils.test.ts | 32 +++++++++++++++++++ packages/cli/src/ui/utils/commandUtils.ts | 18 +++++++---- 2 files changed, 44 insertions(+), 6 deletions(-) diff --git a/packages/cli/src/ui/utils/commandUtils.test.ts b/packages/cli/src/ui/utils/commandUtils.test.ts index 0694a7715d..7a2e62a947 100644 --- a/packages/cli/src/ui/utils/commandUtils.test.ts +++ b/packages/cli/src/ui/utils/commandUtils.test.ts @@ -98,6 +98,9 @@ describe('commandUtils', () => { beforeEach(async () => { vi.clearAllMocks(); + // Reset platform to default for test isolation + mockProcess.platform = 'darwin'; + // Dynamically import and set up spawn mock const { spawn } = await import('node:child_process'); mockSpawn = spawn as Mock; @@ -354,6 +357,35 @@ describe('commandUtils', () => { expect(tty.write).not.toHaveBeenCalled(); expect(tty.end).not.toHaveBeenCalled(); }); + + it('skips /dev/tty on Windows and uses stderr fallback for OSC-52', async () => { + mockProcess.platform = 'win32'; + const stderrStream = makeWritable({ isTTY: true }); + Object.defineProperty(process, 'stderr', { + value: stderrStream, + configurable: true, + }); + + // Set SSH environment to trigger OSC-52 path + process.env['SSH_CONNECTION'] = '1'; + + await copyToClipboard('windows-ssh-test'); + + expect(mockFs.createWriteStream).not.toHaveBeenCalled(); + expect(stderrStream.write).toHaveBeenCalled(); + expect(mockClipboardyWrite).not.toHaveBeenCalled(); + }); + + it('uses clipboardy on native Windows without SSH/WSL', async () => { + mockProcess.platform = 'win32'; + mockClipboardyWrite.mockResolvedValue(undefined); + + await copyToClipboard('windows-native-test'); + + // Fallback to clipboardy and not /dev/tty + expect(mockClipboardyWrite).toHaveBeenCalledWith('windows-native-test'); + expect(mockFs.createWriteStream).not.toHaveBeenCalled(); + }); }); describe('getUrlOpenCommand', () => { diff --git a/packages/cli/src/ui/utils/commandUtils.ts b/packages/cli/src/ui/utils/commandUtils.ts index d16e108423..c1bd755221 100644 --- a/packages/cli/src/ui/utils/commandUtils.ts +++ b/packages/cli/src/ui/utils/commandUtils.ts @@ -66,13 +66,19 @@ const SCREEN_DCS_CHUNK_SIZE = 240; type TtyTarget = { stream: Writable; closeAfter: boolean } | null; const pickTty = (): TtyTarget => { - // Prefer the controlling TTY to avoid interleaving escape sequences with piped stdout. - try { - const devTty = fs.createWriteStream('/dev/tty'); - return { stream: devTty, closeAfter: true }; - } catch { - // fall through + // /dev/tty is only available on Unix-like systems (Linux, macOS, BSD, etc.) + if (process.platform !== 'win32') { + // Prefer the controlling TTY to avoid interleaving escape sequences with piped stdout. + try { + const devTty = fs.createWriteStream('/dev/tty'); + // Prevent unhandled 'error' events from crashing the process. + devTty.on('error', () => {}); + return { stream: devTty, closeAfter: true }; + } catch { + // fall through - /dev/tty not accessible + } } + if (process.stderr?.isTTY) return { stream: process.stderr, closeAfter: false }; if (process.stdout?.isTTY)