diff --git a/create-bestax/src/__tests__/project-creator.test.ts b/create-bestax/src/__tests__/project-creator.test.ts index 4cda89928..6c7dcd33f 100644 --- a/create-bestax/src/__tests__/project-creator.test.ts +++ b/create-bestax/src/__tests__/project-creator.test.ts @@ -55,16 +55,40 @@ const { ICON_LIBRARIES: _ICON_LIBRARIES, BULMA_FLAVORS: _BULMA_FLAVORS } = // Mock console methods const originalConsole = { ...console }; +const originalIsTTYDescriptor = Object.getOwnPropertyDescriptor( + process.stdin, + 'isTTY' +); + +function setStdinTTY(value: boolean | undefined): void { + Object.defineProperty(process.stdin, 'isTTY', { + value, + configurable: true, + writable: true, + }); +} + +function restoreStdinTTY(): void { + if (originalIsTTYDescriptor) { + Object.defineProperty(process.stdin, 'isTTY', originalIsTTYDescriptor); + } else { + delete (process.stdin as { isTTY?: boolean }).isTTY; + } +} beforeEach(() => { console.log = jest.fn(); console.error = jest.fn(); + // ProjectCreator falls through to interactive prompts, which are guarded + // against non-TTY stdin (#192); jest's stdin is not a TTY, so simulate one. + setStdinTTY(true); jest.clearAllMocks(); }); afterEach(() => { console.log = originalConsole.log; console.error = originalConsole.error; + restoreStdinTTY(); }); describe('ProjectCreator', () => { diff --git a/create-bestax/src/__tests__/prompts.test.ts b/create-bestax/src/__tests__/prompts.test.ts index e7d97bc7a..bcc145d7c 100644 --- a/create-bestax/src/__tests__/prompts.test.ts +++ b/create-bestax/src/__tests__/prompts.test.ts @@ -65,19 +65,81 @@ const { // Mock console methods const originalConsole = { ...console }; +const originalIsTTYDescriptor = Object.getOwnPropertyDescriptor( + process.stdin, + 'isTTY' +); + +function setStdinTTY(value: boolean | undefined): void { + Object.defineProperty(process.stdin, 'isTTY', { + value, + configurable: true, + writable: true, + }); +} + +function restoreStdinTTY(): void { + if (originalIsTTYDescriptor) { + Object.defineProperty(process.stdin, 'isTTY', originalIsTTYDescriptor); + } else { + delete (process.stdin as { isTTY?: boolean }).isTTY; + } +} beforeEach(() => { console.log = jest.fn(); console.error = jest.fn(); + // Prompts are only reachable on an interactive terminal (#192); jest's + // stdin is not a TTY, so simulate one for the interactive tests. + setStdinTTY(true); jest.clearAllMocks(); }); afterEach(() => { console.log = originalConsole.log; console.error = originalConsole.error; + restoreStdinTTY(); }); describe('prompts', () => { + describe('non-interactive guard (#192)', () => { + const promptFns: Array<[string, () => Promise]> = [ + ['promptProjectName', () => promptProjectName()], + ['promptOverwriteDirectory', () => promptOverwriteDirectory('project')], + ['promptInstallSkills', () => promptInstallSkills()], + ['promptTemplate', () => promptTemplate()], + ['promptIconLibrary', () => promptIconLibrary()], + ['promptBulmaFlavor', () => promptBulmaFlavor()], + ]; + + it.each(promptFns)( + '%s exits with code 1 and guidance when stdin is not a TTY', + async (_name, fn) => { + setStdinTTY(undefined); + const originalExit = process.exit; + const exitMock = jest.fn((code?: number | string | null) => { + throw new Error(`process.exit(${code})`); + }); + process.exit = exitMock as unknown as typeof process.exit; + + try { + await expect(fn()).rejects.toThrow('process.exit(1)'); + expect(exitMock).toHaveBeenCalledWith(1); + expect(console.error).toHaveBeenCalledWith( + expect.stringContaining('No interactive terminal detected') + ); + expect(console.error).toHaveBeenCalledWith( + expect.stringContaining('-t vite-ts -b complete -i none -y') + ); + // The underlying prompt must never start (it would hang forever). + expect(prompts).not.toHaveBeenCalled(); + } finally { + process.exit = originalExit; + } + } + ); + }); + describe('promptProjectName', () => { it('should prompt for project name', async () => { (prompts as jest.MockedFunction).mockResolvedValue({ diff --git a/create-bestax/src/constants.ts b/create-bestax/src/constants.ts index 8a479e3ac..aa12051cc 100644 --- a/create-bestax/src/constants.ts +++ b/create-bestax/src/constants.ts @@ -21,6 +21,11 @@ export const MESSAGES = { PROJECT_NAME_INVALID_CHARS: 'Project name can only contain letters, numbers, dots, dashes and underscores', OPERATION_CANCELLED: '✖ Operation cancelled', + NO_TTY: + 'No interactive terminal detected — cannot prompt for input.\n' + + 'Re-run non-interactively with a project name and flags, e.g.:\n' + + ' npm create bestax@latest my-app -- -t vite-ts -b complete -i none -y\n' + + 'Run with --help to see all options.', DIRECTORY_NOT_EMPTY: (dir: string) => `Directory ${chalk.yellow(dir)} is not empty. Remove existing files and continue?`, EMPTYING_DIRECTORY: (dir: string) => `\n Emptying ${dir}...`, diff --git a/create-bestax/src/prompts.ts b/create-bestax/src/prompts.ts index b40e81a6b..abcbd1b1d 100644 --- a/create-bestax/src/prompts.ts +++ b/create-bestax/src/prompts.ts @@ -8,8 +8,22 @@ import { BULMA_FLAVORS, } from './constants.js'; import { validateProjectName } from './validators.js'; +import { displayError } from './display.js'; + +/** + * Guard against hanging forever in non-interactive environments (CI, agents, + * piped stdin): every interactive question must fail fast with actionable + * guidance when stdin is not a TTY (#192). + */ +function ensureInteractive(): void { + if (!process.stdin.isTTY) { + displayError(MESSAGES.NO_TTY); + process.exit(1); + } +} export async function promptProjectName(): Promise { + ensureInteractive(); const response = await prompts({ type: 'text', name: 'projectName', @@ -24,6 +38,7 @@ export async function promptProjectName(): Promise { export async function promptOverwriteDirectory( targetDir: string ): Promise { + ensureInteractive(); const response = await prompts({ type: 'confirm', name: 'overwrite', @@ -35,6 +50,7 @@ export async function promptOverwriteDirectory( } export async function promptInstallSkills(): Promise { + ensureInteractive(); const response = await prompts({ type: 'confirm', name: 'skills', @@ -46,6 +62,7 @@ export async function promptInstallSkills(): Promise { } export async function promptTemplate(): Promise { + ensureInteractive(); const response = await prompts({ type: 'select', name: 'template', @@ -60,6 +77,7 @@ export async function promptTemplate(): Promise { } export async function promptIconLibrary(): Promise { + ensureInteractive(); const response = await prompts({ type: 'select', name: 'iconLibrary', @@ -75,6 +93,7 @@ export async function promptIconLibrary(): Promise { } export async function promptBulmaFlavor(): Promise { + ensureInteractive(); const response = await prompts({ type: 'select', name: 'bulmaFlavor',