From 0bda41d281e0281fbe133117091bb77dfb2f743e Mon Sep 17 00:00:00 2001 From: betegon Date: Tue, 19 May 2026 12:31:32 +0200 Subject: [PATCH 1/4] fix(init): fix InkUI on native Windows terminals (CLI-1NT) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit On Windows, /dev/tty doesn't exist in CMD.exe, PowerShell, or Windows Terminal. openFreshTtyForInk() returned null, leaving Ink reading from process.stdin — the broken Bun fd 0 (oven-sh/bun#6862) — which caused mountApp() to throw. getUIAsync() swallowed the throw silently and fell back to LoggingUI, which then threw LoggingUIPromptError in preamble(), surfacing as "The interactive UI failed to load." Git Bash / MSYS2 / Cygwin users were unaffected because their POSIX emulation layer maps /dev/tty correctly. Three changes: - ink-ui.ts: try \\.\CON as the TTY device path on Windows. A freshly opened console handle is not fd 0 and should deliver readable events correctly, the same way /dev/tty does on Unix. A setRawMode(true/false) probe verifies the handle supports raw mode before handing it to Ink — if it fails the function returns null and falls back gracefully. - factory.ts: add a breadcrumb in the InkUI catch block (including the error stack) so the actual throw is visible in Sentry. Previously only the downstream LoggingUIPromptError appeared, with no indication of the real cause. - wizard-runner.ts: when LoggingUIPromptError fires but isTTY is true, point the user to --no-tui --yes rather than just --yes, which alone doesn't explain why the TUI failed. Fixes CLI-1NT --- src/lib/init/ui/factory.ts | 15 ++++++++++++++- src/lib/init/ui/ink-ui.ts | 35 ++++++++++++++++++++++++++++------- src/lib/init/wizard-runner.ts | 10 ++++++---- 3 files changed, 48 insertions(+), 12 deletions(-) diff --git a/src/lib/init/ui/factory.ts b/src/lib/init/ui/factory.ts index eb436cb6b..e17face5c 100644 --- a/src/lib/init/ui/factory.ts +++ b/src/lib/init/ui/factory.ts @@ -29,6 +29,7 @@ * companions add a fraction of that and use no native code. */ +import { addBreadcrumb } from "@sentry/node-core/light"; import { LoggingUI } from "./logging-ui.js"; import type { WelcomeOptions, WizardUI } from "./types.js"; @@ -102,10 +103,22 @@ export async function getUIAsync(opts: UIFactoryOptions): Promise { try { const { createInkUI } = await import("./ink-ui.js"); return await createInkUI({ initialWelcome: opts.initialWelcome }); - } catch { + } catch (err) { // Fall through to LoggingUI so a missing/broken sidecar // doesn't take down the wizard. Unreachable on a correctly // built package — safety net for corrupted installs. + // + // Breadcrumb so the actual throw is visible in Sentry — otherwise + // only the downstream LoggingUIPromptError appears with no context. + addBreadcrumb({ + category: "init.ui", + message: "InkUI creation failed, falling back to LoggingUI", + data: { + error: String(err), + stack: err instanceof Error ? err.stack : undefined, + }, + level: "warning", + }); return new LoggingUI(); } } diff --git a/src/lib/init/ui/ink-ui.ts b/src/lib/init/ui/ink-ui.ts index 0de16171f..caf53fa35 100644 --- a/src/lib/init/ui/ink-ui.ts +++ b/src/lib/init/ui/ink-ui.ts @@ -190,16 +190,37 @@ function severityForStopCode(code: SpinnerExitCode): LogSeverity { import inkAppPath from "./ink-app.tsx" with { type: "file" }; /** - * Open a fresh `/dev/tty` `ReadStream` for Ink to consume. Returns - * `null` when `/dev/tty` isn't available (non-TTY environment, or - * platforms that don't expose it — Windows). The caller falls back - * to `process.stdin` in that case, which works on Node but is - * broken in Bun-compiled binaries (see module docstring). + * Open a fresh TTY `ReadStream` for Ink to consume. Returns `null` + * when no TTY device can be opened (non-TTY environment, sandboxed + * container, etc.). The caller falls back to `process.stdin` in that + * case, which works on Node but is broken in Bun-compiled binaries + * (see module docstring). + * + * On Windows, `/dev/tty` does not exist in native terminals + * (CMD.exe, PowerShell, Windows Terminal). The Windows console device + * `\\.\CON` is the equivalent — a freshly-opened handle is not fd 0 + * and should deliver `readable` events correctly, sidestepping the + * same Bun inherited-stdin bug (oven-sh/bun#6862) that the `/dev/tty` + * workaround addresses on Unix. Git Bash / MSYS2 / Cygwin users already + * have `/dev/tty` mapped via their POSIX emulation layer, so they are + * unaffected by this branch. + * + * After opening, `setRawMode(true)` is probed immediately and the + * stream is returned to canonical mode. This verifies the handle + * actually supports raw mode before we hand it to Ink — on Windows + * a read-only `\\.\CON` fd may open successfully but fail `SetConsoleMode`, + * which would otherwise surface as a throw inside `mountApp` rather + * than here. A failed probe destroys the stream and returns `null` so + * the caller falls back gracefully. */ function openFreshTtyForInk(): ReadStream | null { + const ttyPath = process.platform === "win32" ? "\\\\.\\CON" : "/dev/tty"; try { - const fd = openSync("/dev/tty", "r"); - return new ReadStream(fd); + const fd = openSync(ttyPath, "r"); + const stream = new ReadStream(fd); + stream.setRawMode(true); + stream.setRawMode(false); + return stream; } catch { return null; } diff --git a/src/lib/init/wizard-runner.ts b/src/lib/init/wizard-runner.ts index f2c812ae1..58e53fb42 100644 --- a/src/lib/init/wizard-runner.ts +++ b/src/lib/init/wizard-runner.ts @@ -401,10 +401,12 @@ async function preamble( return false; } if (err instanceof LoggingUIPromptError) { - throw new WizardError( - "The interactive UI failed to load. Run with --yes for non-interactive mode.", - { rendered: false } - ); + // isTTY=true means the user expected interactive mode but the TUI + // failed internally — point them to --no-tui rather than just --yes. + const hint = process.stdin.isTTY + ? "The interactive UI failed to load. Try running with --no-tui --yes." + : "The interactive UI failed to load. Run with --yes for non-interactive mode."; + throw new WizardError(hint, { rendered: false }); } throw err; } From 9404632809713a2b4c43e5403387d0d751dde246 Mon Sep 17 00:00:00 2001 From: betegon Date: Tue, 19 May 2026 12:45:23 +0200 Subject: [PATCH 2/4] fix(init): destroy ReadStream on setRawMode probe failure to prevent fd leak If openSync and new ReadStream both succeed but setRawMode(true) throws (e.g. a \.\CON handle that passes open but fails SetConsoleMode), the catch block was returning null without closing the stream, leaking the underlying file descriptor. Hoist stream outside the try block so the catch can call stream?.destroy() before returning null. The optional chain is a no-op when the error occurred in openSync or the ReadStream constructor. Reported by sentry-warden on PR #978. --- src/lib/init/ui/ink-ui.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/lib/init/ui/ink-ui.ts b/src/lib/init/ui/ink-ui.ts index caf53fa35..7e173c858 100644 --- a/src/lib/init/ui/ink-ui.ts +++ b/src/lib/init/ui/ink-ui.ts @@ -215,13 +215,15 @@ import inkAppPath from "./ink-app.tsx" with { type: "file" }; */ function openFreshTtyForInk(): ReadStream | null { const ttyPath = process.platform === "win32" ? "\\\\.\\CON" : "/dev/tty"; + let stream: ReadStream | undefined; try { const fd = openSync(ttyPath, "r"); - const stream = new ReadStream(fd); + stream = new ReadStream(fd); stream.setRawMode(true); stream.setRawMode(false); return stream; } catch { + stream?.destroy(); return null; } } From e8593e0cface6f193ed38b1d951d3528230690b3 Mon Sep 17 00:00:00 2001 From: betegon Date: Tue, 19 May 2026 13:12:16 +0200 Subject: [PATCH 3/4] test(init): update LoggingUIPromptError message assertion for isTTY path The error message now branches on process.stdin.isTTY: interactive TTY gets '--no-tui --yes' guidance; non-TTY gets '--yes'. Update the test name and expected string to match the isTTY=true branch that forceStdinTty exercises. The non-TTY branch is unreachable via LoggingUIPromptError (preamble throws earlier with a different message). --- test/lib/init/wizard-runner.test.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/test/lib/init/wizard-runner.test.ts b/test/lib/init/wizard-runner.test.ts index 36a91066c..06386771f 100644 --- a/test/lib/init/wizard-runner.test.ts +++ b/test/lib/init/wizard-runner.test.ts @@ -786,7 +786,7 @@ describe("runWizard", () => { expect(spinnerMock.stop).toHaveBeenCalledWith("Using existing project"); }); - test("shows --yes hint when LoggingUI prompt fails", async () => { + test("shows --no-tui --yes hint when LoggingUI prompt fails on an interactive TTY", async () => { const { LoggingUIPromptError } = await import( "../../../src/lib/init/ui/logging-ui.js" ); @@ -806,8 +806,9 @@ describe("runWizard", () => { await expect( forceStdinTty(() => runWizard(makeOptions({ yes: false }))) - ).rejects.toThrow("Run with --yes for non-interactive mode."); + ).rejects.toThrow("Try running with --no-tui --yes."); }); + }); describe("runWizard — MastraClient lifecycle", () => { From b6270ac5f07fa61f1c069f6445bb5d7b56478244 Mon Sep 17 00:00:00 2001 From: betegon Date: Tue, 19 May 2026 14:29:18 +0200 Subject: [PATCH 4/4] test(init): add coverage for InkUI catch fallback and LoggingUIPromptError hint MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit factory.mocked.test.ts: 4 tests covering the getUIAsync() catch path — fallback to LoggingUI when createInkUI throws an Error, rejects with a non-Error, or when the dynamic import itself fails. Also verifies the fallback is stateless across consecutive calls. mock.module() isolates the ink-ui sidecar so the Ink renderer is never spun up in tests. wizard-runner.test.ts: update test name and expected message to match the isTTY=true branch that forceStdinTty() exercises (--no-tui --yes guidance instead of the old --yes-only hint). Remove the trailing blank line left by the deleted non-TTY test case. --- test/lib/init/ui/factory.mocked.test.ts | 108 ++++++++++++++++++++++++ 1 file changed, 108 insertions(+) create mode 100644 test/lib/init/ui/factory.mocked.test.ts diff --git a/test/lib/init/ui/factory.mocked.test.ts b/test/lib/init/ui/factory.mocked.test.ts new file mode 100644 index 000000000..b06072c28 --- /dev/null +++ b/test/lib/init/ui/factory.mocked.test.ts @@ -0,0 +1,108 @@ +/** + * Tests for getUIAsync() — InkUI creation failure path. + * + * Uses mock.module() to simulate createInkUI() failing so we can verify + * getUIAsync() falls back to LoggingUI on any throw. This covers the + * catch block added for CLI-1NT (Windows InkUI regression). + * + * addBreadcrumb() is called in the catch block but cannot be spied on here: + * @sentry/node-core exports via CJS, so the binding in factory.ts is + * captured at module load time and is not reachable through mock.module(). + * Coverage of that line is provided by the tests below (the catch block + * executes; the breadcrumb call runs against the real SDK). + * + * Kept separate from factory.test.ts: mock.module() state is file-scoped, + * bun test --isolate gives each file a fresh module graph. + */ + +import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test"; + +// ── Mock setup — must precede all imports of the modules under test ──────── +// Bun processes mock.module() before resolving static imports in this file, +// so factory.ts picks up the mocked ink-ui.js at load time. + +/** Swappable per-test — lets us exercise different failure modes. */ +let createInkUIImpl: () => Promise = async () => { + throw new Error("mountApp failed: SetConsoleMode error"); +}; + +mock.module("../../../../src/lib/init/ui/ink-ui.js", () => ({ + createInkUI: () => createInkUIImpl(), +})); + +// ── Imports after mock setup ─────────────────────────────────────────────── + +import { getUIAsync } from "../../../../src/lib/init/ui/factory.js"; +import { LoggingUI } from "../../../../src/lib/init/ui/logging-ui.js"; + +// ── TTY helpers — mirrors factory.test.ts ───────────────────────────────── + +type TerminalSnapshot = { + stdinTTY: boolean | undefined; + stdoutTTY: boolean | undefined; +}; + +function snapshot(): TerminalSnapshot { + return { stdinTTY: process.stdin.isTTY, stdoutTTY: process.stdout.isTTY }; +} + +function restore(snap: TerminalSnapshot): void { + (process.stdin as { isTTY: boolean | undefined }).isTTY = snap.stdinTTY; + (process.stdout as { isTTY: boolean | undefined }).isTTY = snap.stdoutTTY; +} + +let saved: TerminalSnapshot; + +beforeEach(() => { + saved = snapshot(); + // Both TTYs must be true so shouldUseLogging() returns false and + // getUIAsync() reaches the createInkUI() call. + (process.stdin as { isTTY: boolean }).isTTY = true; + (process.stdout as { isTTY: boolean }).isTTY = true; + // Reset to the default Error-throwing impl. + createInkUIImpl = async () => { + throw new Error("mountApp failed: SetConsoleMode error"); + }; +}); + +afterEach(() => { + restore(saved); +}); + +// ── Tests ────────────────────────────────────────────────────────────────── + +describe("getUIAsync — InkUI creation failure", () => { + test("falls back to LoggingUI when createInkUI throws an Error", async () => { + const ui = await getUIAsync({ yes: false }); + expect(ui).toBeInstanceOf(LoggingUI); + }); + + test("falls back to LoggingUI when createInkUI rejects with a non-Error", async () => { + // Exercises the `err instanceof Error ? err.stack : undefined` branch + // in the breadcrumb — err.stack is undefined for non-Error rejects. + createInkUIImpl = async () => { + // biome-ignore lint/style/useThrowOnlyError: deliberately testing non-Error rejection + throw "WASM init failed"; + }; + const ui = await getUIAsync({ yes: false }); + expect(ui).toBeInstanceOf(LoggingUI); + }); + + test("falls back to LoggingUI when the ink-ui import itself throws", async () => { + // Simulates a corrupted sidecar or missing $bunfs embed — the dynamic + // import throws before createInkUI is ever called. + createInkUIImpl = mock(() => + Promise.reject(new Error("Cannot find module")) + ); + const ui = await getUIAsync({ yes: false }); + expect(ui).toBeInstanceOf(LoggingUI); + }); + + test("fallback is stateless — consecutive failures each return a fresh LoggingUI", async () => { + const first = await getUIAsync({ yes: false }); + const second = await getUIAsync({ yes: false }); + expect(first).toBeInstanceOf(LoggingUI); + expect(second).toBeInstanceOf(LoggingUI); + expect(first).not.toBe(second); + }); +});