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) +})