@urugus/slack-cli 0.2.2 → 0.2.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (59) hide show
  1. package/.claude/settings.local.json +2 -1
  2. package/dist/commands/channels.d.ts.map +1 -1
  3. package/dist/commands/channels.js +7 -4
  4. package/dist/commands/channels.js.map +1 -1
  5. package/dist/commands/unread.d.ts.map +1 -1
  6. package/dist/commands/unread.js +17 -21
  7. package/dist/commands/unread.js.map +1 -1
  8. package/dist/utils/config/config-file-manager.d.ts +13 -0
  9. package/dist/utils/config/config-file-manager.d.ts.map +1 -0
  10. package/dist/utils/config/config-file-manager.js +85 -0
  11. package/dist/utils/config/config-file-manager.js.map +1 -0
  12. package/dist/utils/config/profile-manager.d.ts +16 -0
  13. package/dist/utils/config/profile-manager.d.ts.map +1 -0
  14. package/dist/utils/config/profile-manager.js +64 -0
  15. package/dist/utils/config/profile-manager.js.map +1 -0
  16. package/dist/utils/config/token-crypto-service.d.ts +11 -0
  17. package/dist/utils/config/token-crypto-service.d.ts.map +1 -0
  18. package/dist/utils/config/token-crypto-service.js +111 -0
  19. package/dist/utils/config/token-crypto-service.js.map +1 -0
  20. package/dist/utils/formatters/base-formatter.d.ts +23 -0
  21. package/dist/utils/formatters/base-formatter.d.ts.map +1 -0
  22. package/dist/utils/formatters/base-formatter.js +26 -0
  23. package/dist/utils/formatters/base-formatter.js.map +1 -0
  24. package/dist/utils/formatters/channel-formatters.d.ts +4 -13
  25. package/dist/utils/formatters/channel-formatters.d.ts.map +1 -1
  26. package/dist/utils/formatters/channel-formatters.js +18 -26
  27. package/dist/utils/formatters/channel-formatters.js.map +1 -1
  28. package/dist/utils/formatters/channels-list-formatters.d.ts +3 -10
  29. package/dist/utils/formatters/channels-list-formatters.d.ts.map +1 -1
  30. package/dist/utils/formatters/channels-list-formatters.js +15 -22
  31. package/dist/utils/formatters/channels-list-formatters.js.map +1 -1
  32. package/dist/utils/formatters/message-formatters.d.ts +9 -0
  33. package/dist/utils/formatters/message-formatters.d.ts.map +1 -0
  34. package/dist/utils/formatters/message-formatters.js +72 -0
  35. package/dist/utils/formatters/message-formatters.js.map +1 -0
  36. package/dist/utils/option-parsers.d.ts +47 -0
  37. package/dist/utils/option-parsers.d.ts.map +1 -0
  38. package/dist/utils/option-parsers.js +75 -0
  39. package/dist/utils/option-parsers.js.map +1 -0
  40. package/dist/utils/profile-config-refactored.d.ts +20 -0
  41. package/dist/utils/profile-config-refactored.d.ts.map +1 -0
  42. package/dist/utils/profile-config-refactored.js +174 -0
  43. package/dist/utils/profile-config-refactored.js.map +1 -0
  44. package/package.json +1 -1
  45. package/src/commands/channels.ts +7 -4
  46. package/src/commands/unread.ts +18 -21
  47. package/src/utils/config/config-file-manager.ts +56 -0
  48. package/src/utils/config/profile-manager.ts +79 -0
  49. package/src/utils/config/token-crypto-service.ts +80 -0
  50. package/src/utils/formatters/base-formatter.ts +34 -0
  51. package/src/utils/formatters/channel-formatters.ts +25 -23
  52. package/src/utils/formatters/channels-list-formatters.ts +27 -31
  53. package/src/utils/formatters/message-formatters.ts +85 -0
  54. package/src/utils/option-parsers.ts +100 -0
  55. package/src/utils/profile-config-refactored.ts +161 -0
  56. package/tests/commands/unread.test.ts +112 -0
  57. package/tests/utils/config/config-file-manager.test.ts +118 -0
  58. package/tests/utils/config/profile-manager.test.ts +266 -0
  59. package/tests/utils/config/token-crypto-service.test.ts +98 -0
