Skip to content

Commit 6c2a1ab

Browse files
committed
fix(account): catch inquirer force-close instead of guarding on isTTY
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.
1 parent 8b8426e commit 6c2a1ab

2 files changed

Lines changed: 56 additions & 37 deletions

File tree

‎src/lib/actions/BaseAction.ts‎

Lines changed: 36 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -450,35 +450,47 @@ export class BaseAction extends ConfigFileManager {
450450
}
451451

452452
/**
453-
* Fail with an actionable message instead of letting inquirer throw a cryptic
454-
* `ExitPromptError: User force closed the prompt` when there is no interactive
455-
* terminal (piped stdin, CI, automation). Without this, a missing flag reads
456-
* like the user hit Ctrl-C. `hint` names the flag(s) that make the command
457-
* non-interactive so the fix is obvious.
453+
* inquirer throws an `ExitPromptError` ("User force closed the prompt") when a
454+
* prompt can't be satisfied — no TTY and no piped stdin. That message reads
455+
* exactly like the user hit Ctrl-C when in fact a required flag is missing.
456+
* Rewrite it into an actionable error naming the flag(s) that make the command
457+
* non-interactive. Crucially this only fires on an ACTUAL force-close: piped
458+
* stdin (how the e2e harness and automation supply the password) still feeds
459+
* inquirer normally, so we must NOT pre-empt with an `isTTY` guard.
458460
*/
459-
protected assertInteractive(hint?: string): void {
460-
if (process.stdin.isTTY) return;
461-
const guidance = hint ?? "Provide the required value via the corresponding command flag to run non-interactively.";
462-
throw new Error(`No interactive terminal available for a prompt. ${guidance}`);
461+
private isExitPromptError(error: unknown): boolean {
462+
if (!error) return false;
463+
const name = (error as {name?: string}).name;
464+
const message = error instanceof Error ? error.message : String(error);
465+
return name === "ExitPromptError" || /force closed the prompt/i.test(message);
463466
}
464467

465468
protected async promptPassword(message: string, nonInteractiveHint?: string): Promise<string> {
466-
this.assertInteractive(nonInteractiveHint);
467-
const answer = await inquirer.prompt([
468-
{
469-
type: "password",
470-
name: "password",
471-
message: chalk.yellow(message),
472-
mask: "*",
473-
validate: (input: string) => {
474-
if (!input) {
475-
return "Password cannot be empty";
476-
}
477-
return true;
469+
try {
470+
const answer = await inquirer.prompt([
471+
{
472+
type: "password",
473+
name: "password",
474+
message: chalk.yellow(message),
475+
mask: "*",
476+
validate: (input: string) => {
477+
if (!input) {
478+
return "Password cannot be empty";
479+
}
480+
return true;
481+
},
478482
},
479-
},
480-
]);
481-
return answer.password;
483+
]);
484+
return answer.password;
485+
} catch (error) {
486+
if (this.isExitPromptError(error)) {
487+
const guidance =
488+
nonInteractiveHint ??
489+
"Provide the required value via the corresponding command flag to run non-interactively.";
490+
throw new Error(`No interactive terminal available for a password prompt. ${guidance}`);
491+
}
492+
throw error;
493+
}
482494
}
483495

484496
protected async confirmPrompt(message: string): Promise<void> {

‎tests/libs/baseAction.test.ts‎

Lines changed: 20 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,6 @@ describe("BaseAction", () => {
2727
let consoleSpy: any;
2828
let consoleErrorSpy: any;
2929
let processExitSpy: any;
30-
const originalIsTTY = process.stdin.isTTY;
3130

3231
// Standard web3 keystore format
3332
const mockKeystoreData = {
@@ -51,10 +50,6 @@ describe("BaseAction", () => {
5150

5251
beforeEach(() => {
5352
vi.clearAllMocks();
54-
// Prompts require an interactive terminal; simulate one so the
55-
// interactive-path assertions exercise inquirer (vitest's stdin is not a
56-
// TTY, which would otherwise trip the non-interactive guard).
57-
(process.stdin as {isTTY?: boolean}).isTTY = true;
5853
consoleSpy = vi.spyOn(console, "log").mockImplementation(() => {});
5954
consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
6055
processExitSpy = vi.spyOn(process, "exit").mockImplementation(() => {
@@ -109,7 +104,6 @@ describe("BaseAction", () => {
109104

110105
afterEach(() => {
111106
vi.restoreAllMocks();
112-
(process.stdin as {isTTY?: boolean}).isTTY = originalIsTTY;
113107
});
114108

115109
test("should start the spinner with a message", () => {
@@ -261,23 +255,36 @@ describe("BaseAction", () => {
261255
}]);
262256
});
263257

264-
test("should throw an actionable error (not prompt) when no interactive terminal is available", async () => {
265-
(process.stdin as {isTTY?: boolean}).isTTY = false;
258+
test("rewrites inquirer's force-close (no TTY, no piped input) into an actionable error", async () => {
259+
// inquirer throws ExitPromptError when a prompt can't be satisfied; that
260+
// reads like Ctrl-C when a flag is actually missing. We rewrite it, naming
261+
// the flag. (We do NOT pre-empt with an isTTY guard — piped stdin must still
262+
// reach inquirer; see the deploy path in the e2e harness.)
263+
const exitErr = Object.assign(new Error("User force closed the prompt with 0 null"), {
264+
name: "ExitPromptError",
265+
});
266+
vi.mocked(inquirer.prompt).mockRejectedValue(exitErr);
266267

267268
await expect(
268269
baseAction["promptPassword"]("Enter password:", "Pass --source-password to run non-interactively."),
269270
).rejects.toThrow(/No interactive terminal available.*--source-password/s);
270-
// Must fail fast, before inquirer force-closes with a misleading ExitPromptError.
271-
expect(inquirer.prompt).not.toHaveBeenCalled();
271+
expect(inquirer.prompt).toHaveBeenCalled(); // inquirer IS invoked; we only rewrite its force-close
272272
});
273273

274-
test("should surface the generic non-interactive hint when no flag hint is given", async () => {
275-
(process.stdin as {isTTY?: boolean}).isTTY = false;
274+
test("surfaces the generic hint when no flag hint is given", async () => {
275+
const exitErr = Object.assign(new Error("User force closed the prompt with 0 null"), {
276+
name: "ExitPromptError",
277+
});
278+
vi.mocked(inquirer.prompt).mockRejectedValue(exitErr);
276279

277280
await expect(baseAction["promptPassword"]("Enter password:")).rejects.toThrow(
278281
/No interactive terminal available.*corresponding command flag/s,
279282
);
280-
expect(inquirer.prompt).not.toHaveBeenCalled();
283+
});
284+
285+
test("passes through a non-force-close prompt error unchanged", async () => {
286+
vi.mocked(inquirer.prompt).mockRejectedValue(new Error("some other inquirer failure"));
287+
await expect(baseAction["promptPassword"]("Enter password:")).rejects.toThrow(/some other inquirer failure/);
281288
});
282289

283290
test("should validate password input is not empty", async () => {

0 commit comments

Comments
 (0)