From 7693a82b2f2517ccc8b5165a5a9961753ca1bedc Mon Sep 17 00:00:00 2001 From: Akanksha Gore Date: Tue, 18 Aug 2026 11:42:53 +0530 Subject: [PATCH 1/2] feat: add interactive config selection fallback --- src/__tests__/config.test.ts | 87 ++++++++++++++-------- src/commands/config.ts | 140 +++++++++++++++++++++++++++++------ 2 files changed, 174 insertions(+), 53 deletions(-) diff --git a/src/__tests__/config.test.ts b/src/__tests__/config.test.ts index 42b0ca2..80f356c 100644 --- a/src/__tests__/config.test.ts +++ b/src/__tests__/config.test.ts @@ -185,46 +185,79 @@ describe('config command', () => { it('should call select, multiple inputs and setConfig on email setup without password', async () => { vi.mocked(tui.select).mockResolvedValue('email'); + vi.mocked(tui.input) .mockResolvedValueOnce('smtp.gmail.com') // host - .mockResolvedValueOnce('587') // port - .mockResolvedValueOnce('user@test.com') // user - .mockResolvedValueOnce('to@test.com') // to - .mockResolvedValueOnce(''); // password (empty) + .mockResolvedValueOnce('587') // port + .mockResolvedValueOnce('user@test.com') // user + .mockResolvedValueOnce('to@test.com'); // recipient await program.parseAsync(['node', 'test', 'config', 'setup']); - - expect(configUtils.setConfig).toHaveBeenCalledWith('notification_service', 'email'); - expect(configUtils.setConfig).toHaveBeenCalledWith('email_host', 'smtp.gmail.com'); - expect(configUtils.setConfig).toHaveBeenCalledWith('email_port', 587); - expect(configUtils.setConfig).toHaveBeenCalledWith('email_user', 'user@test.com'); - expect(configUtils.setConfig).toHaveBeenCalledWith('email_to', 'to@test.com'); - expect(configUtils.setConfig).not.toHaveBeenCalledWith('email_password', expect.any(String)); - expect(consoleLogSpy).toHaveBeenCalledWith(expect.stringMatching(/Email SMTP setup/i)); - expect(consoleLogSpy).toHaveBeenCalledWith(expect.stringContaining('KDM_SMTP_PASSWORD')); + + expect(tui.select).toHaveBeenCalled(); + expect(tui.input).toHaveBeenCalledTimes(4); + + expect(configUtils.setConfig).toHaveBeenCalledWith( + 'notification_service', + 'email', + ); + expect(configUtils.setConfig).toHaveBeenCalledWith( + 'email_host', + 'smtp.gmail.com', + ); + expect(configUtils.setConfig).toHaveBeenCalledWith( + 'email_port', + 587, + ); + expect(configUtils.setConfig).toHaveBeenCalledWith( + 'email_user', + 'user@test.com', + ); + expect(configUtils.setConfig).toHaveBeenCalledWith( + 'email_to', + 'to@test.com', + ); + + expect(configUtils.setConfig).not.toHaveBeenCalledWith( + 'email_password', + expect.any(String), + ); + + expect(consoleLogSpy).toHaveBeenCalledWith( + expect.stringMatching(/Email SMTP setup/i), + ); + expect(consoleLogSpy).toHaveBeenCalledWith( + expect.stringContaining('KDM_SMTP_PASSWORD'), + ); const guideOrder = consoleLogOrder(/Email SMTP setup/i); const firstTuiInputOrder = vi.mocked(tui.input).mock.invocationCallOrder[0]; expect(guideOrder).toBeLessThan(firstTuiInputOrder); }); - it('should not save email_password if provided during email setup', async () => { + it('should not prompt for or save email_password during email setup', async () => { vi.mocked(tui.select).mockResolvedValue('email'); vi.mocked(tui.input) - .mockResolvedValueOnce('smtp.gmail.com') // host - .mockResolvedValueOnce('587') // port - .mockResolvedValueOnce('user@test.com') // user - .mockResolvedValueOnce('to@test.com') // to - .mockResolvedValueOnce('pass123'); // password + .mockResolvedValueOnce('smtp.gmail.com') + .mockResolvedValueOnce('587') + .mockResolvedValueOnce('user@test.com') + .mockResolvedValueOnce('to@test.com'); await program.parseAsync(['node', 'test', 'config', 'setup']); - const passwordCall = vi.mocked(configUtils.setConfig).mock.calls.find( - (call) => (call[0] as any) === 'email_password', + expect(tui.input).toHaveBeenCalledTimes(4); + + expect( + vi.mocked(tui.input).mock.calls.some( + ([prompt]) => prompt.message === 'SMTP Password (optional):', + ), + ).toBe(false); + + expect(configUtils.setConfig).not.toHaveBeenCalledWith( + 'email_password', + expect.any(String), ); - expect(passwordCall).toBeUndefined(); }); - it('should require an SMTP host during email setup and validate optional SMTP password', async () => { vi.mocked(tui.select).mockResolvedValue('email'); vi.mocked(tui.input) @@ -238,12 +271,8 @@ it('should require an SMTP host during email setup and validate optional SMTP pa const smtpHostPrompt = vi.mocked(tui.input).mock.calls[0][0]; expect(smtpHostPrompt.validate?.('')).toBe('Host is required'); - - // Find the password prompt by looking for the last input call - const passwordPromptIndex = vi.mocked(tui.input).mock.calls.length - 1; - const smtpPasswordPrompt = vi.mocked(tui.input).mock.calls[passwordPromptIndex][0]; - expect(smtpPasswordPrompt.validate?.('')).toBe(true); - expect(smtpPasswordPrompt.validate?.('anything')).toBe(true); + expect(smtpHostPrompt.validate?.('smtp.gmail.com')).toBe(true); + expect(tui.input).toHaveBeenCalledTimes(4); }); it('should call setConfig on config set', async () => { diff --git a/src/commands/config.ts b/src/commands/config.ts index db2a6dd..f0e5f8f 100644 --- a/src/commands/config.ts +++ b/src/commands/config.ts @@ -29,22 +29,111 @@ const readlineInput = (question: string): Promise => { }); }; +const readlineSelect = ( + message: string, + options: Array<{ label: string; value: string }>, +): Promise => { + return new Promise((resolve, reject) => { + let index = 0; + + const render = () => { + console.log(`\n${message}`); + + options.forEach((option, i) => { + const marker = i === index ? '❯' : ' '; + console.log(`${marker} ${option.label}`); + }); + + console.log(chalk.dim('Use ↑/↓ to navigate, Enter to select.')); + }; + + const cleanup = () => { + if (process.stdin.isTTY) { + process.stdin.setRawMode?.(false); + } + + process.stdin.pause(); + process.stdin.removeListener('data', onData); + }; + + const onData = (chunk: Buffer) => { + const key = chunk.toString(); + + if (key === '\u0003') { + cleanup(); + reject(new Error('Cancelled')); + return; + } + + if (key === '\r' || key === '\n') { + const selected = options[index]; + cleanup(); + resolve(selected.value); + return; + } + + if (key === '\u001b[A') { + index = (index - 1 + options.length) % options.length; + console.clear(); + render(); + return; + } + + if (key === '\u001b[B') { + index = (index + 1) % options.length; + console.clear(); + render(); + } + }; + + if (!process.stdin.isTTY) { + reject(new Error('Interactive terminal is required.')); + return; + } + + process.stdin.resume(); + process.stdin.setRawMode?.(true); + process.stdin.on('data', onData); + + render(); + }); +}; + +const promptSelect = async ( + message: string, + options: Array<{ label: string; value: string; description?: string }>, +): Promise => { + try { + return await select({ + message, + options, + }); + } catch (error) { + if ((error as Error).message !== 'Cancelled') { + throw error; + } + + return readlineSelect( + message, + options.map(({ label, value }) => ({ + label, + value, + })), + ); + } +}; + const promptReconfigurationIfNeeded = async (): Promise => { const currentConfig = getConfig(); if (!currentConfig.notification_service || currentConfig.notification_service === 'none') { return true; } - const serviceLabel = currentConfig.notification_service === 'discord' ? 'Discord' : 'Email (SMTP)'; console.log(chalk.yellow(`\n⚠ Current notification service is set to: ${chalk.bold(serviceLabel)}`)); - - const shouldReconfigure = await select({ - message: 'Would you like to reconfigure?', - options: [ - { label: 'Yes', value: 'yes' }, - { label: 'No', value: 'no' }, - ], - }); + const shouldReconfigure = await promptSelect('Would you like to reconfigure?', [ + { label: 'Yes', value: 'yes' }, + { label: 'No', value: 'no' }, + ]); if (shouldReconfigure === 'no') { console.log(chalk.dim('Setup cancelled. Current configuration unchanged.')); @@ -115,11 +204,6 @@ const handleEmailSetup = async () => { validate: (v) => /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(v) || 'Must be a valid email address', }); - const password = await input({ - message: 'SMTP Password (optional):', - validate: () => true, - }); - clearNotificationCredentials(); setConfig('email_host', host); setConfig('email_port', parseInt(portStr, 10)); @@ -145,15 +229,23 @@ export const registerConfigCommand = (program: Command) => { try { if (!(await promptReconfigurationIfNeeded())) return; - const choice = await select({ - message: 'Select notification service:', - options: [ - { label: 'Discord', value: 'discord', description: 'Send alerts to a Discord channel via Webhook' }, - { label: 'Email (SMTP)', value: 'email', description: 'Send alerts via Email SMTP' }, - { label: 'None', value: 'none', description: 'Disable notifications' }, - ], - }); - + const choice = await promptSelect('Select notification service:', [ + { + label: 'Discord', + value: 'discord', + description: 'Send alerts to a Discord channel via Webhook', + }, + { + label: 'Email (SMTP)', + value: 'email', + description: 'Send alerts via Email SMTP', + }, + { + label: 'None', + value: 'none', + description: 'Disable notifications', + }, + ]); const handlers: Record Promise> = { none: handleNoneSetup, discord: handleDiscordSetup, @@ -247,7 +339,7 @@ const printEmailSmtpGuide = () => { console.log(chalk.white(' 1. Find your provider SMTP settings before continuing.')); console.log(chalk.white(' 2. Common hosts: smtp.gmail.com for Gmail, smtp.office365.com for Outlook.')); console.log(chalk.white(' 3. Use port 587 for STARTTLS unless your provider says otherwise.')); - console.log(chalk.white(' 4. Provide the SMTP password during setup or via the KDM_SMTP_PASSWORD environment variable.')); + console.log(chalk.white(' 4. Set the SMTP password via the KDM_SMTP_PASSWORD environment variable.')); console.log(chalk.dim(' Gmail accounts with 2FA usually require an App Password.')); console.log(chalk.gray('──────────────────────────────────────────────────\n')); }; From eba6bfe13c90b74feee49805553bd65e1e213b4a Mon Sep 17 00:00:00 2001 From: Akanksha Gore Date: Tue, 18 Aug 2026 13:32:10 +0530 Subject: [PATCH 2/2] fix: improve config command coverage --- src/__tests__/config.test.ts | 145 ++++++++++++++++++++++++++++++++++- src/commands/config.ts | 123 ++++++++++++++++++----------- 2 files changed, 220 insertions(+), 48 deletions(-) diff --git a/src/__tests__/config.test.ts b/src/__tests__/config.test.ts index 80f356c..83549b5 100644 --- a/src/__tests__/config.test.ts +++ b/src/__tests__/config.test.ts @@ -173,6 +173,143 @@ describe('config command', () => { expect(configUtils.setConfig).not.toHaveBeenCalled(); }); + it('should fallback to readline selection when TUI select is cancelled', async () => { + vi.mocked(tui.select).mockRejectedValueOnce(new Error('Cancelled')); + + const stdin = process.stdin as NodeJS.ReadStream & { + setRawMode?: (mode: boolean) => void; + }; + + const originalIsTTY = stdin.isTTY; + const originalSetRawMode = stdin.setRawMode; + const originalResume = stdin.resume; + const originalPause = stdin.pause; + const originalOn = stdin.on; + const originalRemoveListener = stdin.removeListener; + + let dataHandler: ((chunk: Buffer) => void) | undefined; + + Object.defineProperty(stdin, 'isTTY', { + value: true, + configurable: true, + }); + + stdin.setRawMode = vi.fn(); + stdin.resume = vi.fn(); + stdin.pause = vi.fn(); + + stdin.on = vi.fn((event: string, handler: (chunk: Buffer) => void) => { + if (event === 'data') { + dataHandler = handler; + } + return stdin; + }); + + stdin.removeListener = vi.fn(); + + const promise = program.parseAsync([ + 'node', + 'test', + 'config', + 'setup', + ]); + + await new Promise((resolve) => setTimeout(resolve, 0)); + + expect(dataHandler).toBeDefined(); + + dataHandler!(Buffer.from('\u001b[B')); + dataHandler!(Buffer.from('\u001b[A')); + dataHandler!(Buffer.from('\r')); + + await promise; + + expect(stdin.setRawMode).toHaveBeenCalledWith(true); + expect(stdin.setRawMode).toHaveBeenCalledWith(false); + expect(stdin.resume).toHaveBeenCalled(); + expect(stdin.pause).toHaveBeenCalled(); + expect(stdin.removeListener).toHaveBeenCalledWith( + 'data', + expect.any(Function), + ); + + Object.defineProperty(stdin, 'isTTY', { + value: originalIsTTY, + configurable: true, + }); + + stdin.setRawMode = originalSetRawMode; + stdin.resume = originalResume; + stdin.pause = originalPause; + stdin.on = originalOn; + stdin.removeListener = originalRemoveListener; + }); + + it('should cancel readline selection on Ctrl+C', async () => { + vi.mocked(tui.select).mockRejectedValueOnce(new Error('Cancelled')); + + const stdin = process.stdin as NodeJS.ReadStream & { + setRawMode?: (mode: boolean) => void; + }; + + const originalIsTTY = stdin.isTTY; + const originalSetRawMode = stdin.setRawMode; + const originalResume = stdin.resume; + const originalPause = stdin.pause; + const originalOn = stdin.on; + const originalRemoveListener = stdin.removeListener; + + let dataHandler: ((chunk: Buffer) => void) | undefined; + + Object.defineProperty(stdin, 'isTTY', { + value: true, + configurable: true, + }); + + stdin.setRawMode = vi.fn(); + stdin.resume = vi.fn(); + stdin.pause = vi.fn(); + + stdin.on = vi.fn((event: string, handler: (chunk: Buffer) => void) => { + if (event === 'data') { + dataHandler = handler; + } + return stdin; + }); + + stdin.removeListener = vi.fn(); + + const promise = program.parseAsync([ + 'node', + 'test', + 'config', + 'setup', + ]); + + await new Promise((resolve) => setTimeout(resolve, 0)); + + expect(dataHandler).toBeDefined(); + + dataHandler!(Buffer.from('\u0003')); + + await promise; + + expect(consoleErrorSpy).toHaveBeenCalledWith( + expect.stringContaining('Cancelled'), + ); + + Object.defineProperty(stdin, 'isTTY', { + value: originalIsTTY, + configurable: true, + }); + + stdin.setRawMode = originalSetRawMode; + stdin.resume = originalResume; + stdin.pause = originalPause; + stdin.on = originalOn; + stdin.removeListener = originalRemoveListener; + }); + it('should handle setConfig failure gracefully during setup', async () => { vi.mocked(tui.select).mockResolvedValue('discord'); // mockRlInstance.question default resolves with a valid webhook URL (set in beforeEach) @@ -258,20 +395,22 @@ describe('config command', () => { expect.any(String), ); }); -it('should require an SMTP host during email setup and validate optional SMTP password', async () => { +it('should require an SMTP host during email setup', async () => { vi.mocked(tui.select).mockResolvedValue('email'); + vi.mocked(tui.input) .mockResolvedValueOnce('smtp.gmail.com') .mockResolvedValueOnce('587') .mockResolvedValueOnce('user@test.com') - .mockResolvedValueOnce('to@test.com') - .mockResolvedValueOnce(''); + .mockResolvedValueOnce('to@test.com'); await program.parseAsync(['node', 'test', 'config', 'setup']); const smtpHostPrompt = vi.mocked(tui.input).mock.calls[0][0]; + expect(smtpHostPrompt.validate?.('')).toBe('Host is required'); expect(smtpHostPrompt.validate?.('smtp.gmail.com')).toBe(true); + expect(tui.input).toHaveBeenCalledTimes(4); }); diff --git a/src/commands/config.ts b/src/commands/config.ts index f0e5f8f..210f31f 100644 --- a/src/commands/config.ts +++ b/src/commands/config.ts @@ -214,54 +214,52 @@ const handleEmailSetup = async () => { console.log(chalk.dim(' Set the SMTP password via the KDM_SMTP_PASSWORD environment variable.')); console.log(chalk.green('\n✓ Email SMTP configured.')); }; +const handleConfigSetup = async (): Promise => { + try { + if (!(await promptReconfigurationIfNeeded())) return; + + const choice = await promptSelect('Select notification service:', [ + { + label: 'Discord', + value: 'discord', + description: 'Send alerts to a Discord channel via Webhook', + }, + { + label: 'Email (SMTP)', + value: 'email', + description: 'Send alerts via Email SMTP', + }, + { + label: 'None', + value: 'none', + description: 'Disable notifications', + }, + ]); + + const handlers: Record Promise> = { + none: handleNoneSetup, + discord: handleDiscordSetup, + email: handleEmailSetup, + }; + + await handlers[choice](); + } catch (error) { + console.error(`✖ ${(error as Error).message}`); + } +}; /** * Registers the config CLI command group and subcommands on the Commander program. * @param program Commander program instance. */ -export const registerConfigCommand = (program: Command) => { - const config = program.command('config').description('Manage KDM configuration'); - +const registerConfigSetupCommand = (config: Command) => { config .command('setup') .description('Interactively set up notification service') - .action(async () => { - try { - if (!(await promptReconfigurationIfNeeded())) return; - - const choice = await promptSelect('Select notification service:', [ - { - label: 'Discord', - value: 'discord', - description: 'Send alerts to a Discord channel via Webhook', - }, - { - label: 'Email (SMTP)', - value: 'email', - description: 'Send alerts via Email SMTP', - }, - { - label: 'None', - value: 'none', - description: 'Disable notifications', - }, - ]); - const handlers: Record Promise> = { - none: handleNoneSetup, - discord: handleDiscordSetup, - email: handleEmailSetup, - }; - - const handler = handlers[choice]; - if (handler) { - await handler(); - console.log(chalk.green(`\n✓ Notification service set to: ${chalk.bold(choice.toUpperCase())}`)); - } - } catch (error) { - console.error(chalk.red(`\n✗ Set up cancelled or failed: ${(error as Error).message}`)); - } - }); + .action(handleConfigSetup); +}; +const registerConfigSetCommand = (config: Command) => { config .command('set ') .description('Set a configuration value') @@ -272,28 +270,52 @@ export const registerConfigCommand = (program: Command) => { setConfig(key as any, finalValue); console.log(chalk.green(`✓ Set ${key} to ${finalValue}`)); } catch (error) { - console.error(chalk.red(`✗ Failed to set config: ${(error as Error).message}`)); + console.error( + chalk.red(`✗ Failed to set config: ${(error as Error).message}`), + ); } }); +}; +const registerConfigListCommand = (config: Command) => { config .command('list') .description('List current configuration') .action(() => { const current = getConfig(); + console.log(chalk.bold('\nCurrent KDM Configuration:')); - console.log(chalk.gray('──────────────────────────────────────────────────')); + console.log( + chalk.gray('──────────────────────────────────────────────────'), + ); + if (Object.keys(current).length === 0) { - console.log(chalk.yellow(' No configuration found. Use "kdm config set "')); + console.log( + chalk.yellow( + ' No configuration found. Use "kdm config set "', + ), + ); } else { Object.entries(current).forEach(([key, value]) => { - console.log(`${chalk.cyan(key.padEnd(20))} : ${chalk.white(value)}`); + console.log( + `${chalk.cyan(key.padEnd(20))} : ${chalk.white(value)}`, + ); }); } - console.log(chalk.gray('──────────────────────────────────────────────────')); - console.log(chalk.dim('\n Note: SMTP password can be set either in config or via the KDM_SMTP_PASSWORD environment variable, which takes precedence if both are set.\n')); + + console.log( + chalk.gray('──────────────────────────────────────────────────'), + ); + + console.log( + chalk.dim( + '\n Note: SMTP password can be set either in config or via the KDM_SMTP_PASSWORD environment variable, which takes precedence if both are set.\n', + ), + ); }); +}; +const registerConfigClearCommand = (config: Command) => { config .command('clear') .description('Clear all configuration') @@ -303,6 +325,17 @@ export const registerConfigCommand = (program: Command) => { }); }; +export const registerConfigCommand = (program: Command) => { + const config = program + .command('config') + .description('Manage KDM configuration'); + + registerConfigSetupCommand(config); + registerConfigSetCommand(config); + registerConfigListCommand(config); + registerConfigClearCommand(config); +}; + const checkDeprecation = (key: string) => { const credentialKeys = ['notification_service', 'discord_webhook', 'email_host', 'email_port', 'email_user', 'email_to']; if (credentialKeys.includes(key)) {