@@ -0,0 +1,100 @@
1
+ /**
2
+ * Common option parsing utilities to reduce duplication
3
+ */
4
+
5
+ /**
6
+ * Parse format option with default value
7
+ */
8
+ export function parseFormat(format?: string, defaultFormat = 'table'): string {
9
+ return format || defaultFormat;
10
+ }
11
+
12
+ /**
13
+ * Parse limit option with default value
14
+ */
15
+ export function parseLimit(limit: string | undefined, defaultLimit: number): number {
16
+ return parseInt(limit || defaultLimit.toString(), 10);
17
+ }
18
+
19
+ /**
20
+ * Parse boolean option with default value
21
+ */
22
+ export function parseBoolean(value?: boolean, defaultValue = false): boolean {
23
+ return value !== undefined ? value : defaultValue;
24
+ }
25
+
26
+ /**
27
+ * Parse count option with default value
28
+ */
29
+ export function parseCount(
30
+ count: string | undefined,
31
+ defaultCount: number,
32
+ min?: number,
33
+ max?: number
34
+ ): number {
35
+ const parsed = parseInt(count || defaultCount.toString(), 10);
36
+
37
+ if (isNaN(parsed)) {
38
+ return defaultCount;
39
+ }
40
+
41
+ if (min !== undefined && parsed < min) {
42
+ return min;
43
+ }
44
+
45
+ if (max !== undefined && parsed > max) {
46
+ return max;
47
+ }
48
+
49
+ return parsed;
50
+ }
51
+
52
+ /**
53
+ * Parse profile option
54
+ */
55
+ export function parseProfile(profile?: string): string | undefined {
56
+ return profile;
57
+ }
58
+
59
+ /**
60
+ * Common option defaults
61
+ */
62
+ export const OPTION_DEFAULTS = {
63
+ format: 'table',
64
+ limit: 100,
65
+ countOnly: false,
66
+ includeArchived: false,
67
+ } as const;
68
+
69
+ /**
70
+ * Parse common list options
71
+ */
72
+ export interface ListOptions {
73
+ format?: string;
74
+ limit?: string;
75
+ countOnly?: boolean;
76
+ }
77
+
78
+ export interface ParsedListOptions {
79
+ format: string;
80
+ limit: number;
81
+ countOnly: boolean;
82
+ }
83
+
84
+ export function parseListOptions(
85
+ options: ListOptions,
86
+ defaults?: Partial<ParsedListOptions>
87
+ ): ParsedListOptions {
88
+ const mergedDefaults = {
89
+ format: OPTION_DEFAULTS.format,
90
+ limit: OPTION_DEFAULTS.limit,
91
+ countOnly: OPTION_DEFAULTS.countOnly,
92
+ ...defaults,
93
+ };
94
+
95
+ return {
96
+ format: parseFormat(options.format, mergedDefaults.format),
97
+ limit: parseLimit(options.limit, mergedDefaults.limit),
98
+ countOnly: parseBoolean(options.countOnly, mergedDefaults.countOnly),
99
+ };
100
+ }
@@ -0,0 +1,161 @@
1
+ import type { Config, ConfigOptions, Profile } from '../types/config';
2
+ import { TOKEN_MASK_LENGTH, TOKEN_MIN_LENGTH, DEFAULT_PROFILE_NAME } from './constants';
3
+ import { ConfigFileManager } from './config/config-file-manager';
4
+ import { TokenCryptoService } from './config/token-crypto-service';
5
+ import { ProfileManager } from './config/profile-manager';
6
+ import * as fs from 'fs/promises';
7
+
8
+ export class ProfileConfigManager {
9
+ private fileManager: ConfigFileManager;
10
+ private cryptoService: TokenCryptoService;
11
+ private profileManager: ProfileManager;
12
+
13
+ constructor(_options: ConfigOptions = {}) {
14
+ // Note: ConfigFileManager currently doesn't support custom configDir
15
+ // This would need to be added if required
16
+ this.fileManager = new ConfigFileManager();
17
+ this.cryptoService = new TokenCryptoService();
18
+ this.profileManager = new ProfileManager(this.fileManager, this.cryptoService);
19
+ }
20
+
21
+ async setToken(token: string, profile?: string): Promise<void> {
22
+ const profileName = profile || (await this.profileManager.getCurrentProfile());
23
+ const config: Config = {
24
+ token,
25
+ updatedAt: new Date().toISOString(),
26
+ };
27
+
28
+ await this.profileManager.setProfile(profileName, config);
29
+
30
+ // Set as default profile if it's the first one or explicitly setting default
31
+ const profiles = await this.profileManager.listProfiles();
32
+ if (profiles.length === 1 || profileName === DEFAULT_PROFILE_NAME) {
33
+ await this.profileManager.setCurrentProfile(profileName);
34
+ }
35
+ }
36
+
37
+ async getConfig(profile?: string): Promise<Config | null> {
38
+ const profileName = profile || (await this.profileManager.getCurrentProfile());
39
+
40
+ try {
41
+ return await this.profileManager.getProfile(profileName);
42
+ } catch (error) {
43
+ // Return null if profile not found
44
+ if (error instanceof Error && error.message.includes('not found')) {
45
+ return null;
46
+ }
47
+ throw error;
48
+ }
49
+ }
50
+
51
+ async listProfiles(): Promise<Profile[]> {
52
+ const profileNames = await this.profileManager.listProfiles();
53
+ const currentProfile = await this.profileManager.getCurrentProfile();
54
+
55
+ const profiles: Profile[] = [];
56
+ for (const name of profileNames) {
57
+ const config = await this.profileManager.getProfile(name);
58
+ profiles.push({
59
+ name,
60
+ config,
61
+ isDefault: name === currentProfile,
62
+ });
63
+ }
64
+
65
+ return profiles;
66
+ }
67
+
68
+ async useProfile(profile: string): Promise<void> {
69
+ const exists = await this.profileManager.profileExists(profile);
70
+ if (!exists) {
71
+ throw new Error(`Profile "${profile}" does not exist`);
72
+ }
73
+
74
+ await this.profileManager.setCurrentProfile(profile);
75
+ }
76
+
77
+ async getCurrentProfile(): Promise<string> {
78
+ return await this.profileManager.getCurrentProfile();
79
+ }
80
+
81
+ async clearConfig(profile?: string): Promise<void> {
82
+ const profileName = profile || (await this.profileManager.getCurrentProfile());
83
+
84
+ try {
85
+ await this.profileManager.deleteProfile(profileName);
86
+ } catch (error) {
87
+ // If profile doesn't exist, do nothing
88
+ if (error instanceof Error && error.message.includes('not found')) {
89
+ return;
90
+ }
91
+ throw error;
92
+ }
93
+
94
+ // If we deleted the current profile, set a new default
95
+ const currentProfile = await this.profileManager.getCurrentProfile();
96
+ if (currentProfile === profileName) {
97
+ const remainingProfiles = await this.profileManager.listProfiles();
98
+ if (remainingProfiles.length > 0) {
99
+ await this.profileManager.setCurrentProfile(remainingProfiles[0]);
100
+ } else {
101
+ // No profiles left, delete the config file
102
+ try {
103
+ await fs.unlink(this.fileManager.getConfigPath());
104
+ } catch (error: unknown) {
105
+ if (error && typeof error === 'object' && 'code' in error && error.code !== 'ENOENT') {
106
+ throw error;
107
+ }
108
+ }
109
+ }
110
+ }
111
+ }
112
+
113
+ maskToken(token: string): string {
114
+ if (token.length <= TOKEN_MIN_LENGTH) {
115
+ return '****';
116
+ }
117
+
118
+ const prefix = token.substring(0, TOKEN_MASK_LENGTH);
119
+ const suffix = token.substring(token.length - TOKEN_MASK_LENGTH);
120
+
121
+ return `${prefix}-****-****-${suffix}`;
122
+ }
123
+
124
+ // Migration support - to be called separately if needed
125
+ async migrateIfNeeded(): Promise<void> {
126
+ const data = await this.fileManager.read();
127
+
128
+ // Check if migration is needed (old format detection)
129
+ const anyData = data as unknown as Record<string, unknown>;
130
+ if (anyData.token && !anyData.profiles) {
131
+ // Old format detected, migrate
132
+ const oldConfig: Config = {
133
+ token: anyData.token as string,
134
+ updatedAt: (anyData.updatedAt as string) || new Date().toISOString(),
135
+ };
136
+
137
+ // Create new format
138
+ const newData = {
139
+ profiles: { [DEFAULT_PROFILE_NAME]: oldConfig },
140
+ currentProfile: DEFAULT_PROFILE_NAME,
141
+ };
142
+
143
+ await this.fileManager.write(newData);
144
+
145
+ // Re-encrypt token using new service
146
+ await this.setToken(oldConfig.token, DEFAULT_PROFILE_NAME);
147
+ }
148
+ }
149
+ }
150
+
151
+ // Export a simplified version for backward compatibility
152
+ export const profileConfig = {
153
+ getCurrentProfile: (): string => {
154
+ return DEFAULT_PROFILE_NAME;
155
+ },
156
+ getToken: (_profile?: string): string | undefined => {
157
+ // This is a simplified version for testing
158
+ // In real usage, it would need to be async
159
+ return undefined;
160
+ },
161
+ };
@@ -174,6 +174,118 @@ describe('unread command', () => {
174
174
  expect(mockConsole.logSpy).toHaveBeenCalledWith(expect.stringContaining('Hello world'));
175
175
  expect(mockConsole.logSpy).toHaveBeenCalledWith(expect.stringContaining('Test message'));
176
176
  });
