Skip to content
Closed
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
84 changes: 84 additions & 0 deletions src/__tests__/config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,64 @@ describe('config command', () => {
expect(webhookPrompt.validate('not-a-webhook')).toBe('Must be a valid Discord webhook URL (including ID and Token)');
});

it('should detect existing config and prompt for reconfiguration β€” cancel keeps current config', async () => {
vi.mocked(configUtils.getConfig).mockReturnValue({ notification_service: 'discord' });
vi.mocked(tui.select).mockResolvedValueOnce('no'); // decline reconfiguration

await program.parseAsync(['node', 'test', 'config', 'setup']);

expect(consoleLogSpy).toHaveBeenCalledWith(expect.stringContaining('Current notification service is set to'));
expect(tui.select).toHaveBeenCalledTimes(1); // only reconfiguration prompt
expect(configUtils.setConfig).not.toHaveBeenCalled();
});

it('should proceed with setup when user confirms reconfiguration', async () => {
vi.mocked(configUtils.getConfig).mockReturnValue({ notification_service: 'discord' });
vi.mocked(tui.select)
.mockResolvedValueOnce('yes') // confirm reconfiguration
.mockResolvedValueOnce('discord'); // select discord service
vi.mocked(tui.input).mockResolvedValue('https://discord.com/api/webhooks/123456789/token-here');

await program.parseAsync(['node', 'test', 'config', 'setup']);

expect(tui.select).toHaveBeenCalledTimes(2); // reconfiguration + service selection
expect(configUtils.setConfig).toHaveBeenCalledWith('notification_service', 'discord');
expect(configUtils.setConfig).toHaveBeenCalledWith('discord_webhook', 'https://discord.com/api/webhooks/123456789/token-here');
});

it('should skip reconfiguration prompt when no existing config', async () => {
vi.mocked(configUtils.getConfig).mockReturnValue({});
vi.mocked(tui.select).mockResolvedValue('none');

await program.parseAsync(['node', 'test', 'config', 'setup']);

expect(consoleLogSpy).not.toHaveBeenCalledWith(
expect.stringContaining('Current notification service is set to'),
);
expect(tui.select).toHaveBeenCalledTimes(1); // only service selection
expect(configUtils.setConfig).toHaveBeenCalledWith('notification_service', 'none');
});
Comment thread
coderabbitai[bot] marked this conversation as resolved.

it('should handle select rejection gracefully during setup', async () => {
vi.mocked(tui.select).mockRejectedValueOnce(new Error('select failed'));

await program.parseAsync(['node', 'test', 'config', 'setup']);

expect(consoleErrorSpy).toHaveBeenCalledWith(expect.stringContaining('select failed'));
expect(configUtils.setConfig).not.toHaveBeenCalled();
});

it('should handle setConfig failure gracefully during setup', async () => {
vi.mocked(tui.select).mockResolvedValue('discord');
vi.mocked(tui.input).mockResolvedValue('https://discord.com/api/webhooks/123456789/token-here');
// Use mockImplementationOnce to avoid polluting subsequent tests
vi.mocked(configUtils.setConfig).mockImplementationOnce(() => { throw new Error('write failed'); });

await program.parseAsync(['node', 'test', 'config', 'setup']);

expect(consoleErrorSpy).toHaveBeenCalledWith(expect.stringContaining('write failed'));
});

