Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 10 additions & 2 deletions src/commands/account/export.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down Expand Up @@ -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...");

Expand Down
7 changes: 6 additions & 1 deletion src/commands/account/import.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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...");

Expand Down
56 changes: 41 additions & 15 deletions src/lib/actions/BaseAction.ts
Original file line number Diff line number Diff line change
Expand Up @@ -449,22 +449,48 @@ export class BaseAction extends ConfigFileManager {
return wallet.privateKey;
}

protected async promptPassword(message: string): Promise<string> {
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<string> {
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<void> {
Expand Down
32 changes: 32 additions & 0 deletions tests/libs/baseAction.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"});

Expand Down
Loading