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
1 change: 1 addition & 0 deletions docs/Exit-Codes.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ This document describes the exit codes used by the Ably CLI, particularly in int
### Single Ctrl+C
- **At empty prompt**: Shows "^C" and message about typing 'exit' to quit
- **During command execution**: Interrupts the command and returns to prompt
- **At a command's y/n confirmation prompt**: Cancels the command ("Interrupted by user") and returns to prompt
- **With partial command typed**: Clears the line and returns to prompt

### Double Ctrl+C (within 500ms)
Expand Down
2 changes: 1 addition & 1 deletion docs/Project-Structure.md
Original file line number Diff line number Diff line change
Expand Up @@ -132,7 +132,7 @@ This document outlines the directory structure of the Ably CLI project.
│ │ └── interactive-mode.test.ts # Interactive REPL integration tests
│ ├── tty/ # TTY-dependent tests (node-pty, local only)
│ │ ├── tty-test-helper.ts # PTY spawn/wait/write helpers
│ │ └── commands/ # Interactive SIGINT tests
│ │ └── commands/ # Interactive SIGINT tests (prompt, running command, confirmation)
│ ├── e2e/ # End-to-end tests against real Ably
│ │ ├── auth/ # Auth E2E tests
│ │ ├── bench/ # Benchmark E2E tests
Expand Down
23 changes: 22 additions & 1 deletion src/utils/prompt-confirmation.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,20 @@
import * as readline from "node:readline";

/** Rejection reason when the user presses Ctrl+C at a confirmation prompt. */
export class InterruptError extends Error {
constructor() {
super("Interrupted by user");
this.name = "InterruptError";
}
}

/**
* Prompts the user for confirmation with a yes/no question.
* Accepts both "y" and "yes" as affirmative responses (case-insensitive).
*
* Ctrl+C rejects with an `InterruptError` rather than answering "no", because
* some callers carry on with their flow when the answer is no.
*
* @param message - The confirmation message to display to the user
* @param options.defaultYes - If true, an empty answer counts as yes and the
* default suffix becomes " [Y/n]". Use only for non-destructive prompts.
Expand All @@ -27,7 +38,17 @@ export function promptForConfirmation(
? message
: `${message} ${suffix}`;

return new Promise<boolean>((resolve) => {
return new Promise<boolean>((resolve, reject) => {
// Without a SIGINT listener, readline handles Ctrl+C by silently closing
// the interface, leaving this promise pending forever. Inside
// `ably interactive` nothing else then keeps the event loop alive, so Node
// drains it and the whole shell exits with code 0.
rl.on("SIGINT", () => {
rl.close();
process.stdout.write("\n");
reject(new InterruptError());
});

rl.question(promptMessage, (answer) => {
rl.close();
const response = answer.toLowerCase().trim();
Expand Down
65 changes: 65 additions & 0 deletions test/tty/commands/interactive-confirm-sigint.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
/**
* Ctrl+C at a y/n confirmation prompt inside `ably interactive` must cancel
* the command and return to the shell prompt, not exit the shell.
*
* Run locally with: pnpm test:tty
* NOT run in CI — GitHub Actions runners have no TTY.
*/
import { describe, it, expect, afterEach } from "vitest";

import {
spawnTty,
waitForOutput,
writeTty,
sendCtrlC,
killTty,
PROMPT_PATTERN,
type TtyProcess,
} from "../tty-test-helper.js";

describe("Interactive Mode - Ctrl+C at a confirmation prompt (TTY)", () => {
const timeout = 20000;
let proc: TtyProcess | null = null;

afterEach(async () => {
if (proc) {
await killTty(proc);
proc = null;
}
});

it(
"cancels the command and keeps the shell running",
async () => {
proc = spawnTty(["interactive"], {
// Run the real exit path rather than test-mode stubs.
ABLY_CLI_TEST_MODE: undefined,
// `push devices remove` prompts before making any network call.
ABLY_API_KEY: "app.key:secret",
});

await waitForOutput(proc, PROMPT_PATTERN);

writeTty(proc, "push devices remove dev1\r");
await waitForOutput(proc, "[y/n]");

sendCtrlC(proc);

// The command unwinds with an interrupt error and the shell shows a
// fresh prompt instead of exiting.
await waitForOutput(proc, /Interrupted by user[\s\S]*ably>/);
expect(proc.exited).toBe(false);

// The shell still accepts and runs commands.
writeTty(proc, "version\r");
await waitForOutput(proc, "Version: ");
expect(proc.exited).toBe(false);

writeTty(proc, "exit\r");
const { code } = await proc.exitPromise;
// 42 = user exit via `exit` in wrapper mode
expect(code).toBe(42);
},
timeout,
);
});
35 changes: 28 additions & 7 deletions test/unit/utils/prompt-confirmation.test.ts
Original file line number Diff line number Diff line change
@@ -1,17 +1,29 @@
import { describe, it, expect, vi, beforeEach } from "vitest";

let mockQuestion: (query: string, callback: (answer: string) => void) => void;
let mockClose: ReturnType<typeof vi.fn>;
let mockListeners: Record<string, () => void>;

vi.mock("node:readline", () => ({
createInterface: () => ({
close: vi.fn(),
question: (query: string, callback: (answer: string) => void) => {
mockQuestion(query, callback);
},
}),
createInterface: () => {
mockClose = vi.fn();
mockListeners = {};
return {
close: mockClose,
on: (event: string, listener: () => void) => {
mockListeners[event] = listener;
},
question: (query: string, callback: (answer: string) => void) => {
mockQuestion(query, callback);
},
};
},
}));

import { promptForConfirmation } from "../../../src/utils/prompt-confirmation.js";
import {
InterruptError,
promptForConfirmation,
} from "../../../src/utils/prompt-confirmation.js";

describe("promptForConfirmation", () => {
beforeEach(() => {
Expand Down Expand Up @@ -46,6 +58,15 @@ describe("promptForConfirmation", () => {
expect(capturedQuery).toBe("Are you sure? [y/n]");
});

it("rejects with InterruptError and closes the prompt on Ctrl+C", async () => {
vi.spyOn(process.stdout, "write").mockReturnValue(true);
mockQuestion = () => {};
const result = promptForConfirmation("Delete this?");
mockListeners.SIGINT();
await expect(result).rejects.toBeInstanceOf(InterruptError);
expect(mockClose).toHaveBeenCalled();
});

describe("defaultYes", () => {
it("appends [Y/n] suffix when defaultYes is true", async () => {
let capturedQuery = "";
Expand Down
Loading