it('should call select, multiple inputs and setConfig on email setup without password', async () => {
vi.mocked(tui.select).mockResolvedValue('email');
vi.mocked(tui.input)
Expand Down Expand Up @@ -152,6 +210,32 @@ describe('config command', () => {
expect(consoleLogSpy).toHaveBeenCalledWith(expect.stringContaining('Set alert_email to test@test.com'));
});

it('should show deprecation warning when setting credential key via config set', async () => {
await program.parseAsync(['node', 'test', 'config', 'set', 'discord_webhook', 'https://discord.com/api/webhooks/test']);
expect(consoleLogSpy).toHaveBeenCalledWith(expect.stringContaining('Deprecation warning'));
expect(consoleLogSpy).toHaveBeenCalledWith(expect.stringContaining('kdm config setup'));
expect(configUtils.setConfig).toHaveBeenCalled(); // still executes (soft deprecation)
});

it('should show deprecation warning for all credential keys', async () => {
const credentialKeys = ['notification_service', 'discord_webhook', 'email_host', 'email_port', 'email_user', 'email_to'];
for (const key of credentialKeys) {
vi.clearAllMocks();
// email_port gets parsed to int by the existing handler logic
const testValue = key === 'email_port' ? '587' : 'test-value';
const expectedValue = key === 'email_port' ? 587 : testValue;
await program.parseAsync(['node', 'test', 'config', 'set', key, testValue]);
expect(consoleLogSpy).toHaveBeenCalledWith(expect.stringContaining('Deprecation warning'));
expect(configUtils.setConfig).toHaveBeenCalledWith(key, expectedValue);
}
});

it('should not show deprecation warning for non-credential keys', async () => {
await program.parseAsync(['node', 'test', 'config', 'set', 'alert_cooldown', '300']);
expect(consoleLogSpy).not.toHaveBeenCalledWith(expect.stringContaining('Deprecation warning'));
expect(configUtils.setConfig).toHaveBeenCalledWith('alert_cooldown', 300);
});

it('should parse integer for alert_cooldown', async () => {
await program.parseAsync(['node', 'test', 'config', 'set', 'alert_cooldown', '123']);
expect(configUtils.setConfig).toHaveBeenCalledWith('alert_cooldown', 123);
Expand Down
61 changes: 52 additions & 9 deletions src/commands/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,31 @@ import chalk from 'chalk';
import { setConfig, getConfig, clearConfig, clearNotificationCredentials } from '../utils/config';
import { select, input } from '@vr_patel/tui';

const promptReconfigurationIfNeeded = async (): Promise<boolean> => {
const currentConfig = getConfig();
if (currentConfig.notification_service && currentConfig.notification_service !== 'none') {
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' },
],
});

if (shouldReconfigure === 'no') {
console.log(chalk.dim('Setup cancelled. Current configuration unchanged.'));
return false;
}
}
return true;
};

export const registerConfigCommand = (program: Command) => {
const config = program
.command('config')
Expand All @@ -13,6 +38,10 @@ export const registerConfigCommand = (program: Command) => {
.description('Interactively set up notification service')
.action(async () => {
try {
// Check for existing configuration
Comment thread
Yuvraj-Sarathe marked this conversation as resolved.
const shouldProceed = await promptReconfigurationIfNeeded();
if (!shouldProceed) return;

Comment on lines +41 to +44
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

❌ Getting worse: Complex Method
registerConfigCommand increases in cyclomatic complexity from 17 to 21, threshold = 9

Suppress

Comment on lines +41 to +44
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

❌ New issue: Bumpy Road Ahead
registerConfigCommand has 2 blocks with nested conditional logic. Any nesting of 2 or deeper is considered. Threshold is 2 blocks per function

Suppress

const choice = await select({
message: 'Select notification service:',
options: [
Expand All @@ -39,7 +68,7 @@ export const registerConfigCommand = (program: Command) => {
return discordWebhookRegex.test(v) || 'Must be a valid Discord webhook URL (including ID and Token)';
},
});

clearNotificationCredentials();
setConfig('discord_webhook', webhook);
setConfig('notification_service', 'discord');
Expand Down Expand Up @@ -82,13 +111,13 @@ export const registerConfigCommand = (program: Command) => {
setConfig('email_password', password);
}
setConfig('notification_service', 'email');

console.log(chalk.green('\nβœ“ Email SMTP configured.'));
}

console.log(chalk.green(`βœ“ Notification service set to: ${chalk.bold(choice.toUpperCase())}`));
console.log(chalk.green(`\nβœ“ Notification service set to: ${chalk.bold(choice.toUpperCase())}`));
} catch (error) {
console.error(chalk.red(`βœ— Set up cancelled or failed: ${(error as Error).message}`));
console.error(chalk.red(`\nβœ— Set up cancelled or failed: ${(error as Error).message}`));
}
Comment thread
Yuvraj-Sarathe marked this conversation as resolved.
});

Expand All @@ -97,12 +126,26 @@ export const registerConfigCommand = (program: Command) => {
.description('Set a configuration value')
.action((key, value) => {
try {
// Credential keys that should be configured via the interactive setup
const credentialKeys = ['notification_service', 'discord_webhook', 'email_host', 'email_port', 'email_user', 'email_to'];
const credentialKeyPattern = new RegExp(`^(${credentialKeys.join('|')})$`);

if (credentialKeyPattern.test(key)) {
console.log(chalk.yellow(`\n⚠ Deprecation warning: Setting "${key}" via "kdm config set" is deprecated.`));
console.log(chalk.yellow(` Use ${chalk.bold('kdm config setup')} for guided configuration.\n`));
}

// Convert value to number if key is alert_cooldown or email_port
let finalValue = value;
let finalValue = value;
if (key === 'alert_cooldown' || key === 'email_port') {
finalValue = parseInt(value, 10);
const parsed = Number.parseInt(value, 10);
if (Number.isNaN(parsed)) {
throw new Error(`Invalid numeric value for "${key}"`);
}
finalValue = parsed;
}
Comment thread
Yuvraj-Sarathe marked this conversation as resolved.

setConfig(key as any, finalValue);
console.log(chalk.green(`βœ“ Set ${key} to ${finalValue}`));
} catch (error) {
Expand All @@ -117,15 +160,15 @@ export const registerConfigCommand = (program: Command) => {
const current = getConfig();
console.log(chalk.bold('\nCurrent KDM Configuration:'));
console.log(chalk.gray('──────────────────────────────────────────────────'));

if (Object.keys(current).length === 0) {
console.log(chalk.yellow(' No configuration found. Use "kdm config set <key> <value>"'));
} 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'));
});
Expand Down
10 changes: 4 additions & 6 deletions src/ui/banner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,13 +10,11 @@ export const showWelcomeBanner = (version: string) => {
${chalk.cyan('β•šβ•β• β•šβ•β•β•šβ•β•β•β•β•β• β•šβ•β• β•šβ•β•')}
`;

const signature = chalk.gray(`
Comment thread
Yuvraj-Sarathe marked this conversation as resolved.
──────────────────────────────────────────────────
${chalk.white.bold('devloped by')} ${chalk.yellow.bold('utkarshpatrikar')}
──────────────────────────────────────────────────
`);
const signature = chalk.gray(
Comment thread
Yuvraj-Sarathe marked this conversation as resolved.
'──────────────────────────────────────────────────'
);

console.log(banner);
console.log(signature);
Comment thread
Yuvraj-Sarathe marked this conversation as resolved.
console.log(chalk.blue.bold(` Kubernetes & Docker Monitor v${version}\n`));
console.log(chalk.blue.bold(` Kubernetes & Docker Monitor v${version}`));
};
Loading