177
+
178
+ it('should display channel unread in JSON format when specified', async () => {
179
+ vi.mocked(mockConfigManager.getConfig).mockResolvedValue({
180
+ token: 'test-token',
181
+ updatedAt: new Date().toISOString()
182
+ });
183
+ vi.mocked(mockSlackClient.getChannelUnread).mockResolvedValue({
184
+ channel: mockChannelsWithUnread[0],
185
+ messages: mockUnreadMessages,
186
+ users: new Map([
187
+ ['U123', 'john.doe'],
188
+ ['U456', 'jane.smith']
189
+ ])
190
+ });
191
+
192
+ await program.parseAsync(['node', 'slack-cli', 'unread', '--channel', 'general', '--format', 'json']);
193
+
194
+ expect(mockSlackClient.getChannelUnread).toHaveBeenCalledWith('general');
195
+
196
+ const logCall = mockConsole.logSpy.mock.calls[0][0];
197
+ const parsed = JSON.parse(logCall);
198
+
199
+ expect(parsed).toEqual({
200
+ channel: '#general',
201
+ channelId: 'C123',
202
+ unreadCount: 5,
203
+ messages: [
204
+ {
205
+ timestamp: expect.any(String),
206
+ author: 'john.doe',
207
+ text: 'Hello world'
208
+ },
209
+ {
210
+ timestamp: expect.any(String),
211
+ author: 'jane.smith',
212
+ text: 'Test message'
213
+ }
214
+ ]
215
+ });
216
+ });
217
+
218
+ it('should display channel unread in simple format when specified', async () => {
219
+ vi.mocked(mockConfigManager.getConfig).mockResolvedValue({
220
+ token: 'test-token',
221
+ updatedAt: new Date().toISOString()
222
+ });
223
+ vi.mocked(mockSlackClient.getChannelUnread).mockResolvedValue({
224
+ channel: mockChannelsWithUnread[0],
225
+ messages: mockUnreadMessages,
226
+ users: new Map([
227
+ ['U123', 'john.doe'],
228
+ ['U456', 'jane.smith']
229
+ ])
230
+ });
231
+
232
+ await program.parseAsync(['node', 'slack-cli', 'unread', '--channel', 'general', '--format', 'simple']);
233
+
234
+ expect(mockSlackClient.getChannelUnread).toHaveBeenCalledWith('general');
235
+ expect(mockConsole.logSpy).toHaveBeenCalledWith('#general (5)');
236
+ expect(mockConsole.logSpy).toHaveBeenCalledWith(expect.stringMatching(/^\[.*\] john\.doe: Hello world$/));
237
+ expect(mockConsole.logSpy).toHaveBeenCalledWith(expect.stringMatching(/^\[.*\] jane\.smith: Test message$/));
238
+ });
239
+
240
+ it('should show only count for channel when --count-only is specified', async () => {
241
+ vi.mocked(mockConfigManager.getConfig).mockResolvedValue({
242
+ token: 'test-token',
243
+ updatedAt: new Date().toISOString()
244
+ });
245
+ vi.mocked(mockSlackClient.getChannelUnread).mockResolvedValue({
246
+ channel: mockChannelsWithUnread[0],
247
+ messages: mockUnreadMessages,
248
+ users: new Map([
249
+ ['U123', 'john.doe'],
250
+ ['U456', 'jane.smith']
251
+ ])
252
+ });
253
+
254
+ await program.parseAsync(['node', 'slack-cli', 'unread', '--channel', 'general', '--count-only']);
255
+
256
+ expect(mockSlackClient.getChannelUnread).toHaveBeenCalledWith('general');
257
+ expect(mockConsole.logSpy).toHaveBeenCalledWith(chalk.bold(`#general: 5 unread messages`));
258
+ expect(mockConsole.logSpy).not.toHaveBeenCalledWith(expect.stringContaining('Hello world'));
259
+ expect(mockConsole.logSpy).not.toHaveBeenCalledWith(expect.stringContaining('Test message'));
260
+ });
261
+
262
+ it('should show count only in JSON format when both --count-only and --format json are specified', async () => {
263
+ vi.mocked(mockConfigManager.getConfig).mockResolvedValue({
264
+ token: 'test-token',
265
+ updatedAt: new Date().toISOString()
266
+ });
267
+ vi.mocked(mockSlackClient.getChannelUnread).mockResolvedValue({
268
+ channel: mockChannelsWithUnread[0],
269
+ messages: mockUnreadMessages,
270
+ users: new Map([
271
+ ['U123', 'john.doe'],
272
+ ['U456', 'jane.smith']
273
+ ])
274
+ });
275
+
276
+ await program.parseAsync(['node', 'slack-cli', 'unread', '--channel', 'general', '--count-only', '--format', 'json']);
277
+
278
+ expect(mockSlackClient.getChannelUnread).toHaveBeenCalledWith('general');
279
+
280
+ const logCall = mockConsole.logSpy.mock.calls[0][0];
281
+ const parsed = JSON.parse(logCall);
282
+
283
+ expect(parsed).toEqual({
284
+ channel: '#general',
285
+ channelId: 'C123',
286
+ unreadCount: 5
287
+ });
288
+ });
177
289
  });
