From ad802edc393641597e4c07f2ea737c2964b3622d Mon Sep 17 00:00:00 2001 From: itelo Date: Thu, 3 Sep 2026 14:18:14 -0300 Subject: [PATCH] fix(wizard): refuse to run without a terminal instead of crashing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Piping the wizard — a CI job, `seam wizard < /dev/null`, any non-interactive shell — printed a React stack trace out of Ink's internals: ERROR Raw mode is not supported on the current process.stdin, which Ink uses as input stream by default. at commitHookPassiveMountEffects (react-reconciler.development.js) … The welcome splash already handles a missing TTY (it skips straight to init), but `ink-select-input` calls `useInput` unconditionally, and Ink's `setRawMode` throws when stdin is not a TTY. So the run died on the first prompt it rendered, mid-render, after having done real work: it had already connected the workspace and started installing the SDK. Nothing can be done about that at the prompt, because every run reaches one — the connect method, the SDK, the offer to write the integration — so there is no headless path through the wizard to preserve. Check for a TTY at the entrypoint instead and explain what is wrong: Seam Wizard needs an interactive terminal. It asks which account to connect, which SDK to install, and whether to write the integration, so it cannot run with stdin redirected — in a pipe, a CI job, or a non-interactive shell. Run 'seam wizard' in your terminal instead. To stderr, with a non-zero exit code, and no render at all. The check sits after `--help` and `--version`, which are the documented ways to run this without a terminal and keep working. Only stdin is required: a run with stdout redirected still works, since render.ts already skips the alternate screen there and leaves its frames in the log. Co-Authored-By: Claude Opus 5 (1M context) --- README.md | 6 +++++ src/lib/wizard.ts | 33 ++++++++++++++++++++++++- test/wizard.test.ts | 60 ++++++++++++++++++++++++++++++++++++++++++++- 3 files changed, 97 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index c7563e2..4cd6f3b 100644 --- a/README.md +++ b/README.md @@ -84,6 +84,12 @@ the run. It resolves when the wizard is done, and only rejects if the wizard could not run at all: a step that fails reports itself to the developer and does not reject. +Every run reaches a prompt, so the wizard needs an interactive terminal. +Where stdin is not a TTY — a pipe, a CI job, a non-interactive shell — it +writes a notice to stderr, sets a non-zero exit code, and resolves +without rendering anything. +`--help` and `--version` still work there. + Pass `cwd` to set the project root the wizard sets up. It defaults to `process.cwd()`, which is the project the command was run in. diff --git a/src/lib/wizard.ts b/src/lib/wizard.ts index b342c88..dc301a2 100644 --- a/src/lib/wizard.ts +++ b/src/lib/wizard.ts @@ -65,6 +65,16 @@ const wizard = async (options: WizardOptions = {}): Promise => { return } + // Every run reaches a prompt — the connect method, the SDK, the offer to + // write the integration — and a prompt needs stdin in raw mode, which only a + // TTY allows. Without one the first prompt throws from inside Ink's render + // and the developer gets a React stack trace, so say what is wrong instead. + if (!isInteractive()) { + writeError(nonInteractiveNotice(commandName)) + process.exitCode = 1 + return + } + // Dev-only: preview a screen's layout without the auth/agent flow. Passing // `--debug-screen` with no name opens a chooser of every available screen. const debugScreen = args['debug-screen'] @@ -117,7 +127,28 @@ const usage = (commandName: string): string => '', ].join('\n') -// TODO: Replace this with a logger wrapper. +const nonInteractiveNotice = (commandName: string): string => + [ + 'Seam Wizard needs an interactive terminal.', + '', + ' It asks which account to connect, which SDK to install, and whether to', + ' write the integration, so it cannot run with stdin redirected — in a', + ' pipe, a CI job, or a non-interactive shell.', + '', + ` Run '${commandName}' in your terminal instead.`, + '', + ].join('\n') + +// Ink reads stdin, so this is the condition Ink itself checks before allowing +// raw mode. Where stdout is redirected the wizard still runs: it skips the +// alternate screen and leaves its frames in the log. +const isInteractive = (): boolean => process.stdin.isTTY === true + +// TODO: Replace these with a logger wrapper. const write = (message: string): void => { process.stdout.write(`${message}\n`) } + +const writeError = (message: string): void => { + process.stderr.write(`${message}\n`) +} diff --git a/test/wizard.test.ts b/test/wizard.test.ts index 696a697..d4e75b5 100644 --- a/test/wizard.test.ts +++ b/test/wizard.test.ts @@ -7,11 +7,40 @@ import wizard from 'lib/wizard.js' vi.mock('lib/render.js', () => ({ renderApp: vi.fn() })) +// The wizard refuses to run without a TTY, so a test that expects it to render +// has to look like a terminal. Tests for the refusal set this to undefined. +const setInteractive = (isTty: boolean | undefined): void => { + Object.defineProperty(process.stdin, 'isTTY', { + value: isTty, + configurable: true, + }) +} + +const initialIsTty = process.stdin.isTTY + beforeEach(() => { vi.mocked(renderApp).mockClear() + setInteractive(true) }) -afterEach(resetAdapter) +afterEach(() => { + setInteractive(initialIsTty) + // Set by the refusal path; left alone it would fail the whole test run. + process.exitCode = 0 + resetAdapter() +}) + +const captureError = async ( + options: Parameters[0], +): Promise => { + const write = vi.spyOn(process.stderr, 'write').mockImplementation(() => true) + try { + await wizard(options) + return write.mock.calls.map(([chunk]) => String(chunk)).join('') + } finally { + write.mockRestore() + } +} const captureOutput = async ( options: Parameters[0], @@ -134,3 +163,32 @@ test('wizard: asks no adapter anything to display usage', async () => { expect(getAuthSpy).not.toHaveBeenCalled() }) + +test('wizard: refuses to run without an interactive terminal', async () => { + setInteractive(undefined) + + const output = await captureError({ argv: [], commandName: 'seam wizard' }) + + expect(output).toContain('needs an interactive terminal') + expect(output).toContain("Run 'seam wizard' in your terminal instead.") + expect(renderApp).not.toHaveBeenCalled() + expect(process.exitCode).toBe(1) +}) + +test('wizard: still displays usage without an interactive terminal', async () => { + setInteractive(undefined) + + const output = await captureOutput({ argv: ['--help'] }) + + expect(output).toContain('$ wizard [options]') + expect(process.exitCode).not.toBe(1) +}) + +test('wizard: still displays the version without an interactive terminal', async () => { + setInteractive(undefined) + + const output = await captureOutput({ argv: ['--version'] }) + + expect(output.trim()).toBe(seamapiWizardVersion) + expect(process.exitCode).not.toBe(1) +})