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..0e7ef5fe 100644 --- a/src/lib/actions/BaseAction.ts +++ b/src/lib/actions/BaseAction.ts @@ -449,22 +449,48 @@ export class BaseAction extends ConfigFileManager { return wallet.privateKey; } - protected async promptPassword(message: string): Promise { - 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; + /** + * 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. + */ + 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 { + 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 91ad3f9f..b4d8b140 100644 --- a/tests/libs/baseAction.test.ts +++ b/tests/libs/baseAction.test.ts @@ -255,6 +255,38 @@ describe("BaseAction", () => { }]); }); + 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); + expect(inquirer.prompt).toHaveBeenCalled(); // inquirer IS invoked; we only rewrite its force-close + }); + + 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, + ); + }); + + 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 () => { vi.mocked(inquirer.prompt).mockResolvedValue({password: "valid-password"});