Skip to content
Open
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
9 changes: 9 additions & 0 deletions src/cli/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { initSentry, Sentry, flushSentry } from '../sentry.js';
initSentry('cli');

import { main, abortController, interrupted } from './main.js';
import { UserAbortError } from './input.js';

let interruptCount = 0;

Expand All @@ -22,6 +23,14 @@ process.on('SIGINT', () => {
});

main().catch(async (error) => {
if (error instanceof UserAbortError) {
try {
await flushSentry();
} catch {
// Best-effort flush - don't let Sentry errors prevent clean exit
}
process.exit(130);
}
Sentry.captureException(error);
await flushSentry();
console.error('Fatal error:', error);
Expand Down
111 changes: 111 additions & 0 deletions src/cli/input.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { EventEmitter } from 'node:events';
import { readSingleKey, UserAbortError } from './input.js';

/**
* Create a fake stdin that supports setRawMode, resume, pause, and once('data').
* Replaces process.stdin for the duration of the test.
*/
function createFakeStdin() {
const emitter = new EventEmitter();
const fake = Object.assign(emitter, {
isRaw: false,
setRawMode: vi.fn((mode: boolean) => {
fake.isRaw = mode;
return fake;
}),
resume: vi.fn(),
pause: vi.fn(),
isTTY: true as const,
});
return fake;
}

describe('readSingleKey', () => {
let originalStdin: typeof process.stdin;
let fakeStdin: ReturnType<typeof createFakeStdin>;
let stderrSpy: ReturnType<typeof vi.spyOn>;

beforeEach(() => {
originalStdin = process.stdin;
fakeStdin = createFakeStdin();
Object.defineProperty(process, 'stdin', { value: fakeStdin, writable: true });
stderrSpy = vi.spyOn(process.stderr, 'write').mockReturnValue(true);
});

afterEach(() => {
Object.defineProperty(process, 'stdin', { value: originalStdin, writable: true });
stderrSpy.mockRestore();
});

it('resolves with the lowercase key for normal input', async () => {
const promise = readSingleKey();

// Simulate keypress
fakeStdin.emit('data', Buffer.from('A'));

const result = await promise;
expect(result).toBe('a');
});

it('enables raw mode and restores it after reading', async () => {
const promise = readSingleKey();
fakeStdin.emit('data', Buffer.from('x'));
await promise;

expect(fakeStdin.setRawMode).toHaveBeenCalledWith(true);
expect(fakeStdin.setRawMode).toHaveBeenCalledWith(false);
expect(fakeStdin.resume).toHaveBeenCalled();
expect(fakeStdin.pause).toHaveBeenCalled();
});

it('throws UserAbortError on Ctrl+C instead of calling process.exit', async () => {
const exitSpy = vi.spyOn(process, 'exit').mockImplementation(() => undefined as never);

const promise = readSingleKey();

// Simulate Ctrl+C (0x03)
fakeStdin.emit('data', Buffer.from('\x03'));

await expect(promise).rejects.toThrow(UserAbortError);
await expect(promise).rejects.toThrow('User aborted');

// Verify process.exit was NOT called — the whole point of this fix
expect(exitSpy).not.toHaveBeenCalled();

exitSpy.mockRestore();
});

it('restores raw mode before throwing UserAbortError', async () => {
const promise = readSingleKey();
fakeStdin.emit('data', Buffer.from('\x03'));

await expect(promise).rejects.toThrow(UserAbortError);

// Raw mode should have been restored before the rejection
expect(fakeStdin.setRawMode).toHaveBeenCalledWith(false);
expect(fakeStdin.pause).toHaveBeenCalled();
});

it('writes newline to stderr on Ctrl+C', async () => {
const promise = readSingleKey();
fakeStdin.emit('data', Buffer.from('\x03'));

await expect(promise).rejects.toThrow(UserAbortError);
expect(stderrSpy).toHaveBeenCalledWith('\n');
});
});

describe('UserAbortError', () => {
it('is an instance of Error', () => {
const error = new UserAbortError();
expect(error).toBeInstanceOf(Error);
expect(error).toBeInstanceOf(UserAbortError);
});

it('has correct name and message', () => {
const error = new UserAbortError();
expect(error.name).toBe('UserAbortError');
expect(error.message).toBe('User aborted');
});
});
16 changes: 14 additions & 2 deletions src/cli/input.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,19 @@
/**
* Custom error thrown when the user aborts via Ctrl+C during interactive input.
* Allows callers to handle cleanup (e.g. Sentry flush) before exiting.
*/
export class UserAbortError extends Error {
constructor() {
super('User aborted');
this.name = 'UserAbortError';
}
}

/**
* Read a single keypress from stdin in raw mode.
*/
export async function readSingleKey(): Promise<string> {
return new Promise((resolve) => {
return new Promise((resolve, reject) => {
const stdin = process.stdin;
const wasRaw = stdin.isRaw;

Expand All @@ -18,7 +29,8 @@ export async function readSingleKey(): Promise<string> {
// Handle Ctrl+C
if (key === '\x03') {
process.stderr.write('\n');
process.exit(130);
reject(new UserAbortError());
return;
}

resolve(key.toLowerCase());
Expand Down
5 changes: 4 additions & 1 deletion src/cli/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ import {
runInteractiveFixFlow,
renderFixSummary,
} from './fix.js';
import { UserAbortError } from './input.js';
import { runInit } from './commands/init.js';
import { runAdd } from './commands/add.js';
import { runSetupApp } from './commands/setup-app.js';
Expand Down Expand Up @@ -909,7 +910,9 @@ export async function main(): Promise<void> {
isTTY: reporter.mode.isTTY,
reporter,
});
} catch {
} catch (err) {
// Re-throw user abort so it propagates to the top-level handler for cleanup
if (err instanceof UserAbortError) throw err;
// Config load or cleanup failed — skip silently
}

Expand Down