From 8aef64bbb534e470ae72a5decf7ad6003fc59a02 Mon Sep 17 00:00:00 2001 From: "Galib Sarayev (AI)" Date: Mon, 29 Jun 2026 12:39:45 +0000 Subject: [PATCH] fix(cli-internal): guard confirmPrompt against node 24 readline crash Node 24 changed readline.Interface#pause() to throw ERR_USE_AFTER_CLOSE when the interface is already closed; Node 22 and earlier treated the call as a no-op. inquirer@7's baseUI.close() calls rl.pause() unconditionally during prompt teardown, so when stdin closes (EOF / piped input) as a confirm prompt finishes, the throw rejects the prompt promise and bubbles up. This is what makes `amplify add function` die with the generic "There was an error adding the function resource" on Node 24: its openEditor() step asks a confirm question through context.amplify.confirmPrompt(). Guard the legacy confirmPrompt helper (the implementation behind context.amplify.confirmPrompt) so an ERR_USE_AFTER_CLOSE thrown during teardown is caught. The answer is captured on the prompt UI before teardown runs, so we recover it from the prompt promise's ui.answers and return it, restoring the pre-Node-24 behavior where the teardown was a silent no-op. If the prompt closed before an answer was recorded, we fall back to the supplied default. confirmPrompt has ~28 callers. Re-implementing it on top of amplify-prompts' enquirer-based prompter would change --yes and non-interactive behavior for all of them (callers such as s0-analyzeProject already gate --yes themselves), so the narrow guard is the lower-risk fix and covers every caller, including openEditor, at once. Testing: added regression tests that simulate inquirer's teardown crash (answer captured on ui.answers, then the promise rejects with ERR_USE_AFTER_CLOSE) and assert confirmPrompt returns the real answer instead of throwing; also verified against the real inquirer@7 that the answer is present on the prompt promise's ui.answers after the crash. Built with `tsc -b` and ran the amplify-helpers jest suite (40 suites / 155 tests pass); lint clean. A separate amplify-category-api change patches the e2e harness's installed inquirer as a stopgap; that patch can be retired once this product fix ships in a cli-internal release. --- Prompt: Root-fix the Node 24 `amplify add function` crash in the PUBLISHED Gen1 CLI (repo: amplify-cli; PRs target the `dev` branch). ROOT CAUSE: Node 24 made readline.Interface THROW ERR_USE_AFTER_CLOSE when pause() is called after the readline is closed (Node <=22 silently no-op'd). The legacy helper confirm-prompt.ts uses inquirer@7.3.3; inquirer's baseUI.js close() calls this.rl.pause() unconditionally. When stdin closes (EOF / piped input) right as a confirm prompt finishes, Node 24 throws and `amplify add function` dies with the generic "There was an error adding the function resource". The crash path is amplify-category-function openEditor() -> context.amplify.confirmPrompt(...). confirmPrompt is @deprecated in favor of confirmContinue from amplify-prompts (enquirer-based), which does not have this bug. Produce the durable fix in amplify-cli: prefer making confirmPrompt Node-24-safe at the source so every caller benefits, either by re-implementing on amplify-prompts' prompter while preserving the exact public contract, or, if that risks changing behavior for the many callers, by guarding the inquirer path against the closed-readline crash (the minimal change that restores pre-Node-24 behavior for all callers). Also ensure openEditor() no longer crashes. Add a regression test that reproduces the closed-readline / EOF-after-confirm scenario and asserts it no longer throws. Build + test the touched package(s). Follow the repo commit rules and open a draft PR targeting `dev`, noting that amplify-category-api #3503's harness patch is a stopgap that can be retired once this ships in a cli-internal release. --- .../amplify-helpers/confirm-prompt.test.ts | 102 +++++++++++++++--- .../amplify-helpers/confirm-prompt.ts | 37 ++++++- 2 files changed, 122 insertions(+), 17 deletions(-) diff --git a/packages/amplify-cli/src/__tests__/extensions/amplify-helpers/confirm-prompt.test.ts b/packages/amplify-cli/src/__tests__/extensions/amplify-helpers/confirm-prompt.test.ts index 94bc3fcd1fd..c544a2bb325 100644 --- a/packages/amplify-cli/src/__tests__/extensions/amplify-helpers/confirm-prompt.test.ts +++ b/packages/amplify-cli/src/__tests__/extensions/amplify-helpers/confirm-prompt.test.ts @@ -1,21 +1,95 @@ +import * as inquirer from 'inquirer'; import { confirmPrompt } from '../../../extensions/amplify-helpers/confirm-prompt'; -jest.mock('inquirer', () => { - return { - prompt: (input) => { - return new Promise((resolve) => resolve({ yesno: input })); - }, - }; -}); +jest.mock('inquirer', () => ({ + prompt: jest.fn(), +})); + +const promptMock = inquirer.prompt as unknown as jest.Mock; + +// Shape of the value inquirer.prompt() returns: a promise with the prompt UI +// (which carries the captured answers) monkey-patched onto it. +type PromptUiShape = { answers: Record }; +const attachUi = (promise: Promise, ui: PromptUiShape): Promise => { + (promise as Promise & { ui: PromptUiShape }).ui = ui; + return promise; +}; + +// Mirrors the error Node >=24 throws from readline.Interface#pause() once the +// interface is closed (Node <=22 made the call a no-op). +const nodeReadlineAfterCloseError = (): Error => { + const err = new Error('readline was closed') as NodeJS.ErrnoException; + err.code = 'ERR_USE_AFTER_CLOSE'; + return err; +}; describe('confirmPrompt', () => { - it('returns an object', async () => { - const result = await confirmPrompt('test', true); - expect(result).toStrictEqual({ - name: 'yesno', - message: 'test', - type: 'confirm', - default: true, + afterEach(() => { + jest.resetAllMocks(); + }); + + it('asks inquirer a confirm question and returns the answer', async () => { + promptMock.mockReturnValue(attachUi(Promise.resolve({ yesno: false }), { answers: { yesno: false } })); + + await expect(confirmPrompt('Proceed?', true)).resolves.toBe(false); + expect(promptMock).toHaveBeenCalledWith({ name: 'yesno', message: 'Proceed?', type: 'confirm', default: true }); + }); + + it('recovers a "yes" answer when inquirer teardown throws ERR_USE_AFTER_CLOSE on Node >=24', async () => { + // inquirer captures the answer on ui.answers, then baseUI.close() -> rl.pause() + // throws because stdin has already closed, rejecting the prompt promise. + const ui: PromptUiShape = { answers: {} }; + const rejectsAfterCapturing = Promise.resolve().then(() => { + ui.answers.yesno = true; + throw nodeReadlineAfterCloseError(); }); + promptMock.mockReturnValue(attachUi(rejectsAfterCapturing, ui)); + + await expect(confirmPrompt('Edit now?', false)).resolves.toBe(true); + }); + + it('recovers a "no" answer when inquirer teardown throws ERR_USE_AFTER_CLOSE on Node >=24', async () => { + const ui: PromptUiShape = { answers: {} }; + const rejectsAfterCapturing = Promise.resolve().then(() => { + ui.answers.yesno = false; + throw nodeReadlineAfterCloseError(); + }); + promptMock.mockReturnValue(attachUi(rejectsAfterCapturing, ui)); + + await expect(confirmPrompt('Edit now?', true)).resolves.toBe(false); + }); + + it('falls back to the default answer when the crash happens before any answer is captured', async () => { + const uiTrueDefault: PromptUiShape = { answers: {} }; + promptMock.mockReturnValueOnce( + attachUi( + Promise.resolve().then(() => { + throw nodeReadlineAfterCloseError(); + }), + uiTrueDefault, + ), + ); + await expect(confirmPrompt('Edit now?', true)).resolves.toBe(true); + + const uiFalseDefault: PromptUiShape = { answers: {} }; + promptMock.mockReturnValueOnce( + attachUi( + Promise.resolve().then(() => { + throw nodeReadlineAfterCloseError(); + }), + uiFalseDefault, + ), + ); + await expect(confirmPrompt('Edit now?', false)).resolves.toBe(false); + }); + + it('rethrows errors that are not the Node readline-after-close crash', async () => { + const ui: PromptUiShape = { answers: {} }; + const rejectsWithUnrelatedError = Promise.resolve().then(() => { + throw new Error('something else went wrong'); + }); + promptMock.mockReturnValue(attachUi(rejectsWithUnrelatedError, ui)); + + await expect(confirmPrompt('Proceed?')).rejects.toThrow('something else went wrong'); }); }); diff --git a/packages/amplify-cli/src/extensions/amplify-helpers/confirm-prompt.ts b/packages/amplify-cli/src/extensions/amplify-helpers/confirm-prompt.ts index f65b2c292ce..6facde4eced 100644 --- a/packages/amplify-cli/src/extensions/amplify-helpers/confirm-prompt.ts +++ b/packages/amplify-cli/src/extensions/amplify-helpers/confirm-prompt.ts @@ -1,14 +1,45 @@ import * as inquirer from 'inquirer'; +// Node v24 made readline.Interface#pause() throw with this code when the +// interface is already closed; Node <=22 treated the call as a no-op. +const READLINE_USE_AFTER_CLOSE = 'ERR_USE_AFTER_CLOSE'; + /** + * Prompts the user with a yes/no question and resolves to their boolean answer. + * + * On Node >=24 inquirer@7's prompt teardown crashes the CLI: `baseUI.close()` + * calls `readline.Interface#pause()` unconditionally, and Node >=24 throws + * `ERR_USE_AFTER_CLOSE` from that call once stdin has closed (for example EOF on + * piped input) as the prompt finishes. The answer is captured on the prompt UI + * before teardown runs, so this helper recovers it and returns it, restoring the + * pre-Node-24 behavior where the teardown was a silent no-op for every caller. + * + * @param message - the question to display to the user + * @param defaultValue - the answer used when the user accepts the default + * @returns the user's boolean response * @deprecated Use confirmContinue from amplify-prompts instead */ -export async function confirmPrompt(message: string, defaultValue = true) { - const ans = await inquirer.prompt({ +export async function confirmPrompt(message: string, defaultValue = true): Promise { + const promptPromise = inquirer.prompt({ name: 'yesno', message, type: 'confirm', default: defaultValue, }); - return ans.yesno; + try { + const ans = await promptPromise; + return ans.yesno; + } catch (err) { + if (!isReadlineUseAfterCloseError(err)) { + throw err; + } + // The crash happens during teardown, after the answer was captured on the + // prompt UI. Recover it so the caller sees the user's real choice; fall back + // to the default if the prompt closed before an answer was recorded. + const capturedAnswer = promptPromise.ui?.answers?.yesno; + return typeof capturedAnswer === 'boolean' ? capturedAnswer : defaultValue; + } } + +const isReadlineUseAfterCloseError = (err: unknown): boolean => + typeof err === 'object' && err !== null && (err as { code?: unknown }).code === READLINE_USE_AFTER_CLOSE;