From 8b8426eef5b1fd5a560d49b1e027c3f3a3697a22 Mon Sep 17 00:00:00 2001 From: Edgars Date: Fri, 10 Jul 2026 11:31:57 +0100 Subject: [PATCH 1/2] fix(account): clear error for password prompts with no TTY MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Running e.g. `genlayer account export --password ` without --source-password in a non-interactive context (piped stdin, CI, automation) died with `ExitPromptError: User force closed the prompt` — inquirer's message for a force-closed prompt, which reads exactly like the user hit Ctrl-C when in fact a required flag is missing. Guard promptPassword with assertInteractive(): when stdin is not a TTY, throw an actionable error naming the flag(s) that make the command non-interactive (--source-password / --password) instead of letting inquirer force-close. export and import pass contextual hints. confirmPrompt is intentionally left alone (its callers gate on their own --yes/--overwrite flags). Adds non-TTY coverage in baseAction.test.ts; existing prompt tests simulate a TTY (vitest stdin is not one). --- src/commands/account/export.ts | 12 ++++++++++-- src/commands/account/import.ts | 7 ++++++- src/lib/actions/BaseAction.ts | 16 +++++++++++++++- tests/libs/baseAction.test.ts | 25 +++++++++++++++++++++++++ 4 files changed, 56 insertions(+), 4 deletions(-) diff --git a/src/commands/account/export.ts b/src/commands/account/export.ts index bed07a48..38b59869 100644 --- a/src/commands/account/export.ts +++ b/src/commands/account/export.ts @@ -43,7 +43,10 @@ export class ExportAccountAction extends BaseAction { if (options.password) { password = options.password; } else { - password = await this.promptPassword("Enter password for exported keystore (minimum 8 characters):"); + password = await this.promptPassword( + "Enter password for exported keystore (minimum 8 characters):", + "Pass --password to set the exported keystore password non-interactively.", + ); const confirmPassword = await this.promptPassword("Confirm password:"); if (password !== confirmPassword) { this.failSpinner("Passwords do not match"); @@ -89,7 +92,12 @@ export class ExportAccountAction extends BaseAction { const encryptedJson = parsed.encrypted || fileContent; - const password = sourcePassword || await this.promptPassword(`Enter password to unlock '${accountName}':`); + const password = + sourcePassword || + (await this.promptPassword( + `Enter password to unlock '${accountName}':`, + "Pass --source-password to unlock the account non-interactively.", + )); this.startSpinner("Decrypting keystore..."); diff --git a/src/commands/account/import.ts b/src/commands/account/import.ts index a7ec0518..357751fa 100644 --- a/src/commands/account/import.ts +++ b/src/commands/account/import.ts @@ -102,7 +102,12 @@ export class ImportAccountAction extends BaseAction { this.failSpinner("Invalid keystore file. Could not parse JSON."); } - const password = sourcePassword || await this.promptPassword("Enter password to decrypt keystore:"); + const password = + sourcePassword || + (await this.promptPassword( + "Enter password to decrypt keystore:", + "Pass --source-password to decrypt the source keystore non-interactively.", + )); this.startSpinner("Decrypting keystore..."); diff --git a/src/lib/actions/BaseAction.ts b/src/lib/actions/BaseAction.ts index 5361659a..e5c2ab9b 100644 --- a/src/lib/actions/BaseAction.ts +++ b/src/lib/actions/BaseAction.ts @@ -449,7 +449,21 @@ export class BaseAction extends ConfigFileManager { return wallet.privateKey; } - protected async promptPassword(message: string): Promise { + /** + * Fail with an actionable message instead of letting inquirer throw a cryptic + * `ExitPromptError: User force closed the prompt` when there is no interactive + * terminal (piped stdin, CI, automation). Without this, a missing flag reads + * like the user hit Ctrl-C. `hint` names the flag(s) that make the command + * non-interactive so the fix is obvious. + */ + protected assertInteractive(hint?: string): void { + if (process.stdin.isTTY) return; + const guidance = hint ?? "Provide the required value via the corresponding command flag to run non-interactively."; + throw new Error(`No interactive terminal available for a prompt. ${guidance}`); + } + + protected async promptPassword(message: string, nonInteractiveHint?: string): Promise { + this.assertInteractive(nonInteractiveHint); const answer = await inquirer.prompt([ { type: "password", diff --git a/tests/libs/baseAction.test.ts b/tests/libs/baseAction.test.ts index 91ad3f9f..48c31676 100644 --- a/tests/libs/baseAction.test.ts +++ b/tests/libs/baseAction.test.ts @@ -27,6 +27,7 @@ describe("BaseAction", () => { let consoleSpy: any; let consoleErrorSpy: any; let processExitSpy: any; + const originalIsTTY = process.stdin.isTTY; // Standard web3 keystore format const mockKeystoreData = { @@ -50,6 +51,10 @@ describe("BaseAction", () => { beforeEach(() => { vi.clearAllMocks(); + // Prompts require an interactive terminal; simulate one so the + // interactive-path assertions exercise inquirer (vitest's stdin is not a + // TTY, which would otherwise trip the non-interactive guard). + (process.stdin as {isTTY?: boolean}).isTTY = true; consoleSpy = vi.spyOn(console, "log").mockImplementation(() => {}); consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); processExitSpy = vi.spyOn(process, "exit").mockImplementation(() => { @@ -104,6 +109,7 @@ describe("BaseAction", () => { afterEach(() => { vi.restoreAllMocks(); + (process.stdin as {isTTY?: boolean}).isTTY = originalIsTTY; }); test("should start the spinner with a message", () => { @@ -255,6 +261,25 @@ describe("BaseAction", () => { }]); }); + test("should throw an actionable error (not prompt) when no interactive terminal is available", async () => { + (process.stdin as {isTTY?: boolean}).isTTY = false; + + await expect( + baseAction["promptPassword"]("Enter password:", "Pass --source-password to run non-interactively."), + ).rejects.toThrow(/No interactive terminal available.*--source-password/s); + // Must fail fast, before inquirer force-closes with a misleading ExitPromptError. + expect(inquirer.prompt).not.toHaveBeenCalled(); + }); + + test("should surface the generic non-interactive hint when no flag hint is given", async () => { + (process.stdin as {isTTY?: boolean}).isTTY = false; + + await expect(baseAction["promptPassword"]("Enter password:")).rejects.toThrow( + /No interactive terminal available.*corresponding command flag/s, + ); + expect(inquirer.prompt).not.toHaveBeenCalled(); + }); + test("should validate password input is not empty", async () => { vi.mocked(inquirer.prompt).mockResolvedValue({password: "valid-password"}); From 6c2a1ab281521908e8b0ff2dcdbd6fc2d669fed4 Mon Sep 17 00:00:00 2001 From: Edgars Date: Fri, 10 Jul 2026 13:10:06 +0100 Subject: [PATCH 2/2] fix(account): catch inquirer force-close instead of guarding on isTTY MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The first cut guarded promptPassword with !process.stdin.isTTY, which was too blunt: it rejected PIPED stdin outright. But piped stdin is exactly how the e2e harness (and real automation) supply the keystore password — so `genlayer deploy` and every keystore-unlock path started failing with 'Maximum password attempts exceeded' in CI (isTTY is false for a pipe, so the guard threw before inquirer could read the piped password). Instead, let inquirer run and catch its ExitPromptError ('User force closed the prompt') — which only fires on an ACTUAL force-close (no TTY AND no piped input). Rewrite that into the actionable, flag-naming message. Piped stdin now reaches inquirer normally; the clear error still appears when there is genuinely no input. Verified against a keystore: piped source-password exports OK; empty stdin yields 'No interactive terminal available for a password prompt. Pass --source-password ...'. 774/774 unit tests. --- src/lib/actions/BaseAction.ts | 60 +++++++++++++++++++++-------------- tests/libs/baseAction.test.ts | 33 +++++++++++-------- 2 files changed, 56 insertions(+), 37 deletions(-) diff --git a/src/lib/actions/BaseAction.ts b/src/lib/actions/BaseAction.ts index e5c2ab9b..0e7ef5fe 100644 --- a/src/lib/actions/BaseAction.ts +++ b/src/lib/actions/BaseAction.ts @@ -450,35 +450,47 @@ export class BaseAction extends ConfigFileManager { } /** - * Fail with an actionable message instead of letting inquirer throw a cryptic - * `ExitPromptError: User force closed the prompt` when there is no interactive - * terminal (piped stdin, CI, automation). Without this, a missing flag reads - * like the user hit Ctrl-C. `hint` names the flag(s) that make the command - * non-interactive so the fix is obvious. + * inquirer throws an `ExitPromptError` ("User force closed the prompt") when a + * prompt can't be satisfied — no TTY and no piped stdin. That message reads + * exactly like the user hit Ctrl-C when in fact a required flag is missing. + * Rewrite it into an actionable error naming the flag(s) that make the command + * non-interactive. Crucially this only fires on an ACTUAL force-close: piped + * stdin (how the e2e harness and automation supply the password) still feeds + * inquirer normally, so we must NOT pre-empt with an `isTTY` guard. */ - protected assertInteractive(hint?: string): void { - if (process.stdin.isTTY) return; - const guidance = hint ?? "Provide the required value via the corresponding command flag to run non-interactively."; - throw new Error(`No interactive terminal available for a prompt. ${guidance}`); + private isExitPromptError(error: unknown): boolean { + if (!error) return false; + const name = (error as {name?: string}).name; + const message = error instanceof Error ? error.message : String(error); + return name === "ExitPromptError" || /force closed the prompt/i.test(message); } protected async promptPassword(message: string, nonInteractiveHint?: string): Promise { - this.assertInteractive(nonInteractiveHint); - const answer = await inquirer.prompt([ - { - type: "password", - name: "password", - message: chalk.yellow(message), - mask: "*", - validate: (input: string) => { - if (!input) { - return "Password cannot be empty"; - } - return true; + try { + const answer = await inquirer.prompt([ + { + type: "password", + name: "password", + message: chalk.yellow(message), + mask: "*", + validate: (input: string) => { + if (!input) { + return "Password cannot be empty"; + } + return true; + }, }, - }, - ]); - return answer.password; + ]); + return answer.password; + } catch (error) { + if (this.isExitPromptError(error)) { + const guidance = + nonInteractiveHint ?? + "Provide the required value via the corresponding command flag to run non-interactively."; + throw new Error(`No interactive terminal available for a password prompt. ${guidance}`); + } + throw error; + } } protected async confirmPrompt(message: string): Promise { diff --git a/tests/libs/baseAction.test.ts b/tests/libs/baseAction.test.ts index 48c31676..b4d8b140 100644 --- a/tests/libs/baseAction.test.ts +++ b/tests/libs/baseAction.test.ts @@ -27,7 +27,6 @@ describe("BaseAction", () => { let consoleSpy: any; let consoleErrorSpy: any; let processExitSpy: any; - const originalIsTTY = process.stdin.isTTY; // Standard web3 keystore format const mockKeystoreData = { @@ -51,10 +50,6 @@ describe("BaseAction", () => { beforeEach(() => { vi.clearAllMocks(); - // Prompts require an interactive terminal; simulate one so the - // interactive-path assertions exercise inquirer (vitest's stdin is not a - // TTY, which would otherwise trip the non-interactive guard). - (process.stdin as {isTTY?: boolean}).isTTY = true; consoleSpy = vi.spyOn(console, "log").mockImplementation(() => {}); consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); processExitSpy = vi.spyOn(process, "exit").mockImplementation(() => { @@ -109,7 +104,6 @@ describe("BaseAction", () => { afterEach(() => { vi.restoreAllMocks(); - (process.stdin as {isTTY?: boolean}).isTTY = originalIsTTY; }); test("should start the spinner with a message", () => { @@ -261,23 +255,36 @@ describe("BaseAction", () => { }]); }); - test("should throw an actionable error (not prompt) when no interactive terminal is available", async () => { - (process.stdin as {isTTY?: boolean}).isTTY = false; + test("rewrites inquirer's force-close (no TTY, no piped input) into an actionable error", async () => { + // inquirer throws ExitPromptError when a prompt can't be satisfied; that + // reads like Ctrl-C when a flag is actually missing. We rewrite it, naming + // the flag. (We do NOT pre-empt with an isTTY guard — piped stdin must still + // reach inquirer; see the deploy path in the e2e harness.) + const exitErr = Object.assign(new Error("User force closed the prompt with 0 null"), { + name: "ExitPromptError", + }); + vi.mocked(inquirer.prompt).mockRejectedValue(exitErr); await expect( baseAction["promptPassword"]("Enter password:", "Pass --source-password to run non-interactively."), ).rejects.toThrow(/No interactive terminal available.*--source-password/s); - // Must fail fast, before inquirer force-closes with a misleading ExitPromptError. - expect(inquirer.prompt).not.toHaveBeenCalled(); + expect(inquirer.prompt).toHaveBeenCalled(); // inquirer IS invoked; we only rewrite its force-close }); - test("should surface the generic non-interactive hint when no flag hint is given", async () => { - (process.stdin as {isTTY?: boolean}).isTTY = false; + test("surfaces the generic hint when no flag hint is given", async () => { + const exitErr = Object.assign(new Error("User force closed the prompt with 0 null"), { + name: "ExitPromptError", + }); + vi.mocked(inquirer.prompt).mockRejectedValue(exitErr); await expect(baseAction["promptPassword"]("Enter password:")).rejects.toThrow( /No interactive terminal available.*corresponding command flag/s, ); - expect(inquirer.prompt).not.toHaveBeenCalled(); + }); + + test("passes through a non-force-close prompt error unchanged", async () => { + vi.mocked(inquirer.prompt).mockRejectedValue(new Error("some other inquirer failure")); + await expect(baseAction["promptPassword"]("Enter password:")).rejects.toThrow(/some other inquirer failure/); }); test("should validate password input is not empty", async () => {