Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 14 additions & 1 deletion src/lib/init/ui/factory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -102,10 +103,22 @@ export async function getUIAsync(opts: UIFactoryOptions): Promise<WizardUI> {
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();
}
}
37 changes: 30 additions & 7 deletions src/lib/init/ui/ink-ui.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Comment thread
betegon marked this conversation as resolved.
}
Expand Down
10 changes: 6 additions & 4 deletions src/lib/init/wizard-runner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down
108 changes: 108 additions & 0 deletions test/lib/init/ui/factory.mocked.test.ts
Original file line number Diff line number Diff line change
@@ -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<never> = 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);
});
});
5 changes: 3 additions & 2 deletions test/lib/init/wizard-runner.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"
);
Expand All @@ -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", () => {
Expand Down
Loading