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..7e173c858 100644 --- a/src/lib/init/ui/ink-ui.ts +++ b/src/lib/init/ui/ink-ui.ts @@ -190,17 +190,40 @@ 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"; + let stream: ReadStream | undefined; try { - const fd = openSync("/dev/tty", "r"); - return new ReadStream(fd); + const fd = openSync(ttyPath, "r"); + stream = new ReadStream(fd); + stream.setRawMode(true); + stream.setRawMode(false); + return stream; } catch { + stream?.destroy(); 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; } 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); + }); +}); 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", () => {