178
290
 
179
291
  describe('limit option', () => {
@@ -0,0 +1,118 @@
1
+ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
2
+ import * as fs from 'fs/promises';
3
+ import * as os from 'os';
4
+ import * as path from 'path';
5
+ import { ConfigFileManager } from '../../../src/utils/config/config-file-manager';
6
+
7
+ vi.mock('fs/promises');
8
+ vi.mock('os');
9
+
10
+ describe('ConfigFileManager', () => {
11
+ let manager: ConfigFileManager;
12
+ const mockHomeDir = '/home/user';
13
+ const mockConfigPath = path.join(mockHomeDir, '.slack-cli', 'config.json');
14
+
15
+ beforeEach(() => {
16
+ vi.mocked(os.homedir).mockReturnValue(mockHomeDir);
17
+ manager = new ConfigFileManager();
18
+ });
19
+
20
+ afterEach(() => {
21
+ vi.clearAllMocks();
22
+ });
23
+
24
+ describe('read', () => {
25
+ it('should read and parse config file when it exists', async () => {
26
+ const mockConfig = {
27
+ profiles: { default: { token: 'encrypted-token' } },
28
+ currentProfile: 'default'
29
+ };
30
+ vi.mocked(fs.access).mockResolvedValueOnce(undefined);
31
+ vi.mocked(fs.readFile).mockResolvedValueOnce(JSON.stringify(mockConfig));
32
+
33
+ const result = await manager.read();
34
+
35
+ expect(result).toEqual(mockConfig);
36
+ expect(fs.readFile).toHaveBeenCalledWith(mockConfigPath, 'utf-8');
37
+ });
38
+
39
+ it('should return default config when file does not exist', async () => {
40
+ vi.mocked(fs.access).mockRejectedValueOnce(new Error('ENOENT'));
41
+
42
+ const result = await manager.read();
43
+
44
+ expect(result).toEqual({
45
+ profiles: {},
46
+ currentProfile: 'default'
47
+ });
48
+ });
49
+
50
+ it('should throw error for invalid JSON', async () => {
51
+ vi.mocked(fs.access).mockResolvedValueOnce(undefined);
52
+ vi.mocked(fs.readFile).mockResolvedValueOnce('invalid json');
53
+
54
+ await expect(manager.read()).rejects.toThrow('Invalid configuration file');
55
+ });
56
+ });
57
+
58
+ describe('write', () => {
59
+ it('should create directory and write config file', async () => {
60
+ const mockConfig = {
61
+ profiles: { default: { token: 'encrypted-token' } },
62
+ currentProfile: 'default'
63
+ };
64
+ vi.mocked(fs.mkdir).mockResolvedValueOnce(undefined as any);
65
+ vi.mocked(fs.writeFile).mockResolvedValueOnce(undefined);
66
+
67
+ await manager.write(mockConfig);
68
+
69
+ expect(fs.mkdir).toHaveBeenCalledWith(
70
+ path.dirname(mockConfigPath),
71
+ { recursive: true }
72
+ );
73
+ expect(fs.writeFile).toHaveBeenCalledWith(
74
+ mockConfigPath,
75
+ JSON.stringify(mockConfig, null, 2),
76
+ 'utf-8'
77
+ );
78
+ });
79
+
80
+ it('should handle write errors', async () => {
81
+ const mockConfig = {
82
+ profiles: {},
83
+ currentProfile: 'default'
84
+ };
85
+ vi.mocked(fs.mkdir).mockResolvedValueOnce(undefined as any);
86
+ vi.mocked(fs.writeFile).mockRejectedValueOnce(new Error('Write failed'));
87
+
88
+ await expect(manager.write(mockConfig)).rejects.toThrow('Write failed');
89
+ });
90
+ });
91
+
92
+ describe('exists', () => {
93
+ it('should return true when config file exists', async () => {
94
+ vi.mocked(fs.access).mockResolvedValueOnce(undefined);
95
+
96
+ const result = await manager.exists();
97
+
98
+ expect(result).toBe(true);
99
+ expect(fs.access).toHaveBeenCalledWith(mockConfigPath);
100
+ });
101
+
102
+ it('should return false when config file does not exist', async () => {
103
+ vi.mocked(fs.access).mockRejectedValueOnce(new Error('ENOENT'));
104
+
105
+ const result = await manager.exists();
106
+
107
+ expect(result).toBe(false);
108
+ });
109
+ });
110
+
111
+ describe('getConfigPath', () => {
112
+ it('should return the correct config path', () => {
113
+ const result = manager.getConfigPath();
114
+
115
+ expect(result).toBe(mockConfigPath);
116
+ });
117
+ });
118
+ });