From 17042d362f66953ba36fc646bca89c3ec756c56d Mon Sep 17 00:00:00 2001 From: mimo-3 Date: Thu, 23 Jul 2026 19:34:30 +0900 Subject: [PATCH 01/28] =?UTF-8?q?=E3=83=A6=E3=83=BC=E3=82=B6=E3=83=BC?= =?UTF-8?q?=E3=82=B0=E3=83=AB=E3=83=BC=E3=83=97=E5=8F=96=E5=BE=97=E3=81=AE?= =?UTF-8?q?=E6=93=8D=E4=BD=9C=E3=82=AF=E3=83=A9=E3=82=B9=E3=82=92=E8=BF=BD?= =?UTF-8?q?=E5=8A=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../slack-operations/usergroup-operations.ts | 39 ++++ .../usergroup-operations.test.ts | 197 ++++++++++++++++++ 2 files changed, 236 insertions(+) create mode 100644 src/utils/slack-operations/usergroup-operations.ts create mode 100644 tests/utils/slack-operations/usergroup-operations.test.ts diff --git a/src/utils/slack-operations/usergroup-operations.ts b/src/utils/slack-operations/usergroup-operations.ts new file mode 100644 index 0000000..ad1e864 --- /dev/null +++ b/src/utils/slack-operations/usergroup-operations.ts @@ -0,0 +1,39 @@ +import type { SlackUsergroup } from '../../types/slack'; +import { ApiError } from '../errors'; +import { BaseSlackClient, SlackClientDependency } from './base-client'; + +export class UsergroupOperations extends BaseSlackClient { + constructor(dependency: SlackClientDependency) { + super(dependency); + } + + async listUsergroups(includeDisabled = false): Promise { + const response = await this.client.usergroups.list({ + include_count: true, + ...(includeDisabled ? { include_disabled: true } : {}), + }); + + return (response.usergroups || []) as SlackUsergroup[]; + } + + async listUsergroupUsers(usergroupId: string): Promise { + const response = await this.client.usergroups.users.list({ + usergroup: usergroupId, + }); + + return (response.users || []) as string[]; + } + + async resolveUsergroupIdByHandle(handle: string): Promise { + const normalized = handle.replace(/^@/, '').toLowerCase(); + + const usergroups = await this.listUsergroups(true); + for (const usergroup of usergroups) { + if (usergroup.handle?.toLowerCase() === normalized) { + return usergroup.id!; + } + } + + throw new ApiError(`Usergroup '@${handle.replace(/^@/, '')}' not found`); + } +} diff --git a/tests/utils/slack-operations/usergroup-operations.test.ts b/tests/utils/slack-operations/usergroup-operations.test.ts new file mode 100644 index 0000000..37997ea --- /dev/null +++ b/tests/utils/slack-operations/usergroup-operations.test.ts @@ -0,0 +1,197 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { UsergroupOperations } from '../../../src/utils/slack-operations/usergroup-operations'; + +vi.mock('@slack/web-api', () => ({ + WebClient: vi.fn().mockImplementation(function () { + return { + usergroups: { + list: vi.fn(), + users: { + list: vi.fn(), + }, + }, + }; + }), + LogLevel: { + ERROR: 'error', + }, +})); + +describe('UsergroupOperations', () => { + type MockClient = { + usergroups: { + list: ReturnType; + users: { + list: ReturnType; + }; + }; + }; + + let usergroupOps: UsergroupOperations; + let mockClient: MockClient; + + beforeEach(() => { + vi.clearAllMocks(); + usergroupOps = new UsergroupOperations('test-token'); + mockClient = (usergroupOps as unknown as { client: MockClient }).client; + }); + + describe('listUsergroups', () => { + it('should list usergroups with member counts', async () => { + mockClient.usergroups.list.mockResolvedValue({ + ok: true, + usergroups: [ + { + id: 'S123', + name: 'Engineering', + handle: 'engineers', + description: 'Engineering team', + user_count: 10, + date_delete: 0, + }, + { + id: 'S456', + name: 'Design', + handle: 'designers', + description: 'Design team', + user_count: 5, + date_delete: 0, + }, + ], + }); + + const result = await usergroupOps.listUsergroups(); + + expect(mockClient.usergroups.list).toHaveBeenCalledWith({ + include_count: true, + }); + expect(result).toHaveLength(2); + expect(result[0].id).toBe('S123'); + expect(result[0].handle).toBe('engineers'); + expect(result[1].id).toBe('S456'); + }); + + it('should include disabled usergroups when requested', async () => { + mockClient.usergroups.list.mockResolvedValue({ + ok: true, + usergroups: [], + }); + + await usergroupOps.listUsergroups(true); + + expect(mockClient.usergroups.list).toHaveBeenCalledWith({ + include_count: true, + include_disabled: true, + }); + }); + + it('should return empty array when no usergroups exist', async () => { + mockClient.usergroups.list.mockResolvedValue({ + ok: true, + usergroups: [], + }); + + const result = await usergroupOps.listUsergroups(); + + expect(result).toEqual([]); + }); + + it('should return empty array when usergroups field is missing', async () => { + mockClient.usergroups.list.mockResolvedValue({ + ok: true, + }); + + const result = await usergroupOps.listUsergroups(); + + expect(result).toEqual([]); + }); + + it('should throw when API call fails', async () => { + mockClient.usergroups.list.mockRejectedValue(new Error('missing_scope')); + + await expect(usergroupOps.listUsergroups()).rejects.toThrow('missing_scope'); + }); + }); + + describe('listUsergroupUsers', () => { + it('should list user IDs in a usergroup', async () => { + mockClient.usergroups.users.list.mockResolvedValue({ + ok: true, + users: ['U123', 'U456'], + }); + + const result = await usergroupOps.listUsergroupUsers('S123'); + + expect(mockClient.usergroups.users.list).toHaveBeenCalledWith({ + usergroup: 'S123', + }); + expect(result).toEqual(['U123', 'U456']); + }); + + it('should return empty array when usergroup has no users', async () => { + mockClient.usergroups.users.list.mockResolvedValue({ + ok: true, + users: [], + }); + + const result = await usergroupOps.listUsergroupUsers('S123'); + + expect(result).toEqual([]); + }); + + it('should throw when usergroup not found', async () => { + mockClient.usergroups.users.list.mockRejectedValue(new Error('no_such_subteam')); + + await expect(usergroupOps.listUsergroupUsers('SINVALID')).rejects.toThrow('no_such_subteam'); + }); + }); + + describe('resolveUsergroupIdByHandle', () => { + it('should resolve usergroup ID by handle', async () => { + mockClient.usergroups.list.mockResolvedValue({ + ok: true, + usergroups: [ + { id: 'S123', name: 'Engineering', handle: 'engineers' }, + { id: 'S456', name: 'Design', handle: 'designers' }, + ], + }); + + const result = await usergroupOps.resolveUsergroupIdByHandle('designers'); + + expect(result).toBe('S456'); + }); + + it('should resolve handle with @ prefix', async () => { + mockClient.usergroups.list.mockResolvedValue({ + ok: true, + usergroups: [{ id: 'S123', name: 'Engineering', handle: 'engineers' }], + }); + + const result = await usergroupOps.resolveUsergroupIdByHandle('@engineers'); + + expect(result).toBe('S123'); + }); + + it('should resolve handle case-insensitively', async () => { + mockClient.usergroups.list.mockResolvedValue({ + ok: true, + usergroups: [{ id: 'S123', name: 'Engineering', handle: 'Engineers' }], + }); + + const result = await usergroupOps.resolveUsergroupIdByHandle('engineers'); + + expect(result).toBe('S123'); + }); + + it('should throw when handle not found', async () => { + mockClient.usergroups.list.mockResolvedValue({ + ok: true, + usergroups: [{ id: 'S123', name: 'Engineering', handle: 'engineers' }], + }); + + await expect(usergroupOps.resolveUsergroupIdByHandle('unknown')).rejects.toThrow( + "Usergroup '@unknown' not found" + ); + }); + }); +}); From 936d281b513eae7d3f45f59b812c9307b69851bb Mon Sep 17 00:00:00 2001 From: mimo-3 Date: Thu, 23 Jul 2026 19:34:30 +0900 Subject: [PATCH 02/28] =?UTF-8?q?=E3=83=A6=E3=83=BC=E3=82=B6=E3=83=BC?= =?UTF-8?q?=E3=82=B0=E3=83=AB=E3=83=BC=E3=83=97=E9=96=A2=E9=80=A3=E3=81=AE?= =?UTF-8?q?=E5=9E=8B=E3=82=92=E8=BF=BD=E5=8A=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/types/commands.ts | 13 +++++++++++++ src/types/slack.ts | 12 ++++++++++++ 2 files changed, 25 insertions(+) diff --git a/src/types/commands.ts b/src/types/commands.ts index 077f595..8ddeadb 100644 --- a/src/types/commands.ts +++ b/src/types/commands.ts @@ -142,6 +142,19 @@ export interface UsersPresenceOptions { profile?: string; } +export interface UsergroupsListOptions { + includeDisabled?: boolean; + format?: 'table' | 'simple' | 'json'; + profile?: string; +} + +export interface UsergroupsMembersOptions { + id?: string; + handle?: string; + format?: 'table' | 'simple' | 'json'; + profile?: string; +} + export interface ChannelInfoOptions { channel: string; format?: 'table' | 'simple' | 'json'; diff --git a/src/types/slack.ts b/src/types/slack.ts index c6c0e50..78cf6d4 100644 --- a/src/types/slack.ts +++ b/src/types/slack.ts @@ -90,6 +90,18 @@ export interface UserPresence { presence: string; } +export interface SlackUsergroup { + id?: string; + team_id?: string; + name?: string; + handle?: string; + description?: string; + is_external?: boolean; + date_create?: number; + date_delete?: number; + user_count?: number; +} + export interface SlackUser { id?: string; name?: string; From 4bba44ae56bd2e35fca6d304edd83d043fdbfd5d Mon Sep 17 00:00:00 2001 From: mimo-3 Date: Thu, 23 Jul 2026 19:34:30 +0900 Subject: [PATCH 03/28] =?UTF-8?q?SlackApiClient=E3=81=AB=E3=83=A6=E3=83=BC?= =?UTF-8?q?=E3=82=B6=E3=83=BC=E3=82=B0=E3=83=AB=E3=83=BC=E3=83=97API?= =?UTF-8?q?=E3=82=92=E8=BF=BD=E5=8A=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/utils/slack-client-service.ts | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/src/utils/slack-client-service.ts b/src/utils/slack-client-service.ts index 8f09e76..48b9463 100644 --- a/src/utils/slack-client-service.ts +++ b/src/utils/slack-client-service.ts @@ -22,6 +22,7 @@ import type { SearchMessagesOptions, SearchResult, SlackUser, + SlackUsergroup, StarListResult, UserPresence, } from '../types/slack'; @@ -37,6 +38,7 @@ import { ReminderOperations } from './slack-operations/reminder-operations'; import { SearchOperations } from './slack-operations/search-operations'; import { StarOperations } from './slack-operations/star-operations'; import { UserOperations } from './slack-operations/user-operations'; +import { UsergroupOperations } from './slack-operations/usergroup-operations'; export class SlackApiClient { private channelOps: ChannelOperations; @@ -45,6 +47,7 @@ export class SlackApiClient { private reactionOps: ReactionOperations; private pinOps: PinOperations; private userOps: UserOperations; + private usergroupOps: UsergroupOperations; private searchOps: SearchOperations; private reminderOps: ReminderOperations; private starOps: StarOperations; @@ -58,6 +61,7 @@ export class SlackApiClient { this.reactionOps = new ReactionOperations(sharedContext, this.channelOps); this.pinOps = new PinOperations(sharedContext, this.channelOps); this.userOps = new UserOperations(sharedContext); + this.usergroupOps = new UsergroupOperations(sharedContext); this.searchOps = new SearchOperations(sharedContext); this.reminderOps = new ReminderOperations(sharedContext); this.starOps = new StarOperations(sharedContext); @@ -215,6 +219,18 @@ export class SlackApiClient { return this.userOps.resolveUserIdByName(username); } + async listUsergroups(includeDisabled?: boolean): Promise { + return this.usergroupOps.listUsergroups(includeDisabled); + } + + async listUsergroupMembers(usergroupId: string): Promise { + return this.usergroupOps.listUsergroupUsers(usergroupId); + } + + async resolveUsergroupIdByHandle(handle: string): Promise { + return this.usergroupOps.resolveUsergroupIdByHandle(handle); + } + async searchMessages(query: string, options?: SearchMessagesOptions): Promise { return this.searchOps.searchMessages(query, options); } From 4b10e931a355a54cec7fe65d417fa1c63c1f5829 Mon Sep 17 00:00:00 2001 From: mimo-3 Date: Thu, 23 Jul 2026 19:34:30 +0900 Subject: [PATCH 04/28] =?UTF-8?q?usergroups=E3=82=B3=E3=83=9E=E3=83=B3?= =?UTF-8?q?=E3=83=89=E3=82=92=E8=BF=BD=E5=8A=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/commands/usergroups.ts | 144 +++++++++++++ tests/commands/usergroups.test.ts | 336 ++++++++++++++++++++++++++++++ 2 files changed, 480 insertions(+) create mode 100644 src/commands/usergroups.ts create mode 100644 tests/commands/usergroups.test.ts diff --git a/src/commands/usergroups.ts b/src/commands/usergroups.ts new file mode 100644 index 0000000..4fa9302 --- /dev/null +++ b/src/commands/usergroups.ts @@ -0,0 +1,144 @@ +import { Command } from 'commander'; +import { UsergroupsListOptions, UsergroupsMembersOptions } from '../types/commands'; +import { SlackUsergroup } from '../types/slack'; +import { renderByFormat, withSlackClient } from '../utils/command-support'; +import { wrapCommand } from '../utils/command-wrapper'; +import { sanitizeTerminalData, sanitizeTerminalText } from '../utils/terminal-sanitizer'; +import { createValidationHook, optionValidators } from '../utils/validators'; + +interface UsergroupMemberInfo { + id: string; + name?: string; + real_name?: string; +} + +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 || ''), + user_count: usergroup.user_count ?? '', + })); + + console.table(sanitizeTerminalData(rows)); +} + +function renderUsergroupSimple(usergroups: SlackUsergroup[]) { + for (const usergroup of usergroups) { + console.log( + `${sanitizeTerminalText(usergroup.id || '')}\t@${sanitizeTerminalText( + usergroup.handle || '' + )}\t${sanitizeTerminalText(usergroup.name || '')}` + ); + } +} + +function renderMemberTable(members: UsergroupMemberInfo[]) { + const rows = members.map((member) => ({ + id: sanitizeTerminalText(member.id), + name: sanitizeTerminalText(member.name || ''), + real_name: sanitizeTerminalText(member.real_name || ''), + })); + + console.table(sanitizeTerminalData(rows)); +} + +function renderMemberSimple(members: UsergroupMemberInfo[]) { + for (const member of members) { + console.log( + `${sanitizeTerminalText(member.id)}\t${sanitizeTerminalText( + member.name || '' + )}\t${sanitizeTerminalText(member.real_name || '')}` + ); + } +} + +export function setupUsergroupsCommand(): Command { + const usergroupsCommand = new Command('usergroups').description( + 'List user groups and their members' + ); + + const listCommand = new Command('list') + .description('List user groups in the workspace') + .option('--include-disabled', 'Include disabled user groups') + .option('--format ', 'Output format: table, simple, json', 'table') + .option('--profile ', 'Use specific workspace profile') + .hook('preAction', createValidationHook([optionValidators.format])) + .action( + wrapCommand(async (options: UsergroupsListOptions) => { + await withSlackClient(options, async (client) => { + const usergroups = await client.listUsergroups(options.includeDisabled ?? false); + + if (usergroups.length === 0) { + console.log('No usergroups found'); + return; + } + + renderByFormat(options, usergroups, { + table: renderUsergroupTable, + simple: renderUsergroupSimple, + }); + }); + }) + ); + + const membersCommand = new Command('members') + .description('List members of a user group') + .option('--id ', 'User group ID') + .option('--handle ', 'User group handle (e.g. @engineers)') + .option('--format ', 'Output format: table, simple, json', 'table') + .option('--profile ', 'Use specific workspace profile') + .hook('preAction', createValidationHook([optionValidators.format])) + .action( + wrapCommand(async (options: UsergroupsMembersOptions) => { + if (!options.id && !options.handle) { + throw new Error('You must specify either --id or --handle'); + } + if (options.id && options.handle) { + throw new Error('Cannot use both --id and --handle'); + } + + await withSlackClient(options, async (client) => { + let usergroupId: string; + if (options.handle) { + usergroupId = await client.resolveUsergroupIdByHandle(options.handle); + } else { + usergroupId = options.id!; + } + + const memberIds = await client.listUsergroupMembers(usergroupId); + + if (memberIds.length === 0) { + console.log('No members found'); + return; + } + + const members: UsergroupMemberInfo[] = await Promise.all( + memberIds.map(async (userId) => { + try { + const user = await client.getUserInfo(userId); + return { + id: userId, + name: user.name, + real_name: user.real_name, + }; + } catch { + return { id: userId }; + } + }) + ); + + renderByFormat(options, members, { + table: renderMemberTable, + simple: renderMemberSimple, + }); + }); + }) + ); + + usergroupsCommand.addCommand(listCommand); + usergroupsCommand.addCommand(membersCommand); + + return usergroupsCommand; +} diff --git a/tests/commands/usergroups.test.ts b/tests/commands/usergroups.test.ts new file mode 100644 index 0000000..04806a8 --- /dev/null +++ b/tests/commands/usergroups.test.ts @@ -0,0 +1,336 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { setupUsergroupsCommand } from '../../src/commands/usergroups'; +import { ProfileConfigManager } from '../../src/utils/profile-config'; +import { SlackApiClient } from '../../src/utils/slack-api-client'; +import { createTestProgram, restoreMocks, setupMockConsole } from '../test-utils'; + +vi.mock('../../src/utils/slack-api-client'); +vi.mock('../../src/utils/profile-config'); + +describe('usergroups command', () => { + let program: ReturnType; + let mockSlackClient: SlackApiClient; + let mockConfigManager: ProfileConfigManager; + let mockConsole: ReturnType; + + beforeEach(() => { + vi.clearAllMocks(); + + mockConfigManager = new ProfileConfigManager(); + vi.mocked(ProfileConfigManager).mockImplementation(function () { + return mockConfigManager; + }); + + mockSlackClient = new SlackApiClient('test-token'); + vi.mocked(SlackApiClient).mockImplementation(function () { + return mockSlackClient; + }); + + mockConsole = setupMockConsole(); + program = createTestProgram(); + program.addCommand(setupUsergroupsCommand()); + }); + + afterEach(() => { + restoreMocks(); + }); + + describe('list subcommand', () => { + it('should list usergroups in table format', async () => { + vi.mocked(mockConfigManager.getConfig).mockResolvedValue({ + token: 'test-token', + updatedAt: new Date().toISOString(), + }); + vi.mocked(mockSlackClient.listUsergroups).mockResolvedValue([ + { + id: 'S123', + name: 'Engineering', + handle: 'engineers', + description: 'Engineering team', + user_count: 10, + }, + ]); + + await program.parseAsync(['node', 'slack-cli', 'usergroups', 'list']); + + expect(mockSlackClient.listUsergroups).toHaveBeenCalledWith(false); + expect(mockConsole.logSpy).toHaveBeenCalled(); + }); + + it('should include disabled usergroups with --include-disabled', async () => { + vi.mocked(mockConfigManager.getConfig).mockResolvedValue({ + token: 'test-token', + updatedAt: new Date().toISOString(), + }); + vi.mocked(mockSlackClient.listUsergroups).mockResolvedValue([]); + + await program.parseAsync(['node', 'slack-cli', 'usergroups', 'list', '--include-disabled']); + + expect(mockSlackClient.listUsergroups).toHaveBeenCalledWith(true); + }); + + it('should list usergroups in json format', async () => { + vi.mocked(mockConfigManager.getConfig).mockResolvedValue({ + token: 'test-token', + updatedAt: new Date().toISOString(), + }); + const usergroups = [ + { + id: 'S123', + name: 'Engineering', + handle: 'engineers', + description: 'Engineering team', + user_count: 10, + }, + ]; + vi.mocked(mockSlackClient.listUsergroups).mockResolvedValue(usergroups); + + await program.parseAsync(['node', 'slack-cli', 'usergroups', 'list', '--format', 'json']); + + expect(mockConsole.logSpy).toHaveBeenCalledWith(JSON.stringify(usergroups, null, 2)); + }); + + it('should list usergroups in simple format', async () => { + vi.mocked(mockConfigManager.getConfig).mockResolvedValue({ + token: 'test-token', + updatedAt: new Date().toISOString(), + }); + vi.mocked(mockSlackClient.listUsergroups).mockResolvedValue([ + { + id: 'S123', + name: 'Engineering', + handle: 'engineers', + description: 'Engineering team', + user_count: 10, + }, + ]); + + await program.parseAsync(['node', 'slack-cli', 'usergroups', 'list', '--format', 'simple']); + + expect(mockConsole.logSpy).toHaveBeenCalledWith(expect.stringContaining('S123')); + expect(mockConsole.logSpy).toHaveBeenCalledWith(expect.stringContaining('engineers')); + }); + + it('should show message when no usergroups found', async () => { + vi.mocked(mockConfigManager.getConfig).mockResolvedValue({ + token: 'test-token', + updatedAt: new Date().toISOString(), + }); + vi.mocked(mockSlackClient.listUsergroups).mockResolvedValue([]); + + await program.parseAsync(['node', 'slack-cli', 'usergroups', 'list']); + + expect(mockConsole.logSpy).toHaveBeenCalledWith('No usergroups found'); + }); + + it('should use specified profile', async () => { + vi.mocked(mockConfigManager.getConfig).mockResolvedValue({ + token: 'work-token', + updatedAt: new Date().toISOString(), + }); + vi.mocked(mockSlackClient.listUsergroups).mockResolvedValue([]); + + await program.parseAsync(['node', 'slack-cli', 'usergroups', 'list', '--profile', 'work']); + + expect(mockConfigManager.getConfig).toHaveBeenCalledWith('work'); + expect(SlackApiClient).toHaveBeenCalledWith('work-token'); + }); + + it('should handle missing scope error', async () => { + vi.mocked(mockConfigManager.getConfig).mockResolvedValue({ + token: 'test-token', + updatedAt: new Date().toISOString(), + }); + vi.mocked(mockSlackClient.listUsergroups).mockRejectedValue(new Error('missing_scope')); + + await program.parseAsync(['node', 'slack-cli', 'usergroups', 'list']); + + expect(mockConsole.errorSpy).toHaveBeenCalledWith( + expect.stringContaining('Error:'), + expect.any(String) + ); + expect(mockConsole.exitSpy).toHaveBeenCalledWith(1); + }); + }); + + describe('members subcommand', () => { + it('should list usergroup members by id', async () => { + vi.mocked(mockConfigManager.getConfig).mockResolvedValue({ + token: 'test-token', + updatedAt: new Date().toISOString(), + }); + vi.mocked(mockSlackClient.listUsergroupMembers).mockResolvedValue(['U123', 'U456']); + vi.mocked(mockSlackClient.getUserInfo).mockImplementation(async (userId: string) => ({ + id: userId, + name: userId === 'U123' ? 'alice' : 'bob', + real_name: userId === 'U123' ? 'Alice Smith' : 'Bob Jones', + })); + + await program.parseAsync(['node', 'slack-cli', 'usergroups', 'members', '--id', 'S123']); + + expect(mockSlackClient.listUsergroupMembers).toHaveBeenCalledWith('S123'); + expect(mockSlackClient.getUserInfo).toHaveBeenCalledWith('U123'); + expect(mockSlackClient.getUserInfo).toHaveBeenCalledWith('U456'); + expect(mockConsole.logSpy).toHaveBeenCalled(); + }); + + it('should list usergroup members by handle', async () => { + vi.mocked(mockConfigManager.getConfig).mockResolvedValue({ + token: 'test-token', + updatedAt: new Date().toISOString(), + }); + vi.mocked(mockSlackClient.resolveUsergroupIdByHandle).mockResolvedValue('S123'); + vi.mocked(mockSlackClient.listUsergroupMembers).mockResolvedValue(['U123']); + vi.mocked(mockSlackClient.getUserInfo).mockResolvedValue({ + id: 'U123', + name: 'alice', + real_name: 'Alice Smith', + }); + + await program.parseAsync([ + 'node', + 'slack-cli', + 'usergroups', + 'members', + '--handle', + '@engineers', + ]); + + expect(mockSlackClient.resolveUsergroupIdByHandle).toHaveBeenCalledWith('@engineers'); + expect(mockSlackClient.listUsergroupMembers).toHaveBeenCalledWith('S123'); + }); + + it('should list members in simple format', async () => { + vi.mocked(mockConfigManager.getConfig).mockResolvedValue({ + token: 'test-token', + updatedAt: new Date().toISOString(), + }); + vi.mocked(mockSlackClient.listUsergroupMembers).mockResolvedValue(['U123']); + vi.mocked(mockSlackClient.getUserInfo).mockResolvedValue({ + id: 'U123', + name: 'alice', + real_name: 'Alice Smith', + }); + + await program.parseAsync([ + 'node', + 'slack-cli', + 'usergroups', + 'members', + '--id', + 'S123', + '--format', + 'simple', + ]); + + expect(mockConsole.logSpy).toHaveBeenCalledWith(expect.stringContaining('U123')); + expect(mockConsole.logSpy).toHaveBeenCalledWith(expect.stringContaining('alice')); + }); + + it('should fall back to user ID when user info lookup fails', async () => { + vi.mocked(mockConfigManager.getConfig).mockResolvedValue({ + token: 'test-token', + updatedAt: new Date().toISOString(), + }); + vi.mocked(mockSlackClient.listUsergroupMembers).mockResolvedValue(['U123']); + vi.mocked(mockSlackClient.getUserInfo).mockRejectedValue(new Error('user_not_found')); + + await program.parseAsync([ + 'node', + 'slack-cli', + 'usergroups', + 'members', + '--id', + 'S123', + '--format', + 'simple', + ]); + + expect(mockConsole.logSpy).toHaveBeenCalledWith(expect.stringContaining('U123')); + expect(mockConsole.exitSpy).not.toHaveBeenCalled(); + }); + + it('should show message when usergroup has no members', async () => { + vi.mocked(mockConfigManager.getConfig).mockResolvedValue({ + token: 'test-token', + updatedAt: new Date().toISOString(), + }); + vi.mocked(mockSlackClient.listUsergroupMembers).mockResolvedValue([]); + + await program.parseAsync(['node', 'slack-cli', 'usergroups', 'members', '--id', 'S123']); + + expect(mockConsole.logSpy).toHaveBeenCalledWith('No members found'); + }); + + it('should error when neither --id nor --handle is specified', async () => { + vi.mocked(mockConfigManager.getConfig).mockResolvedValue({ + token: 'test-token', + updatedAt: new Date().toISOString(), + }); + + await program.parseAsync(['node', 'slack-cli', 'usergroups', 'members']); + + expect(mockConsole.errorSpy).toHaveBeenCalledWith( + expect.stringContaining('Error:'), + expect.any(String) + ); + expect(mockConsole.exitSpy).toHaveBeenCalledWith(1); + }); + + it('should error when both --id and --handle are specified', async () => { + vi.mocked(mockConfigManager.getConfig).mockResolvedValue({ + token: 'test-token', + updatedAt: new Date().toISOString(), + }); + + await program.parseAsync([ + 'node', + 'slack-cli', + 'usergroups', + 'members', + '--id', + 'S123', + '--handle', + 'engineers', + ]); + + expect(mockConsole.errorSpy).toHaveBeenCalledWith( + expect.stringContaining('Error:'), + expect.any(String) + ); + expect(mockConsole.exitSpy).toHaveBeenCalledWith(1); + }); + + it('should handle usergroup not found error', async () => { + vi.mocked(mockConfigManager.getConfig).mockResolvedValue({ + token: 'test-token', + updatedAt: new Date().toISOString(), + }); + vi.mocked(mockSlackClient.listUsergroupMembers).mockRejectedValue( + new Error('no_such_subteam') + ); + + await program.parseAsync(['node', 'slack-cli', 'usergroups', 'members', '--id', 'SINVALID']); + + expect(mockConsole.errorSpy).toHaveBeenCalledWith( + expect.stringContaining('Error:'), + expect.any(String) + ); + expect(mockConsole.exitSpy).toHaveBeenCalledWith(1); + }); + }); + + describe('error handling', () => { + it('should handle missing configuration', async () => { + vi.mocked(mockConfigManager.getConfig).mockResolvedValue(null); + + await program.parseAsync(['node', 'slack-cli', 'usergroups', 'list']); + + expect(mockConsole.errorSpy).toHaveBeenCalledWith( + expect.stringContaining('Error:'), + expect.any(String) + ); + expect(mockConsole.exitSpy).toHaveBeenCalledWith(1); + }); + }); +}); From 48e75bbcbd28ed931487d6833cd2d1747cbe55e2 Mon Sep 17 00:00:00 2001 From: mimo-3 Date: Thu, 23 Jul 2026 19:34:30 +0900 Subject: [PATCH 05/28] =?UTF-8?q?usergroups=E3=82=B3=E3=83=9E=E3=83=B3?= =?UTF-8?q?=E3=83=89=E3=82=92=E7=99=BB=E9=8C=B2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/index.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/index.ts b/src/index.ts index 3069d8e..7e5802a 100644 --- a/src/index.ts +++ b/src/index.ts @@ -25,6 +25,7 @@ import { setupSendCommand } from './commands/send'; import { setupSendEphemeralCommand } from './commands/send-ephemeral'; import { setupUnreadCommand } from './commands/unread'; import { setupUploadCommand } from './commands/upload'; +import { setupUsergroupsCommand } from './commands/usergroups'; import { setupUsersCommand } from './commands/users'; import { checkForUpdates } from './utils/update-notifier'; @@ -62,6 +63,7 @@ export function createProgram(): Command { program.addCommand(setupReactionCommand()); program.addCommand(setupPinCommand()); program.addCommand(setupUsersCommand()); + program.addCommand(setupUsergroupsCommand()); program.addCommand(setupChannelCommand()); program.addCommand(setupMembersCommand()); program.addCommand(setupSendEphemeralCommand()); From 9afcc76dac407703d2deb19ac05370d2a0cd4f46 Mon Sep 17 00:00:00 2001 From: mimo-3 Date: Thu, 23 Jul 2026 19:34:30 +0900 Subject: [PATCH 06/28] v0.24.0 --- package-lock.json | 4 ++-- package.json | 19 +++++++++++++++---- 2 files changed, 17 insertions(+), 6 deletions(-) diff --git a/package-lock.json b/package-lock.json index a68a022..6b2c830 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@mimo-3/slack-cli", - "version": "0.23.0", + "version": "0.24.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@mimo-3/slack-cli", - "version": "0.23.0", + "version": "0.24.0", "license": "MIT", "dependencies": { "@slack/web-api": "7.15.0", diff --git a/package.json b/package.json index 62a2928..6e79a73 100644 --- a/package.json +++ b/package.json @@ -1,12 +1,14 @@ { "name": "@mimo-3/slack-cli", - "version": "0.23.0", + "version": "0.24.0", "description": "A command-line tool for sending messages to Slack", "main": "dist/index.js", "bin": { "slack-cli": "dist/index.js" }, - "files": ["dist"], + "files": [ + "dist" + ], "scripts": { "build": "tsc", "dev": "ts-node src/index.ts", @@ -23,9 +25,18 @@ "prepare": "npm run build", "prepublishOnly": "npm run build" }, - "keywords": ["slack", "cli", "command-line", "messaging", "api"], + "keywords": [ + "slack", + "cli", + "command-line", + "messaging", + "api" + ], "author": "mimo-3 (fork of urugus/slack-cli)", - "contributors": ["urugus", "mimo-3"], + "contributors": [ + "urugus", + "mimo-3" + ], "repository": { "type": "git", "url": "git+https://github.com/mimo-3/slack-cli.git" From 0fc747b5ac91ac8cb96b1f2fa30f5429fc4c0bcd Mon Sep 17 00:00:00 2001 From: mimo-3 Date: Thu, 23 Jul 2026 19:35:17 +0900 Subject: [PATCH 07/28] =?UTF-8?q?README=E3=81=ABusergroups=E3=82=B3?= =?UTF-8?q?=E3=83=9E=E3=83=B3=E3=83=89=E3=81=AE=E8=AA=AC=E6=98=8E=E3=82=92?= =?UTF-8?q?=E8=BF=BD=E5=8A=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 43 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 43 insertions(+) diff --git a/README.md b/README.md index f2a933a..5ff6b22 100644 --- a/README.md +++ b/README.md @@ -402,6 +402,29 @@ slack-cli users presence --name @alice --format json slack-cli users list --profile work ``` +### User Groups + +```bash +# List user groups in the workspace +slack-cli usergroups list + +# Include disabled user groups +slack-cli usergroups list --include-disabled + +# Output in different formats +slack-cli usergroups list --format json +slack-cli usergroups list --format simple + +# List members of a user group by ID +slack-cli usergroups members --id S01ABCDEF + +# List members of a user group by handle +slack-cli usergroups members --handle @engineers + +# Use specific profile +slack-cli usergroups list --profile work +``` + ### Scheduled Messages ```bash @@ -639,6 +662,25 @@ Subcommands: `list`, `info`, `lookup` | --email | | Email address to look up (required) | | --format | | Output format: table, simple, json (default: table) | +### usergroups command + +Subcommands: `list`, `members` + +#### usergroups list + +| Option | Short | Description | +| ------------------ | ----- | --------------------------------------------------- | +| --include-disabled | | Include disabled user groups | +| --format | | Output format: table, simple, json (default: table) | + +#### usergroups members + +| Option | Short | Description | +| -------- | ----- | ---------------------------------------------------- | +| --id | | User group ID (either --id or --handle is required) | +| --handle | | User group handle (e.g. @engineers) | +| --format | | Output format: table, simple, json (default: table) | + ### scheduled command Subcommands: `list`, `cancel` @@ -799,6 +841,7 @@ Your Slack API token needs the following scopes: - `im:write` - Open DM channels for --user/--email DM sending - `users:read` - Access user information for unread counts and user listing - `users:read.email` - Look up users by email address +- `usergroups:read` - List user groups and their members - `search:read` - Search messages (user token only, not supported with bot tokens) - `reactions:write` - Add and remove reactions - `pins:read` - List pinned items in a channel From d07654c941383b8256c299f2caf0f77cda609b69 Mon Sep 17 00:00:00 2001 From: mimo-3 Date: Thu, 23 Jul 2026 19:36:10 +0900 Subject: [PATCH 08/28] =?UTF-8?q?package.json=E3=81=AE=E6=95=B4=E5=BD=A2?= =?UTF-8?q?=E3=82=92=E4=BF=AE=E6=AD=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- package.json | 17 +++-------------- 1 file changed, 3 insertions(+), 14 deletions(-) diff --git a/package.json b/package.json index 6e79a73..8f32fff 100644 --- a/package.json +++ b/package.json @@ -6,9 +6,7 @@ "bin": { "slack-cli": "dist/index.js" }, - "files": [ - "dist" - ], + "files": ["dist"], "scripts": { "build": "tsc", "dev": "ts-node src/index.ts", @@ -25,18 +23,9 @@ "prepare": "npm run build", "prepublishOnly": "npm run build" }, - "keywords": [ - "slack", - "cli", - "command-line", - "messaging", - "api" - ], + "keywords": ["slack", "cli", "command-line", "messaging", "api"], "author": "mimo-3 (fork of urugus/slack-cli)", - "contributors": [ - "urugus", - "mimo-3" - ], + "contributors": ["urugus", "mimo-3"], "repository": { "type": "git", "url": "git+https://github.com/mimo-3/slack-cli.git" From 1dfda8cbd78ff896c5595f824db50487e2da4807 Mon Sep 17 00:00:00 2001 From: mimo-3 Date: Thu, 23 Jul 2026 19:46:50 +0900 Subject: [PATCH 09/28] =?UTF-8?q?=E3=83=A1=E3=83=B3=E3=83=90=E3=83=BC?= =?UTF-8?q?=E5=8F=96=E5=BE=97=E3=81=AE=E3=83=A1=E3=82=BD=E3=83=83=E3=83=89?= =?UTF-8?q?=E5=90=8D=E3=82=92facade=E3=81=A8=E6=8F=83=E3=81=88=E3=82=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/utils/slack-operations/usergroup-operations.ts | 2 +- .../slack-operations/usergroup-operations.test.ts | 10 ++++++---- 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/src/utils/slack-operations/usergroup-operations.ts b/src/utils/slack-operations/usergroup-operations.ts index ad1e864..72e9fe6 100644 --- a/src/utils/slack-operations/usergroup-operations.ts +++ b/src/utils/slack-operations/usergroup-operations.ts @@ -16,7 +16,7 @@ export class UsergroupOperations extends BaseSlackClient { return (response.usergroups || []) as SlackUsergroup[]; } - async listUsergroupUsers(usergroupId: string): Promise { + async listUsergroupMembers(usergroupId: string): Promise { const response = await this.client.usergroups.users.list({ usergroup: usergroupId, }); diff --git a/tests/utils/slack-operations/usergroup-operations.test.ts b/tests/utils/slack-operations/usergroup-operations.test.ts index 37997ea..ae08b5e 100644 --- a/tests/utils/slack-operations/usergroup-operations.test.ts +++ b/tests/utils/slack-operations/usergroup-operations.test.ts @@ -113,14 +113,14 @@ describe('UsergroupOperations', () => { }); }); - describe('listUsergroupUsers', () => { + describe('listUsergroupMembers', () => { it('should list user IDs in a usergroup', async () => { mockClient.usergroups.users.list.mockResolvedValue({ ok: true, users: ['U123', 'U456'], }); - const result = await usergroupOps.listUsergroupUsers('S123'); + const result = await usergroupOps.listUsergroupMembers('S123'); expect(mockClient.usergroups.users.list).toHaveBeenCalledWith({ usergroup: 'S123', @@ -134,7 +134,7 @@ describe('UsergroupOperations', () => { users: [], }); - const result = await usergroupOps.listUsergroupUsers('S123'); + const result = await usergroupOps.listUsergroupMembers('S123'); expect(result).toEqual([]); }); @@ -142,7 +142,9 @@ describe('UsergroupOperations', () => { it('should throw when usergroup not found', async () => { mockClient.usergroups.users.list.mockRejectedValue(new Error('no_such_subteam')); - await expect(usergroupOps.listUsergroupUsers('SINVALID')).rejects.toThrow('no_such_subteam'); + await expect(usergroupOps.listUsergroupMembers('SINVALID')).rejects.toThrow( + 'no_such_subteam' + ); }); }); From ebc983964f2ab238794f3c7fcd388e15afb2bced Mon Sep 17 00:00:00 2001 From: mimo-3 Date: Thu, 23 Jul 2026 19:46:50 +0900 Subject: [PATCH 10/28] =?UTF-8?q?SlackApiClient=E5=81=B4=E3=81=AE=E5=91=BC?= =?UTF-8?q?=E3=81=B3=E5=87=BA=E3=81=97=E3=82=82=E8=BF=BD=E9=9A=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/utils/slack-client-service.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/utils/slack-client-service.ts b/src/utils/slack-client-service.ts index 48b9463..6a62dc1 100644 --- a/src/utils/slack-client-service.ts +++ b/src/utils/slack-client-service.ts @@ -224,7 +224,7 @@ export class SlackApiClient { } async listUsergroupMembers(usergroupId: string): Promise { - return this.usergroupOps.listUsergroupUsers(usergroupId); + return this.usergroupOps.listUsergroupMembers(usergroupId); } async resolveUsergroupIdByHandle(handle: string): Promise { From d82dba02e26b9cbcb9fe1c6a04c5d6ea0f6ddbfa Mon Sep 17 00:00:00 2001 From: mimo-3 Date: Thu, 23 Jul 2026 19:46:50 +0900 Subject: [PATCH 11/28] =?UTF-8?q?members=E3=81=AE=E8=A1=A8=E7=A4=BA?= =?UTF-8?q?=E3=82=92=E6=97=A2=E5=AD=98=E3=83=95=E3=82=A9=E3=83=BC=E3=83=9E?= =?UTF-8?q?=E3=83=83=E3=82=BF=E3=81=AB=E7=B5=B1=E4=B8=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/commands/usergroups.ts | 38 ++++++-------------------------------- 1 file changed, 6 insertions(+), 32 deletions(-) diff --git a/src/commands/usergroups.ts b/src/commands/usergroups.ts index 4fa9302..225ffe0 100644 --- a/src/commands/usergroups.ts +++ b/src/commands/usergroups.ts @@ -3,15 +3,11 @@ import { UsergroupsListOptions, UsergroupsMembersOptions } from '../types/comman import { SlackUsergroup } from '../types/slack'; 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 { createValidationHook, optionValidators } from '../utils/validators'; -interface UsergroupMemberInfo { - id: string; - name?: string; - real_name?: string; -} - function renderUsergroupTable(usergroups: SlackUsergroup[]) { const rows = usergroups.map((usergroup) => ({ id: sanitizeTerminalText(usergroup.id || ''), @@ -34,26 +30,6 @@ function renderUsergroupSimple(usergroups: SlackUsergroup[]) { } } -function renderMemberTable(members: UsergroupMemberInfo[]) { - const rows = members.map((member) => ({ - id: sanitizeTerminalText(member.id), - name: sanitizeTerminalText(member.name || ''), - real_name: sanitizeTerminalText(member.real_name || ''), - })); - - console.table(sanitizeTerminalData(rows)); -} - -function renderMemberSimple(members: UsergroupMemberInfo[]) { - for (const member of members) { - console.log( - `${sanitizeTerminalText(member.id)}\t${sanitizeTerminalText( - member.name || '' - )}\t${sanitizeTerminalText(member.real_name || '')}` - ); - } -} - export function setupUsergroupsCommand(): Command { const usergroupsCommand = new Command('usergroups').description( 'List user groups and their members' @@ -114,14 +90,14 @@ export function setupUsergroupsCommand(): Command { return; } - const members: UsergroupMemberInfo[] = await Promise.all( + const members: MemberInfo[] = await Promise.all( memberIds.map(async (userId) => { try { const user = await client.getUserInfo(userId); return { id: userId, name: user.name, - real_name: user.real_name, + realName: user.real_name, }; } catch { return { id: userId }; @@ -129,10 +105,8 @@ export function setupUsergroupsCommand(): Command { }) ); - renderByFormat(options, members, { - table: renderMemberTable, - simple: renderMemberSimple, - }); + const format = parseFormat(options.format); + createMembersFormatter(format).format({ members }); }); }) ); From f33e1189552ae62305d23d4c96fd21cd1328d3e1 Mon Sep 17 00:00:00 2001 From: mimo-3 Date: Thu, 23 Jul 2026 22:07:18 +0900 Subject: [PATCH 12/28] =?UTF-8?q?=E3=82=A8=E3=83=A9=E3=83=BC=E5=87=BA?= =?UTF-8?q?=E5=8A=9B=E3=82=82=E3=82=BF=E3=83=BC=E3=83=9F=E3=83=8A=E3=83=AB?= =?UTF-8?q?=E3=82=B5=E3=83=8B=E3=82=BF=E3=82=A4=E3=82=BA=E3=82=92=E9=80=9A?= =?UTF-8?q?=E3=81=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/utils/command-wrapper.ts | 8 ++- tests/utils/command-wrapper.test.ts | 75 +++++++++++++++++++++++++++++ 2 files changed, 81 insertions(+), 2 deletions(-) create mode 100644 tests/utils/command-wrapper.test.ts diff --git a/src/utils/command-wrapper.ts b/src/utils/command-wrapper.ts index 2a5da90..30b2b9a 100644 --- a/src/utils/command-wrapper.ts +++ b/src/utils/command-wrapper.ts @@ -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 = (options: T) => Promise | void; @@ -9,10 +10,13 @@ export function wrapCommand(action: CommandAction): CommandActio try { await action(options); } catch (error) { - console.error(chalk.red('✗ Error:'), redactSlackTokens(extractErrorMessage(error))); + console.error( + chalk.red('✗ Error:'), + sanitizeTerminalText(redactSlackTokens(extractErrorMessage(error))) + ); if (process.env.NODE_ENV === 'development' && error instanceof Error) { - console.error(chalk.gray(redactSlackTokens(error.stack))); + console.error(chalk.gray(sanitizeTerminalText(redactSlackTokens(error.stack) ?? ''))); } process.exit(1); diff --git a/tests/utils/command-wrapper.test.ts b/tests/utils/command-wrapper.test.ts new file mode 100644 index 0000000..bde8ba8 --- /dev/null +++ b/tests/utils/command-wrapper.test.ts @@ -0,0 +1,75 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { wrapCommand } from '../../src/utils/command-wrapper'; + +describe('wrapCommand', () => { + let errorSpy: ReturnType; + let exitSpy: ReturnType; + + beforeEach(() => { + errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + exitSpy = vi.spyOn(process, 'exit').mockImplementation(() => undefined as never); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('should run the action and not exit on success', async () => { + const action = vi.fn().mockResolvedValue(undefined); + + await wrapCommand(action)({}); + + expect(action).toHaveBeenCalled(); + expect(exitSpy).not.toHaveBeenCalled(); + }); + + it('should print the error message and exit with code 1', async () => { + const action = vi.fn().mockRejectedValue(new Error('channel_not_found')); + + await wrapCommand(action)({}); + + expect(errorSpy).toHaveBeenCalledWith( + expect.stringContaining('Error:'), + expect.stringContaining('channel_not_found') + ); + expect(exitSpy).toHaveBeenCalledWith(1); + }); + + it('should strip ANSI escape sequences from error messages', async () => { + const action = vi + .fn() + .mockRejectedValue(new Error('bad_request injected')); + + await wrapCommand(action)({}); + + const message = errorSpy.mock.calls[0][1] as string; + expect(message).not.toContain(''); + expect(message).toContain('bad_request'); + expect(message).toContain('injected'); + }); + + it('should strip OSC sequences from error messages', async () => { + const action = vi + .fn() + .mockRejectedValue(new Error('failed ]0;evil titlerest')); + + await wrapCommand(action)({}); + + const message = errorSpy.mock.calls[0][1] as string; + expect(message).not.toContain(''); + expect(message).not.toContain('evil title'); + expect(message).toContain('failed'); + expect(message).toContain('rest'); + }); + + it('should still redact Slack tokens in error messages', async () => { + const fakeToken = ['xoxb', '1234567890', 'abcdefghij'].join('-'); + const action = vi.fn().mockRejectedValue(new Error(`invalid_auth: ${fakeToken}`)); + + await wrapCommand(action)({}); + + const message = errorSpy.mock.calls[0][1] as string; + expect(message).not.toContain(fakeToken); + expect(message).toContain('invalid_auth'); + }); +}); From 67c6f6e9b2d0caea1fcaf7c0925964c27b08759b Mon Sep 17 00:00:00 2001 From: mimo-3 Date: Thu, 23 Jul 2026 22:08:45 +0900 Subject: [PATCH 13/28] =?UTF-8?q?1=E8=A1=8C=E5=87=BA=E5=8A=9B=E7=94=A8?= =?UTF-8?q?=E3=81=AE=E3=82=B5=E3=83=8B=E3=82=BF=E3=82=A4=E3=82=BA=E3=83=98?= =?UTF-8?q?=E3=83=AB=E3=83=91=E3=83=BC=E3=82=92=E8=BF=BD=E5=8A=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/utils/terminal-sanitizer.ts | 9 +++++++++ tests/utils/terminal-sanitizer.test.ts | 24 +++++++++++++++++++++++- 2 files changed, 32 insertions(+), 1 deletion(-) diff --git a/src/utils/terminal-sanitizer.ts b/src/utils/terminal-sanitizer.ts index 14d9ddd..ffc4a65 100644 --- a/src/utils/terminal-sanitizer.ts +++ b/src/utils/terminal-sanitizer.ts @@ -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 { if (!value || typeof value !== 'object') { return false; diff --git a/tests/utils/terminal-sanitizer.test.ts b/tests/utils/terminal-sanitizer.test.ts index 7d3abb7..4b8a139 100644 --- a/tests/utils/terminal-sanitizer.test.ts +++ b/tests/utils/terminal-sanitizer.test.ts @@ -1,5 +1,9 @@ import { describe, expect, it } from 'vitest'; -import { sanitizeTerminalData, sanitizeTerminalText } from '../../src/utils/terminal-sanitizer'; +import { + sanitizeSingleLineText, + sanitizeTerminalData, + sanitizeTerminalText, +} from '../../src/utils/terminal-sanitizer'; describe('terminal-sanitizer', () => { it('removes ANSI escape sequences and control characters', () => { @@ -31,4 +35,22 @@ describe('terminal-sanitizer', () => { expect(sanitizeTerminalData({ createdAt: date })).toEqual({ createdAt: date }); }); + + describe('sanitizeSingleLineText', () => { + it('collapses newlines and tabs into single spaces', () => { + expect(sanitizeSingleLineText('a\nb\tc')).toBe('a b c'); + }); + + it('collapses whitespace runs and trims the result', () => { + expect(sanitizeSingleLineText(' a \r\n\t b ')).toBe('a b'); + }); + + it('removes ANSI escape sequences before collapsing', () => { + expect(sanitizeSingleLineText('red\nnext')).toBe('red next'); + }); + + it('returns empty string for empty input', () => { + expect(sanitizeSingleLineText('')).toBe(''); + }); + }); }); From 08b9eb1446afe218b0668b6ca21354baf28bbcbb Mon Sep 17 00:00:00 2001 From: mimo-3 Date: Thu, 23 Jul 2026 22:08:45 +0900 Subject: [PATCH 14/28] =?UTF-8?q?=E3=83=A1=E3=83=B3=E3=83=90=E3=83=BC?= =?UTF-8?q?=E8=A1=A8=E7=A4=BA=E3=81=AE=E6=94=B9=E8=A1=8C=E3=83=BB=E3=82=BF?= =?UTF-8?q?=E3=83=96=E3=82=921=E8=A1=8C=E3=81=AB=E7=95=B3=E3=82=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/utils/formatters/members-formatters.ts | 10 +++---- .../formatters/members-formatters.test.ts | 28 +++++++++++++++++++ 2 files changed, 33 insertions(+), 5 deletions(-) diff --git a/src/utils/formatters/members-formatters.ts b/src/utils/formatters/members-formatters.ts index 210fb8d..baac79f 100644 --- a/src/utils/formatters/members-formatters.ts +++ b/src/utils/formatters/members-formatters.ts @@ -1,4 +1,4 @@ -import { sanitizeTerminalText } from '../terminal-sanitizer'; +import { sanitizeSingleLineText } from '../terminal-sanitizer'; import { AbstractFormatter, createFormatterFactory, JsonFormatter } from './base-formatter'; export interface MemberInfo { @@ -17,9 +17,9 @@ class MembersTableFormatter extends AbstractFormatter { 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}`); }); @@ -30,7 +30,7 @@ class MembersSimpleFormatter extends AbstractFormatter 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 || '')}` ); }); } diff --git a/tests/utils/formatters/members-formatters.test.ts b/tests/utils/formatters/members-formatters.test.ts index f895b8b..129cac5 100644 --- a/tests/utils/formatters/members-formatters.test.ts +++ b/tests/utils/formatters/members-formatters.test.ts @@ -88,6 +88,34 @@ describe('members formatters', () => { }); }); + describe('single-line output hardening', () => { + it('should collapse newlines and tabs in simple format fields', () => { + const formatter = createMembersFormatter('simple'); + formatter.format({ + members: [{ id: 'U01', name: 'alice\nfake-row', realName: 'Alice\tSmith' }], + }); + + const output = logSpy.mock.calls[0][0] as string; + expect(output).not.toContain('\n'); + expect(output).toContain('alice fake-row'); + expect(output).toContain('Alice Smith'); + }); + + it('should collapse newlines in table format fields', () => { + const formatter = createMembersFormatter('table'); + formatter.format({ + members: [{ id: 'U01', name: 'alice\nfake-row', realName: 'Alice Smith' }], + }); + + const dataRow = logSpy.mock.calls + .map((call) => call[0] as string) + .find((line) => line.includes('U01')); + expect(dataRow).toBeDefined(); + expect(dataRow).not.toContain('\n'); + expect(dataRow).toContain('alice fake-row'); + }); + }); + describe('factory', () => { it('should default to table format for unknown format', () => { const formatter = createMembersFormatter('unknown'); From 873440ebfba3eddabe17a1685ff88adf1dc64089 Mon Sep 17 00:00:00 2001 From: mimo-3 Date: Thu, 23 Jul 2026 22:08:45 +0900 Subject: [PATCH 15/28] =?UTF-8?q?usergroups=E8=A1=A8=E7=A4=BA=E3=82=821?= =?UTF-8?q?=E8=A1=8C=E3=82=B5=E3=83=8B=E3=82=BF=E3=82=A4=E3=82=BA=E3=81=AB?= =?UTF-8?q?=E6=8F=83=E3=81=88=E3=82=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/commands/usergroups.ts | 14 +++++++------- tests/commands/usergroups.test.ts | 23 +++++++++++++++++++++++ 2 files changed, 30 insertions(+), 7 deletions(-) diff --git a/src/commands/usergroups.ts b/src/commands/usergroups.ts index 225ffe0..897cebb 100644 --- a/src/commands/usergroups.ts +++ b/src/commands/usergroups.ts @@ -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 ?? '', })); @@ -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 || '')}` ); } } diff --git a/tests/commands/usergroups.test.ts b/tests/commands/usergroups.test.ts index 04806a8..f39d18a 100644 --- a/tests/commands/usergroups.test.ts +++ b/tests/commands/usergroups.test.ts @@ -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', From 2784867a5ee224854e1d39563501cf2bf976dd72 Mon Sep 17 00:00:00 2001 From: mimo-3 Date: Thu, 23 Jul 2026 22:09:34 +0900 Subject: [PATCH 16/28] =?UTF-8?q?users.info=E3=82=92=E3=83=AC=E3=83=BC?= =?UTF-8?q?=E3=83=88=E3=83=AA=E3=83=9F=E3=83=83=E3=82=BF=E7=B5=8C=E7=94=B1?= =?UTF-8?q?=E3=81=AB=E3=81=99=E3=82=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/utils/slack-operations/user-operations.ts | 2 +- .../slack-operations/user-operations.test.ts | 18 ++++++++++++++++++ 2 files changed, 19 insertions(+), 1 deletion(-) diff --git a/src/utils/slack-operations/user-operations.ts b/src/utils/slack-operations/user-operations.ts index 72c13b4..5e2ac0d 100644 --- a/src/utils/slack-operations/user-operations.ts +++ b/src/utils/slack-operations/user-operations.ts @@ -35,7 +35,7 @@ export class UserOperations extends BaseSlackClient { } async getUserInfo(userId: string): Promise { - 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; } diff --git a/tests/utils/slack-operations/user-operations.test.ts b/tests/utils/slack-operations/user-operations.test.ts index 092660d..6fda3c5 100644 --- a/tests/utils/slack-operations/user-operations.test.ts +++ b/tests/utils/slack-operations/user-operations.test.ts @@ -182,6 +182,24 @@ describe('UserOperations', () => { await expect(userOps.getUserInfo('UINVALID')).rejects.toThrow('user_not_found'); }); + + it('should limit concurrent users.info calls through the rate limiter', async () => { + let inFlight = 0; + let maxInFlight = 0; + mockClient.users.info.mockImplementation(async ({ user }: { user: string }) => { + inFlight++; + maxInFlight = Math.max(maxInFlight, inFlight); + await new Promise((resolve) => setTimeout(resolve, 5)); + inFlight--; + return { ok: true, user: { id: user } }; + }); + + const userIds = Array.from({ length: 10 }, (_, index) => `U${index}`); + await Promise.all(userIds.map((userId) => userOps.getUserInfo(userId))); + + expect(mockClient.users.info).toHaveBeenCalledTimes(10); + expect(maxInFlight).toBeLessThanOrEqual(3); + }); }); describe('lookupByEmail', () => { From 35fa282b1adfe1ecf2dfa460ecd57c2db875d7c0 Mon Sep 17 00:00:00 2001 From: mimo-3 Date: Thu, 23 Jul 2026 22:09:35 +0900 Subject: [PATCH 17/28] v0.24.1 --- package-lock.json | 4 ++-- package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index 6b2c830..13d28df 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@mimo-3/slack-cli", - "version": "0.24.0", + "version": "0.24.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@mimo-3/slack-cli", - "version": "0.24.0", + "version": "0.24.1", "license": "MIT", "dependencies": { "@slack/web-api": "7.15.0", diff --git a/package.json b/package.json index 8f32fff..c9d1bf8 100644 --- a/package.json +++ b/package.json @@ -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": { From c464be580d0263364dce013c9536cde52b057586 Mon Sep 17 00:00:00 2001 From: mimo-3 Date: Thu, 23 Jul 2026 22:09:46 +0900 Subject: [PATCH 18/28] =?UTF-8?q?=E3=83=86=E3=82=B9=E3=83=88=E3=81=AE?= =?UTF-8?q?=E6=95=B4=E5=BD=A2=E3=82=92=E4=BF=AE=E6=AD=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tests/utils/command-wrapper.test.ts | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/tests/utils/command-wrapper.test.ts b/tests/utils/command-wrapper.test.ts index bde8ba8..35484c8 100644 --- a/tests/utils/command-wrapper.test.ts +++ b/tests/utils/command-wrapper.test.ts @@ -36,9 +36,7 @@ describe('wrapCommand', () => { }); it('should strip ANSI escape sequences from error messages', async () => { - const action = vi - .fn() - .mockRejectedValue(new Error('bad_request injected')); + const action = vi.fn().mockRejectedValue(new Error('bad_request injected')); await wrapCommand(action)({}); @@ -49,9 +47,7 @@ describe('wrapCommand', () => { }); it('should strip OSC sequences from error messages', async () => { - const action = vi - .fn() - .mockRejectedValue(new Error('failed ]0;evil titlerest')); + const action = vi.fn().mockRejectedValue(new Error('failed ]0;evil titlerest')); await wrapCommand(action)({}); From 32052b93938ebf4d8b95f513f6f2272709159d01 Mon Sep 17 00:00:00 2001 From: mimo-3 Date: Thu, 23 Jul 2026 22:20:07 +0900 Subject: [PATCH 19/28] =?UTF-8?q?=E3=83=88=E3=83=BC=E3=82=AF=E3=83=B3?= =?UTF-8?q?=E7=A7=98=E5=8C=BF=E3=82=92=E3=82=B5=E3=83=8B=E3=82=BF=E3=82=A4?= =?UTF-8?q?=E3=82=BA=E5=BE=8C=E3=81=AB=E8=A1=8C=E3=81=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/utils/command-wrapper.ts | 5 +++-- tests/utils/command-wrapper.test.ts | 14 ++++++++++++++ 2 files changed, 17 insertions(+), 2 deletions(-) diff --git a/src/utils/command-wrapper.ts b/src/utils/command-wrapper.ts index 30b2b9a..b81ca32 100644 --- a/src/utils/command-wrapper.ts +++ b/src/utils/command-wrapper.ts @@ -10,13 +10,14 @@ export function wrapCommand(action: CommandAction): CommandActio try { await action(options); } catch (error) { + // Sanitize first so tokens split by injected escape sequences are still redacted. console.error( chalk.red('✗ Error:'), - sanitizeTerminalText(redactSlackTokens(extractErrorMessage(error))) + redactSlackTokens(sanitizeTerminalText(extractErrorMessage(error))) ); if (process.env.NODE_ENV === 'development' && error instanceof Error) { - console.error(chalk.gray(sanitizeTerminalText(redactSlackTokens(error.stack) ?? ''))); + console.error(chalk.gray(redactSlackTokens(sanitizeTerminalText(error.stack ?? '')))); } process.exit(1); diff --git a/tests/utils/command-wrapper.test.ts b/tests/utils/command-wrapper.test.ts index 35484c8..1ad2fc4 100644 --- a/tests/utils/command-wrapper.test.ts +++ b/tests/utils/command-wrapper.test.ts @@ -68,4 +68,18 @@ describe('wrapCommand', () => { expect(message).not.toContain(fakeToken); expect(message).toContain('invalid_auth'); }); + + it('should redact tokens split by injected escape sequences', async () => { + const tokenHead = ['xoxb', '12345'].join('-'); + const tokenTail = '67890-abcdefghij'; + const action = vi + .fn() + .mockRejectedValue(new Error(`invalid_auth: ${tokenHead}${tokenTail}`)); + + await wrapCommand(action)({}); + + const message = errorSpy.mock.calls[0][1] as string; + expect(message).not.toContain(tokenTail); + expect(message).toContain('invalid_auth'); + }); }); From f6c39ceb2bf2de09546e853acef2efc1b12d31e3 Mon Sep 17 00:00:00 2001 From: mimo-3 Date: Thu, 23 Jul 2026 22:20:07 +0900 Subject: [PATCH 20/28] =?UTF-8?q?=E3=83=96=E3=83=83=E3=82=AF=E3=83=9E?= =?UTF-8?q?=E3=83=BC=E3=82=AF=E8=A1=A8=E7=A4=BA=E3=82=821=E8=A1=8C?= =?UTF-8?q?=E3=82=B5=E3=83=8B=E3=82=BF=E3=82=A4=E3=82=BA=E3=81=AB=E6=8F=83?= =?UTF-8?q?=E3=81=88=E3=82=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/utils/formatters/bookmark-formatters.ts | 10 +++--- .../formatters/bookmark-formatters.test.ts | 33 +++++++++++++++++++ 2 files changed, 38 insertions(+), 5 deletions(-) diff --git a/src/utils/formatters/bookmark-formatters.ts b/src/utils/formatters/bookmark-formatters.ts index 746a754..5285766 100644 --- a/src/utils/formatters/bookmark-formatters.ts +++ b/src/utils/formatters/bookmark-formatters.ts @@ -1,4 +1,4 @@ -import { sanitizeTerminalText } from '../terminal-sanitizer'; +import { sanitizeSingleLineText } from '../terminal-sanitizer'; import { AbstractFormatter, createFormatterFactory, JsonFormatter } from './base-formatter'; export interface BookmarkItem { @@ -35,9 +35,9 @@ class BookmarkTableFormatter extends AbstractFormatter 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); @@ -52,7 +52,7 @@ class BookmarkSimpleFormatter extends AbstractFormatter { 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}` ); }); } diff --git a/tests/utils/formatters/bookmark-formatters.test.ts b/tests/utils/formatters/bookmark-formatters.test.ts index 3e98dc7..47e2e60 100644 --- a/tests/utils/formatters/bookmark-formatters.test.ts +++ b/tests/utils/formatters/bookmark-formatters.test.ts @@ -118,4 +118,37 @@ describe('bookmark formatters', () => { expect(logSpy).toHaveBeenCalled(); }); }); + + describe('single-line output hardening', () => { + const itemsWithNewline: BookmarkFormatterOptions = { + items: [ + { + type: 'message', + channel: 'C123', + message: { text: 'first line\nfake-row', ts: '1234567890.123456' }, + date_create: 1709290800, + }, + ], + }; + + it('should collapse newlines in simple format text', () => { + const formatter = createBookmarkFormatter('simple'); + formatter.format(itemsWithNewline); + + const output = logSpy.mock.calls[0][0] as string; + expect(output).not.toContain('\n'); + expect(output).toContain('first line fake-row'); + }); + + it('should collapse newlines in table format text', () => { + const formatter = createBookmarkFormatter('table'); + formatter.format(itemsWithNewline); + + const dataRow = logSpy.mock.calls + .map((call) => call[0] as string) + .find((line) => line.includes('C123')); + expect(dataRow).toBeDefined(); + expect(dataRow).not.toContain('\n'); + }); + }); }); From 3ee44bc72f780a393027bf23128d34113712b417 Mon Sep 17 00:00:00 2001 From: mimo-3 Date: Thu, 23 Jul 2026 22:20:07 +0900 Subject: [PATCH 21/28] =?UTF-8?q?=E3=83=AA=E3=83=9E=E3=82=A4=E3=83=B3?= =?UTF-8?q?=E3=83=80=E3=83=BC=E8=A1=A8=E7=A4=BA=E3=82=821=E8=A1=8C?= =?UTF-8?q?=E3=82=B5=E3=83=8B=E3=82=BF=E3=82=A4=E3=82=BA=E3=81=AB=E6=8F=83?= =?UTF-8?q?=E3=81=88=E3=82=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/utils/formatters/reminder-formatters.ts | 8 ++--- .../formatters/reminder-formatters.test.ts | 32 +++++++++++++++++++ 2 files changed, 36 insertions(+), 4 deletions(-) diff --git a/src/utils/formatters/reminder-formatters.ts b/src/utils/formatters/reminder-formatters.ts index 31b9326..86999c3 100644 --- a/src/utils/formatters/reminder-formatters.ts +++ b/src/utils/formatters/reminder-formatters.ts @@ -1,4 +1,4 @@ -import { sanitizeTerminalText } from '../terminal-sanitizer'; +import { sanitizeSingleLineText } from '../terminal-sanitizer'; import { AbstractFormatter, createFormatterFactory, JsonFormatter } from './base-formatter'; export interface ReminderInfo { @@ -37,8 +37,8 @@ class ReminderTableFormatter extends AbstractFormatter 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); @@ -55,7 +55,7 @@ class ReminderSimpleFormatter extends AbstractFormatter { expect(logSpy).toHaveBeenCalled(); }); }); + + describe('single-line output hardening', () => { + const remindersWithNewline = [ + { + id: 'Rm01', + text: 'first line\nfake-row', + time: 1709290800, + complete_ts: 0, + recurring: false, + }, + ]; + + it('should collapse newlines in simple format text', () => { + const formatter = createReminderFormatter('simple'); + formatter.format({ reminders: remindersWithNewline }); + + const output = logSpy.mock.calls[0][0] as string; + expect(output).not.toContain('\n'); + expect(output).toContain('first line fake-row'); + }); + + it('should collapse newlines in table format text', () => { + const formatter = createReminderFormatter('table'); + formatter.format({ reminders: remindersWithNewline }); + + const dataRow = logSpy.mock.calls + .map((call) => call[0] as string) + .find((line) => line.includes('Rm01')); + expect(dataRow).toBeDefined(); + expect(dataRow).not.toContain('\n'); + }); + }); }); From 3c05a6856e3fac6f9910a88ba057aa6a8cdd0d21 Mon Sep 17 00:00:00 2001 From: mimo-3 Date: Thu, 23 Jul 2026 22:20:07 +0900 Subject: [PATCH 22/28] =?UTF-8?q?=E6=A4=9C=E7=B4=A2=E7=B5=90=E6=9E=9C?= =?UTF-8?q?=E3=81=AE=E8=A1=8C=E8=A1=A8=E7=A4=BA=E3=82=821=E8=A1=8C?= =?UTF-8?q?=E3=82=B5=E3=83=8B=E3=82=BF=E3=82=A4=E3=82=BA=E3=81=AB=E6=8F=83?= =?UTF-8?q?=E3=81=88=E3=82=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/utils/formatters/search-formatters.ts | 18 +++++----- .../formatters/search-formatters.test.ts | 36 +++++++++++++++++++ 2 files changed, 45 insertions(+), 9 deletions(-) diff --git a/src/utils/formatters/search-formatters.ts b/src/utils/formatters/search-formatters.ts index 66d77bf..01197db 100644 --- a/src/utils/formatters/search-formatters.ts +++ b/src/utils/formatters/search-formatters.ts @@ -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 { @@ -30,16 +30,16 @@ class TableSearchFormatter extends AbstractFormatter { 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(''); }); @@ -59,11 +59,11 @@ class SimpleSearchFormatter extends AbstractFormatter { 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}`); }); diff --git a/tests/utils/formatters/search-formatters.test.ts b/tests/utils/formatters/search-formatters.test.ts index a603615..ff2893a 100644 --- a/tests/utils/formatters/search-formatters.test.ts +++ b/tests/utils/formatters/search-formatters.test.ts @@ -136,4 +136,40 @@ describe('SearchFormatters', () => { expect(mockConsole).toHaveBeenCalled(); }); }); + + describe('single-line output hardening', () => { + const matchWithNewline: SearchMatch = { + text: 'first line\nfake-row', + user: 'U123', + username: 'john.doe', + ts: '1609459200.000100', + channel: { id: 'C123', name: 'general' }, + permalink: 'https://slack.com/archives/C123/p1609459200000100', + }; + + it('should keep each simple format match on a single line', () => { + mockConsole.mockClear(); + const formatter = createSearchFormatter('simple'); + formatter.format(createOptions({ matches: [matchWithNewline], totalCount: 1 })); + + const matchLine = mockConsole.mock.calls + .map((call) => call[0] as string) + .find((line) => line.includes('john.doe')); + expect(matchLine).toBeDefined(); + expect(matchLine).not.toContain('\n'); + expect(matchLine).toContain('first line fake-row'); + }); + + it('should keep each table format match on a single line', () => { + mockConsole.mockClear(); + const formatter = createSearchFormatter('table'); + formatter.format(createOptions({ matches: [matchWithNewline], totalCount: 1 })); + + const matchLine = mockConsole.mock.calls + .map((call) => call[0] as string) + .find((line) => line.includes('john.doe')); + expect(matchLine).toBeDefined(); + expect(matchLine).not.toContain('\n'); + }); + }); }); From b3af428928d33bc7c441f63be7bdf4b5419e3c89 Mon Sep 17 00:00:00 2001 From: mimo-3 Date: Thu, 23 Jul 2026 22:20:07 +0900 Subject: [PATCH 23/28] =?UTF-8?q?=E3=83=A6=E3=83=BC=E3=82=B6=E3=83=BC?= =?UTF-8?q?=E4=B8=80=E8=A6=A7=E3=81=AEsimple=E8=A1=A8=E7=A4=BA=E3=82=821?= =?UTF-8?q?=E8=A1=8C=E3=81=AB=E7=95=B3=E3=82=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/commands/users.ts | 12 ++++++++---- tests/commands/users.test.ts | 24 ++++++++++++++++++++++++ 2 files changed, 32 insertions(+), 4 deletions(-) diff --git a/src/commands/users.ts b/src/commands/users.ts index 6531593..c912bfe 100644 --- a/src/commands/users.ts +++ b/src/commands/users.ts @@ -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[]) { @@ -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}` ); } } diff --git a/tests/commands/users.test.ts b/tests/commands/users.test.ts index 0bdb7dc..e81c70d 100644 --- a/tests/commands/users.test.ts +++ b/tests/commands/users.test.ts @@ -102,6 +102,30 @@ describe('users command', () => { expect(mockConsole.logSpy).toHaveBeenCalledWith(expect.stringContaining('alice')); }); + it('should collapse newlines in simple format fields', async () => { + vi.mocked(mockConfigManager.getConfig).mockResolvedValue({ + token: 'test-token', + updatedAt: new Date().toISOString(), + }); + vi.mocked(mockSlackClient.listUsers).mockResolvedValue([ + { + id: 'U123', + name: 'alice\nfake-row', + real_name: 'Alice\tSmith', + profile: { email: 'alice@example.com', display_name: 'alice' }, + is_bot: false, + deleted: false, + }, + ]); + + await program.parseAsync(['node', 'slack-cli', 'users', 'list', '--format', 'simple']); + + const output = mockConsole.logSpy.mock.calls[0][0] as string; + expect(output).not.toContain('\n'); + expect(output).toContain('alice fake-row'); + expect(output).toContain('Alice Smith'); + }); + it('should show message when no users found', async () => { vi.mocked(mockConfigManager.getConfig).mockResolvedValue({ token: 'test-token', From 7b7b839f23810a08c006559b365fc4cec42eb220 Mon Sep 17 00:00:00 2001 From: mimo-3 Date: Thu, 23 Jul 2026 22:20:07 +0900 Subject: [PATCH 24/28] =?UTF-8?q?=E4=B8=A6=E5=88=97=E5=BA=A6=E3=83=86?= =?UTF-8?q?=E3=82=B9=E3=83=88=E3=82=92=E5=AE=9A=E6=95=B0=E5=8F=82=E7=85=A7?= =?UTF-8?q?=E3=81=AB=E3=81=99=E3=82=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tests/utils/slack-operations/user-operations.test.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/tests/utils/slack-operations/user-operations.test.ts b/tests/utils/slack-operations/user-operations.test.ts index 6fda3c5..c29de63 100644 --- a/tests/utils/slack-operations/user-operations.test.ts +++ b/tests/utils/slack-operations/user-operations.test.ts @@ -1,4 +1,5 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { RATE_LIMIT } from '../../../src/utils/constants'; import { UserOperations } from '../../../src/utils/slack-operations/user-operations'; vi.mock('@slack/web-api', () => ({ @@ -198,7 +199,8 @@ describe('UserOperations', () => { await Promise.all(userIds.map((userId) => userOps.getUserInfo(userId))); expect(mockClient.users.info).toHaveBeenCalledTimes(10); - expect(maxInFlight).toBeLessThanOrEqual(3); + expect(maxInFlight).toBeGreaterThan(1); + expect(maxInFlight).toBeLessThanOrEqual(RATE_LIMIT.CONCURRENT_REQUESTS); }); }); From ab9e0f27c1ad9f46e49596fcf681707ff7d646e1 Mon Sep 17 00:00:00 2001 From: mimo-3 Date: Thu, 23 Jul 2026 22:27:40 +0900 Subject: [PATCH 25/28] =?UTF-8?q?=E3=83=81=E3=83=A3=E3=83=B3=E3=83=8D?= =?UTF-8?q?=E3=83=AB=E4=B8=80=E8=A6=A7=E8=A1=A8=E7=A4=BA=E3=82=821?= =?UTF-8?q?=E8=A1=8C=E3=82=B5=E3=83=8B=E3=82=BF=E3=82=A4=E3=82=BA=E3=81=AB?= =?UTF-8?q?=E6=8F=83=E3=81=88=E3=82=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../formatters/channels-list-formatters.ts | 8 +-- .../channels-list-formatters.test.ts | 49 +++++++++++++++++++ 2 files changed, 53 insertions(+), 4 deletions(-) create mode 100644 tests/utils/formatters/channels-list-formatters.test.ts diff --git a/src/utils/formatters/channels-list-formatters.ts b/src/utils/formatters/channels-list-formatters.ts index 90c63d4..e0e7279 100644 --- a/src/utils/formatters/channels-list-formatters.ts +++ b/src/utils/formatters/channels-list-formatters.ts @@ -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 { @@ -14,8 +14,8 @@ class ChannelsTableFormatter extends AbstractFormatter { - 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); @@ -29,7 +29,7 @@ class ChannelsTableFormatter extends AbstractFormatter { format({ channels }: ChannelsListFormatterOptions): void { - channels.forEach((channel) => console.log(sanitizeTerminalText(channel.name))); + channels.forEach((channel) => console.log(sanitizeSingleLineText(channel.name))); } } diff --git a/tests/utils/formatters/channels-list-formatters.test.ts b/tests/utils/formatters/channels-list-formatters.test.ts new file mode 100644 index 0000000..62d7055 --- /dev/null +++ b/tests/utils/formatters/channels-list-formatters.test.ts @@ -0,0 +1,49 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { createChannelsListFormatter } from '../../../src/utils/formatters/channels-list-formatters'; + +describe('channels list formatters', () => { + let logSpy: ReturnType; + + beforeEach(() => { + logSpy = vi.spyOn(console, 'log').mockImplementation(() => undefined); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + const channelsWithNewline = [ + { + id: 'C123', + name: 'general\nfake-row', + type: 'public', + members: 10, + created: '2026-01-01', + purpose: 'first line\nsecond line', + is_archived: false, + }, + ]; + + describe('single-line output hardening', () => { + it('should collapse newlines in table format fields', () => { + const formatter = createChannelsListFormatter('table'); + formatter.format({ channels: channelsWithNewline }); + + const dataRow = logSpy.mock.calls + .map((call) => call[0] as string) + .find((line) => line.includes('general')); + expect(dataRow).toBeDefined(); + expect(dataRow).not.toContain('\n'); + expect(dataRow).toContain('general fake-row'); + }); + + it('should collapse newlines in simple format channel names', () => { + const formatter = createChannelsListFormatter('simple'); + formatter.format({ channels: channelsWithNewline }); + + const output = logSpy.mock.calls[0][0] as string; + expect(output).not.toContain('\n'); + expect(output).toContain('general fake-row'); + }); + }); +}); From 64c968b53fdaeae60a15fb04fec40b0c0c0a00b4 Mon Sep 17 00:00:00 2001 From: mimo-3 Date: Thu, 23 Jul 2026 22:27:40 +0900 Subject: [PATCH 26/28] =?UTF-8?q?=E6=9C=AA=E8=AA=AD=E3=83=81=E3=83=A3?= =?UTF-8?q?=E3=83=B3=E3=83=8D=E3=83=AB=E8=A1=A8=E7=A4=BA=E3=82=821?= =?UTF-8?q?=E8=A1=8C=E3=82=B5=E3=83=8B=E3=82=BF=E3=82=A4=E3=82=BA=E3=81=AB?= =?UTF-8?q?=E6=8F=83=E3=81=88=E3=82=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/utils/formatters/channel-formatters.ts | 14 +++-- .../formatters/channel-formatters.test.ts | 61 +++++++++++++++++++ 2 files changed, 69 insertions(+), 6 deletions(-) create mode 100644 tests/utils/formatters/channel-formatters.test.ts diff --git a/src/utils/formatters/channel-formatters.ts b/src/utils/formatters/channel-formatters.ts index a68aad5..5d6ef43 100644 --- a/src/utils/formatters/channel-formatters.ts +++ b/src/utils/formatters/channel-formatters.ts @@ -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; @@ -15,8 +20,7 @@ class ChannelTableFormatter extends AbstractFormatter { 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}`); @@ -27,8 +31,7 @@ class ChannelTableFormatter extends AbstractFormatter { class ChannelSimpleFormatter extends AbstractFormatter { 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})`); }); } } @@ -49,8 +52,7 @@ class ChannelCountFormatter extends AbstractFormatter { 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`)); } diff --git a/tests/utils/formatters/channel-formatters.test.ts b/tests/utils/formatters/channel-formatters.test.ts new file mode 100644 index 0000000..9b1fff1 --- /dev/null +++ b/tests/utils/formatters/channel-formatters.test.ts @@ -0,0 +1,61 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { Channel } from '../../../src/types/slack'; +import { createChannelFormatter } from '../../../src/utils/formatters/channel-formatters'; + +describe('channel formatters', () => { + let logSpy: ReturnType; + + beforeEach(() => { + logSpy = vi.spyOn(console, 'log').mockImplementation(() => undefined); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + const channelsWithNewline: Channel[] = [ + { + id: 'C123', + name: 'general', + display_name: 'general\nfake-row', + is_private: false, + created: 1709290800, + unread_count: 2, + last_read: '1709290800.000100', + }, + ]; + + describe('single-line output hardening', () => { + it('should collapse newlines in table format channel names', () => { + const formatter = createChannelFormatter('table', false); + formatter.format({ channels: channelsWithNewline }); + + const dataRow = logSpy.mock.calls + .map((call) => call[0] as string) + .find((line) => line.includes('general')); + expect(dataRow).toBeDefined(); + expect(dataRow).not.toContain('\n'); + expect(dataRow).toContain('general fake-row'); + }); + + it('should collapse newlines in simple format channel names', () => { + const formatter = createChannelFormatter('simple', false); + formatter.format({ channels: channelsWithNewline }); + + const output = logSpy.mock.calls[0][0] as string; + expect(output).not.toContain('\n'); + expect(output).toContain('general fake-row'); + }); + + it('should collapse newlines in count format channel names', () => { + const formatter = createChannelFormatter('count', true); + formatter.format({ channels: channelsWithNewline }); + + const countRow = logSpy.mock.calls + .map((call) => call[0] as string) + .find((line) => line.includes('general')); + expect(countRow).toBeDefined(); + expect(countRow).not.toContain('\n'); + }); + }); +}); From 6881348429c0d6119df49bba9c981b282cabc958 Mon Sep 17 00:00:00 2001 From: mimo-3 Date: Thu, 23 Jul 2026 22:27:40 +0900 Subject: [PATCH 27/28] =?UTF-8?q?presence=E8=A1=A8=E7=A4=BA=E3=82=821?= =?UTF-8?q?=E8=A1=8C=E3=81=AB=E7=95=B3=E3=82=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/commands/users.ts | 6 +++--- tests/commands/users.test.ts | 25 +++++++++++++++++++++++++ 2 files changed, 28 insertions(+), 3 deletions(-) diff --git a/src/commands/users.ts b/src/commands/users.ts index c912bfe..9e2f561 100644 --- a/src/commands/users.ts +++ b/src/commands/users.ts @@ -65,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 { diff --git a/tests/commands/users.test.ts b/tests/commands/users.test.ts index e81c70d..b736480 100644 --- a/tests/commands/users.test.ts +++ b/tests/commands/users.test.ts @@ -399,6 +399,31 @@ describe('users command', () => { expect(mockConsole.logSpy).toHaveBeenCalledWith(expect.stringContaining('active')); }); + it('should collapse newlines in simple presence output', async () => { + vi.mocked(mockConfigManager.getConfig).mockResolvedValue({ + token: 'test-token', + updatedAt: new Date().toISOString(), + }); + vi.mocked(mockSlackClient.getUserPresence).mockResolvedValue({ + presence: 'active\nfake-row', + }); + + await program.parseAsync([ + 'node', + 'slack-cli', + 'users', + 'presence', + '--id', + 'U123', + '--format', + 'simple', + ]); + + const output = mockConsole.logSpy.mock.calls[0][0] as string; + expect(output).not.toContain('\n'); + expect(output).toContain('active fake-row'); + }); + it('should display away presence in table format', async () => { vi.mocked(mockConfigManager.getConfig).mockResolvedValue({ token: 'test-token', From 6ee9c74f0723c0c7d832ae02527a1a99cfafddd9 Mon Sep 17 00:00:00 2001 From: mimo-3 Date: Thu, 23 Jul 2026 22:29:07 +0900 Subject: [PATCH 28/28] =?UTF-8?q?=E7=A9=BA=E3=83=96=E3=83=AD=E3=83=83?= =?UTF-8?q?=E3=82=AF=E3=81=AElint=E6=8C=87=E6=91=98=E3=82=92=E4=BF=AE?= =?UTF-8?q?=E6=AD=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tests/utils/command-wrapper.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/utils/command-wrapper.test.ts b/tests/utils/command-wrapper.test.ts index 1ad2fc4..5495956 100644 --- a/tests/utils/command-wrapper.test.ts +++ b/tests/utils/command-wrapper.test.ts @@ -6,7 +6,7 @@ describe('wrapCommand', () => { let exitSpy: ReturnType; beforeEach(() => { - errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + errorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined); exitSpy = vi.spyOn(process, 'exit').mockImplementation(() => undefined as never); });