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
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@mimo-3/slack-cli",
"version": "0.24.0",
"version": "0.24.1",
"description": "A command-line tool for sending messages to Slack",
"main": "dist/index.js",
"bin": {
Expand Down
14 changes: 7 additions & 7 deletions src/commands/usergroups.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,15 +5,15 @@ import { renderByFormat, withSlackClient } from '../utils/command-support';
import { wrapCommand } from '../utils/command-wrapper';
import { createMembersFormatter, MemberInfo } from '../utils/formatters/members-formatters';
import { parseFormat } from '../utils/option-parsers';
import { sanitizeTerminalData, sanitizeTerminalText } from '../utils/terminal-sanitizer';
import { sanitizeSingleLineText, sanitizeTerminalData } from '../utils/terminal-sanitizer';
import { createValidationHook, optionValidators } from '../utils/validators';

function renderUsergroupTable(usergroups: SlackUsergroup[]) {
const rows = usergroups.map((usergroup) => ({
id: sanitizeTerminalText(usergroup.id || ''),
handle: sanitizeTerminalText(usergroup.handle || ''),
name: sanitizeTerminalText(usergroup.name || ''),
description: sanitizeTerminalText(usergroup.description || ''),
id: sanitizeSingleLineText(usergroup.id || ''),
handle: sanitizeSingleLineText(usergroup.handle || ''),
name: sanitizeSingleLineText(usergroup.name || ''),
description: sanitizeSingleLineText(usergroup.description || ''),
user_count: usergroup.user_count ?? '',
}));

Expand All @@ -23,9 +23,9 @@ function renderUsergroupTable(usergroups: SlackUsergroup[]) {
function renderUsergroupSimple(usergroups: SlackUsergroup[]) {
for (const usergroup of usergroups) {
console.log(
`${sanitizeTerminalText(usergroup.id || '')}\t@${sanitizeTerminalText(
`${sanitizeSingleLineText(usergroup.id || '')}\t@${sanitizeSingleLineText(
usergroup.handle || ''
)}\t${sanitizeTerminalText(usergroup.name || '')}`
)}\t${sanitizeSingleLineText(usergroup.name || '')}`
);
}
}
Expand Down
18 changes: 11 additions & 7 deletions src/commands/users.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,11 @@ import { SlackUser, UserPresence } from '../types/slack';
import { renderByFormat, withSlackClient } from '../utils/command-support';
import { wrapCommand } from '../utils/command-wrapper';
import { parseLimit } from '../utils/option-parsers';
import { sanitizeTerminalData, sanitizeTerminalText } from '../utils/terminal-sanitizer';
import {
sanitizeSingleLineText,
sanitizeTerminalData,
sanitizeTerminalText,
} from '../utils/terminal-sanitizer';
import { createValidationHook, optionValidators } from '../utils/validators';

function renderUserTable(users: SlackUser[]) {
Expand All @@ -27,11 +31,11 @@ function renderUserTable(users: SlackUser[]) {

function renderUserSimple(users: SlackUser[]) {
for (const user of users) {
const email = user.profile?.email ? ` <${sanitizeTerminalText(user.profile.email)}>` : '';
const email = user.profile?.email ? ` <${sanitizeSingleLineText(user.profile.email)}>` : '';
console.log(
`${sanitizeTerminalText(user.id || '')}\t${sanitizeTerminalText(
`${sanitizeSingleLineText(user.id || '')}\t${sanitizeSingleLineText(
user.name || ''
)}\t${sanitizeTerminalText(user.real_name || '')}${email}`
)}\t${sanitizeSingleLineText(user.real_name || '')}${email}`
);
}
}
Expand Down Expand Up @@ -61,15 +65,15 @@ function renderUserInfo(user: SlackUser) {
function renderPresenceTable(userId: string, presence: UserPresence) {
const rows = [
{
user: sanitizeTerminalText(userId),
presence: sanitizeTerminalText(presence.presence),
user: sanitizeSingleLineText(userId),
presence: sanitizeSingleLineText(presence.presence),
},
];
console.table(sanitizeTerminalData(rows));
}

function renderPresenceSimple(userId: string, presence: UserPresence) {
console.log(`${sanitizeTerminalText(userId)}\t${sanitizeTerminalText(presence.presence)}`);
console.log(`${sanitizeSingleLineText(userId)}\t${sanitizeSingleLineText(presence.presence)}`);
}

export function setupUsersCommand(): Command {
Expand Down
9 changes: 7 additions & 2 deletions src/utils/command-wrapper.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import chalk from 'chalk';
import { extractErrorMessage } from './error-utils';
import { sanitizeTerminalText } from './terminal-sanitizer';
import { redactSlackTokens } from './token-utils';

export type CommandAction<T = unknown> = (options: T) => Promise<void> | void;
Expand All @@ -9,10 +10,14 @@ export function wrapCommand<T = unknown>(action: CommandAction<T>): CommandActio
try {
await action(options);
} catch (error) {
console.error(chalk.red('✗ Error:'), redactSlackTokens(extractErrorMessage(error)));
// Sanitize first so tokens split by injected escape sequences are still redacted.
console.error(
chalk.red('✗ Error:'),
redactSlackTokens(sanitizeTerminalText(extractErrorMessage(error)))
);

if (process.env.NODE_ENV === 'development' && error instanceof Error) {
console.error(chalk.gray(redactSlackTokens(error.stack)));
console.error(chalk.gray(redactSlackTokens(sanitizeTerminalText(error.stack ?? ''))));
}

process.exit(1);
Expand Down
10 changes: 5 additions & 5 deletions src/utils/formatters/bookmark-formatters.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { sanitizeTerminalText } from '../terminal-sanitizer';
import { sanitizeSingleLineText } from '../terminal-sanitizer';
import { AbstractFormatter, createFormatterFactory, JsonFormatter } from './base-formatter';

export interface BookmarkItem {
Expand Down Expand Up @@ -35,9 +35,9 @@ class BookmarkTableFormatter extends AbstractFormatter<BookmarkFormatterOptions>
console.log('\u2500'.repeat(channelWidth + tsWidth + textWidth + savedAtWidth));

items.forEach((item) => {
const channel = sanitizeTerminalText(item.channel || '').padEnd(channelWidth);
const ts = sanitizeTerminalText(item.message?.ts || '').padEnd(tsWidth);
const text = sanitizeTerminalText(item.message?.text || '')
const channel = sanitizeSingleLineText(item.channel || '').padEnd(channelWidth);
const ts = sanitizeSingleLineText(item.message?.ts || '').padEnd(tsWidth);
const text = sanitizeSingleLineText(item.message?.text || '')
.slice(0, textWidth - 2)
.padEnd(textWidth);
const savedAt = formatDate(item.date_create).padEnd(savedAtWidth);
Expand All @@ -52,7 +52,7 @@ class BookmarkSimpleFormatter extends AbstractFormatter<BookmarkFormatterOptions
items.forEach((item) => {
const savedAt = formatDate(item.date_create);
console.log(
`${sanitizeTerminalText(item.channel || '')}\t${sanitizeTerminalText(item.message?.ts || '')}\t${sanitizeTerminalText(item.message?.text || '')}\t${savedAt}`
`${sanitizeSingleLineText(item.channel || '')}\t${sanitizeSingleLineText(item.message?.ts || '')}\t${sanitizeSingleLineText(item.message?.text || '')}\t${savedAt}`
);
});
}
Expand Down
14 changes: 8 additions & 6 deletions src/utils/formatters/channel-formatters.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,13 @@ import chalk from 'chalk';
import { Channel } from '../../types/slack';
import { formatChannelName } from '../channel-formatter';
import { formatSlackTimestamp } from '../date-utils';
import { sanitizeSingleLineText } from '../terminal-sanitizer';
import { AbstractFormatter, createFormatterFactory, JsonFormatter } from './base-formatter';

function singleLineChannelName(channel: Channel): string {
return sanitizeSingleLineText(channel.display_name || formatChannelName(channel.name));
}

export interface ChannelFormatterOptions {
channels: Channel[];
countOnly?: boolean;
Expand All @@ -15,8 +20,7 @@ class ChannelTableFormatter extends AbstractFormatter<ChannelFormatterOptions> {
console.log('─'.repeat(50));

channels.forEach((channel) => {
const channelName = channel.display_name || formatChannelName(channel.name);
const paddedName = channelName.padEnd(16);
const paddedName = singleLineChannelName(channel).padEnd(16);
const count = (channel.unread_count || 0).toString().padEnd(6);
const lastRead = channel.last_read ? formatSlackTimestamp(channel.last_read) : 'Unknown';
console.log(`${paddedName} ${count} ${lastRead}`);
Expand All @@ -27,8 +31,7 @@ class ChannelTableFormatter extends AbstractFormatter<ChannelFormatterOptions> {
class ChannelSimpleFormatter extends AbstractFormatter<ChannelFormatterOptions> {
format({ channels }: ChannelFormatterOptions): void {
channels.forEach((channel) => {
const channelName = channel.display_name || formatChannelName(channel.name);
console.log(`${channelName} (${channel.unread_count || 0})`);
console.log(`${singleLineChannelName(channel)} (${channel.unread_count || 0})`);
});
}
}
Expand All @@ -49,8 +52,7 @@ class ChannelCountFormatter extends AbstractFormatter<ChannelFormatterOptions> {
channels.forEach((channel) => {
const count = channel.unread_count || 0;
totalUnread += count;
const channelName = channel.display_name || formatChannelName(channel.name);
console.log(`${channelName}: ${count}`);
console.log(`${singleLineChannelName(channel)}: ${count}`);
});
console.log(chalk.bold(`Total: ${totalUnread} unread messages`));
}
Expand Down
8 changes: 4 additions & 4 deletions src/utils/formatters/channels-list-formatters.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { ChannelInfo } from '../channel-formatter';
import { sanitizeTerminalText } from '../terminal-sanitizer';
import { sanitizeSingleLineText } from '../terminal-sanitizer';
import { AbstractFormatter, createFormatterFactory, JsonFormatter } from './base-formatter';

export interface ChannelsListFormatterOptions {
Expand All @@ -14,8 +14,8 @@ class ChannelsTableFormatter extends AbstractFormatter<ChannelsListFormatterOpti

// Print channel rows
channels.forEach((channel) => {
const safeName = sanitizeTerminalText(channel.name);
const safePurpose = sanitizeTerminalText(channel.purpose);
const safeName = sanitizeSingleLineText(channel.name);
const safePurpose = sanitizeSingleLineText(channel.purpose);
const name = safeName.padEnd(17);
const type = channel.type.padEnd(9);
const members = channel.members.toString().padEnd(8);
Expand All @@ -29,7 +29,7 @@ class ChannelsTableFormatter extends AbstractFormatter<ChannelsListFormatterOpti

class ChannelsSimpleFormatter extends AbstractFormatter<ChannelsListFormatterOptions> {
format({ channels }: ChannelsListFormatterOptions): void {
channels.forEach((channel) => console.log(sanitizeTerminalText(channel.name)));
channels.forEach((channel) => console.log(sanitizeSingleLineText(channel.name)));
}
}

Expand Down
10 changes: 5 additions & 5 deletions src/utils/formatters/members-formatters.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { sanitizeTerminalText } from '../terminal-sanitizer';
import { sanitizeSingleLineText } from '../terminal-sanitizer';
import { AbstractFormatter, createFormatterFactory, JsonFormatter } from './base-formatter';

export interface MemberInfo {
Expand All @@ -17,9 +17,9 @@ class MembersTableFormatter extends AbstractFormatter<MembersFormatterOptions> {
console.log('\u2500'.repeat(60));

members.forEach((member) => {
const id = sanitizeTerminalText(member.id || '').padEnd(17);
const name = sanitizeTerminalText(member.name || '').padEnd(17);
const realName = sanitizeTerminalText(member.realName || '');
const id = sanitizeSingleLineText(member.id || '').padEnd(17);
const name = sanitizeSingleLineText(member.name || '').padEnd(17);
const realName = sanitizeSingleLineText(member.realName || '');

console.log(`${id} ${name} ${realName}`);
});
Expand All @@ -30,7 +30,7 @@ class MembersSimpleFormatter extends AbstractFormatter<MembersFormatterOptions>
format({ members }: MembersFormatterOptions): void {
members.forEach((member) => {
console.log(
`${sanitizeTerminalText(member.id || '')}\t${sanitizeTerminalText(member.name || '')}\t${sanitizeTerminalText(member.realName || '')}`
`${sanitizeSingleLineText(member.id || '')}\t${sanitizeSingleLineText(member.name || '')}\t${sanitizeSingleLineText(member.realName || '')}`
);
});
}
Expand Down
8 changes: 4 additions & 4 deletions src/utils/formatters/reminder-formatters.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { sanitizeTerminalText } from '../terminal-sanitizer';
import { sanitizeSingleLineText } from '../terminal-sanitizer';
import { AbstractFormatter, createFormatterFactory, JsonFormatter } from './base-formatter';

export interface ReminderInfo {
Expand Down Expand Up @@ -37,8 +37,8 @@ class ReminderTableFormatter extends AbstractFormatter<ReminderFormatterOptions>
console.log('\u2500'.repeat(idWidth + textWidth + timeWidth + statusWidth));

reminders.forEach((reminder) => {
const id = sanitizeTerminalText(reminder.id || '').padEnd(idWidth);
const text = sanitizeTerminalText(reminder.text || '')
const id = sanitizeSingleLineText(reminder.id || '').padEnd(idWidth);
const text = sanitizeSingleLineText(reminder.text || '')
.slice(0, textWidth - 2)
.padEnd(textWidth);
const time = formatTime(reminder.time).padEnd(timeWidth);
Expand All @@ -55,7 +55,7 @@ class ReminderSimpleFormatter extends AbstractFormatter<ReminderFormatterOptions
const time = formatTime(reminder.time);
const status = getStatus(reminder.complete_ts);
console.log(
`${sanitizeTerminalText(reminder.id || '')}\t${sanitizeTerminalText(reminder.text || '')}\t${time}\t${status}`
`${sanitizeSingleLineText(reminder.id || '')}\t${sanitizeSingleLineText(reminder.text || '')}\t${time}\t${status}`
);
});
}
Expand Down
18 changes: 9 additions & 9 deletions src/utils/formatters/search-formatters.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import chalk from 'chalk';
import { SearchMatch } from '../../types/slack';
import { formatTimestampFixed } from '../date-utils';
import { sanitizeTerminalText } from '../terminal-sanitizer';
import { sanitizeSingleLineText, sanitizeTerminalText } from '../terminal-sanitizer';
import { AbstractFormatter, createFormatterFactory, JsonFormatter } from './base-formatter';

export interface SearchFormatterOptions {
Expand Down Expand Up @@ -30,16 +30,16 @@ class TableSearchFormatter extends AbstractFormatter<SearchFormatterOptions> {
console.log('');
matches.forEach((match) => {
const channel = match.channel.name
? `#${sanitizeTerminalText(match.channel.name)}`
: sanitizeTerminalText(match.channel.id || 'unknown');
const username = sanitizeTerminalText(match.username || match.user || 'Unknown');
? `#${sanitizeSingleLineText(match.channel.name)}`
: sanitizeSingleLineText(match.channel.id || 'unknown');
const username = sanitizeSingleLineText(match.username || match.user || 'Unknown');
const timestamp = match.ts ? formatTimestampFixed(match.ts) : '';
const text = sanitizeTerminalText(match.text || '(no text)');

console.log(`${chalk.gray(`[${timestamp}]`)} ${chalk.blue(channel)} ${chalk.cyan(username)}`);
console.log(text);
if (match.permalink) {
console.log(chalk.gray(sanitizeTerminalText(match.permalink)));
console.log(chalk.gray(sanitizeSingleLineText(match.permalink)));
}
console.log('');
});
Expand All @@ -59,11 +59,11 @@ class SimpleSearchFormatter extends AbstractFormatter<SearchFormatterOptions> {

matches.forEach((match) => {
const channel = match.channel.name
? `#${sanitizeTerminalText(match.channel.name)}`
: sanitizeTerminalText(match.channel.id || 'unknown');
const username = sanitizeTerminalText(match.username || match.user || 'Unknown');
? `#${sanitizeSingleLineText(match.channel.name)}`
: sanitizeSingleLineText(match.channel.id || 'unknown');
const username = sanitizeSingleLineText(match.username || match.user || 'Unknown');
const timestamp = match.ts ? formatTimestampFixed(match.ts) : '';
const text = sanitizeTerminalText(match.text || '(no text)');
const text = sanitizeSingleLineText(match.text || '(no text)');
console.log(`[${channel}] ${username} (${timestamp}): ${text}`);
});

Expand Down
2 changes: 1 addition & 1 deletion src/utils/slack-operations/user-operations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ export class UserOperations extends BaseSlackClient {
}

async getUserInfo(userId: string): Promise<SlackUser> {
const response = await this.client.users.info({ user: userId });
const response = await this.rateLimiter(() => this.client.users.info({ user: userId }));
return response.user as SlackUser;
}

Expand Down
9 changes: 9 additions & 0 deletions src/utils/terminal-sanitizer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,15 @@ export function sanitizeTerminalText(value: string): string {
return sanitized;
}

/**
* Sanitize a value for single-line output (TSV rows, table cells).
* Collapses whitespace runs (including newlines and tabs) into single spaces
* so untrusted values cannot forge extra rows or columns.
*/
export function sanitizeSingleLineText(value: string): string {
return sanitizeTerminalText(value).replace(/\s+/g, ' ').trim();
}

function isPlainObject(value: unknown): value is Record<string, unknown> {
if (!value || typeof value !== 'object') {
return false;
Expand Down
23 changes: 23 additions & 0 deletions tests/commands/usergroups.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,29 @@ describe('usergroups command', () => {
expect(mockConsole.logSpy).toHaveBeenCalledWith(expect.stringContaining('engineers'));
});

it('should collapse newlines in simple format fields', async () => {
vi.mocked(mockConfigManager.getConfig).mockResolvedValue({
token: 'test-token',
updatedAt: new Date().toISOString(),
});
vi.mocked(mockSlackClient.listUsergroups).mockResolvedValue([
{
id: 'S123',
name: 'Engineering\nfake-row',
handle: 'eng\tineers',
description: 'team',
user_count: 10,
},
]);

await program.parseAsync(['node', 'slack-cli', 'usergroups', 'list', '--format', 'simple']);

const output = mockConsole.logSpy.mock.calls[0][0] as string;
expect(output).not.toContain('\n');
expect(output).toContain('Engineering fake-row');
expect(output).toContain('eng ineers');
});

it('should show message when no usergroups found', async () => {
vi.mocked(mockConfigManager.getConfig).mockResolvedValue({
token: 'test-token',
Expand Down
Loading
Loading