@urugus/slack-cli 0.2.5 → 0.2.7

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 (55) hide show
  1. package/.claude/settings.local.json +2 -1
  2. package/README.md +7 -0
  3. package/dist/commands/history.d.ts.map +1 -1
  4. package/dist/commands/history.js +7 -7
  5. package/dist/commands/history.js.map +1 -1
  6. package/dist/commands/send.d.ts.map +1 -1
  7. package/dist/commands/send.js +7 -11
  8. package/dist/commands/send.js.map +1 -1
  9. package/dist/types/commands.d.ts +1 -0
  10. package/dist/types/commands.d.ts.map +1 -1
  11. package/dist/utils/constants.d.ts +1 -0
  12. package/dist/utils/constants.d.ts.map +1 -1
  13. package/dist/utils/constants.js +1 -0
  14. package/dist/utils/constants.js.map +1 -1
  15. package/dist/utils/profile-config.d.ts.map +1 -1
  16. package/dist/utils/profile-config.js +2 -6
  17. package/dist/utils/profile-config.js.map +1 -1
  18. package/dist/utils/slack-api-client.d.ts +1 -1
  19. package/dist/utils/slack-api-client.d.ts.map +1 -1
  20. package/dist/utils/slack-api-client.js +2 -2
  21. package/dist/utils/slack-api-client.js.map +1 -1
  22. package/dist/utils/slack-operations/channel-operations.d.ts +9 -0
  23. package/dist/utils/slack-operations/channel-operations.d.ts.map +1 -1
  24. package/dist/utils/slack-operations/channel-operations.js +77 -50
  25. package/dist/utils/slack-operations/channel-operations.js.map +1 -1
  26. package/dist/utils/slack-operations/message-operations.d.ts +1 -1
  27. package/dist/utils/slack-operations/message-operations.d.ts.map +1 -1
  28. package/dist/utils/slack-operations/message-operations.js +7 -3
  29. package/dist/utils/slack-operations/message-operations.js.map +1 -1
  30. package/dist/utils/token-utils.d.ts +7 -0
  31. package/dist/utils/token-utils.d.ts.map +1 -0
  32. package/dist/utils/token-utils.js +18 -0
  33. package/dist/utils/token-utils.js.map +1 -0
  34. package/dist/utils/validators.d.ts +79 -0
  35. package/dist/utils/validators.d.ts.map +1 -0
  36. package/dist/utils/validators.js +175 -0
  37. package/dist/utils/validators.js.map +1 -0
  38. package/package.json +1 -1
  39. package/src/commands/history.ts +17 -12
  40. package/src/commands/send.ts +10 -11
  41. package/src/types/commands.ts +1 -0
  42. package/src/utils/constants.ts +1 -0
  43. package/src/utils/profile-config.ts +3 -15
  44. package/src/utils/slack-api-client.ts +6 -2
  45. package/src/utils/slack-operations/channel-operations.ts +91 -54
  46. package/src/utils/slack-operations/message-operations.ts +14 -4
  47. package/src/utils/token-utils.ts +17 -0
  48. package/src/utils/validators.ts +212 -0
  49. package/tests/commands/send.test.ts +69 -2
  50. package/tests/utils/option-parsers.test.ts +173 -0
  51. package/tests/utils/profile-config.test.ts +282 -0
  52. package/tests/utils/slack-operations/channel-operations-refactored.test.ts +179 -0
  53. package/tests/utils/token-utils.test.ts +33 -0
  54. package/tests/utils/validators.test.ts +307 -0
  55. package/src/utils/profile-config-refactored.ts +0 -161
@@ -0,0 +1,307 @@
1
+ import { describe, it, expect, vi } from 'vitest';
2
+ import {
3
+ validateRequired,
4
+ validateMutuallyExclusive,
5
+ validateFormat,
6
+ validateRange,
7
+ validateDateFormat,
8
+ formatValidators,
9
+ createValidationHook,
10
+ optionValidators,
11
+ createOptionParser,
12
+ } from '../../src/utils/validators';
13
+ import { Command } from 'commander';
14
+
15
+ describe('validators', () => {
16
+ describe('validateRequired', () => {
17
+ it('should return null for valid values', () => {
18
+ expect(validateRequired('value', 'field')).toBeNull();
19
+ expect(validateRequired(123, 'field')).toBeNull();
20
+ expect(validateRequired(true, 'field')).toBeNull();
21
+ expect(validateRequired(0, 'field')).toBeNull();
22
+ expect(validateRequired(false, 'field')).toBeNull();
23
+ });
24
+
25
+ it('should return error for invalid values', () => {
26
+ expect(validateRequired(undefined, 'field')).toBe('field is required');
27
+ expect(validateRequired(null, 'field')).toBe('field is required');
28
+ expect(validateRequired('', 'field')).toBe('field is required');
29
+ });
30
+ });
31
+
32
+ describe('validateMutuallyExclusive', () => {
33
+ it('should return null when exactly one option is present', () => {
34
+ expect(validateMutuallyExclusive({ a: 'value' }, ['a', 'b'])).toBeNull();
35
+ expect(validateMutuallyExclusive({ b: 'value' }, ['a', 'b'])).toBeNull();
36
+ });
37
+
38
+ it('should return error when multiple options are present', () => {
39
+ const result = validateMutuallyExclusive({ a: 'value', b: 'value' }, ['a', 'b']);
40
+ expect(result).toBe('Cannot use both a and b');
41
+ });
42
+
43
+ it('should return error when no options are present', () => {
44
+ const result = validateMutuallyExclusive({}, ['a', 'b']);
45
+ expect(result).toBe('Must specify one of: a, b');
46
+ });
47
+
48
+ it('should use custom error message', () => {
49
+ const result = validateMutuallyExclusive(
50
+ { a: 'value', b: 'value' },
51
+ ['a', 'b'],
52
+ 'Custom error'
53
+ );
54
+ expect(result).toBe('Custom error');
55
+ });
56
+ });
57
+
58
+ describe('validateFormat', () => {
59
+ it('should return null for valid format', () => {
60
+ expect(validateFormat('123', /^\d+$/, 'error')).toBeNull();
61
+ expect(validateFormat('abc', /^[a-z]+$/, 'error')).toBeNull();
62
+ });
63
+
64
+ it('should return error for invalid format', () => {
65
+ expect(validateFormat('abc', /^\d+$/, 'Must be numbers')).toBe('Must be numbers');
66
+ expect(validateFormat('123', /^[a-z]+$/, 'Must be letters')).toBe('Must be letters');
67
+ });
68
+ });
69
+
70
+ describe('validateRange', () => {
71
+ it('should return null for values within range', () => {
72
+ expect(validateRange(5, 1, 10)).toBeNull();
73
+ expect(validateRange(1, 1, 10)).toBeNull();
74
+ expect(validateRange(10, 1, 10)).toBeNull();
75
+ });
76
+
77
+ it('should return error for values below minimum', () => {
78
+ expect(validateRange(0, 1, 10)).toBe('Value must be at least 1');
79
+ expect(validateRange(-5, 0, 10, 'Count')).toBe('Count must be at least 0');
80
+ });
81
+
82
+ it('should return error for values above maximum', () => {
83
+ expect(validateRange(11, 1, 10)).toBe('Value must be at most 10');
84
+ expect(validateRange(1001, 1, 1000, 'Limit')).toBe('Limit must be at most 1000');
85
+ });
86
+
87
+ it('should handle only min or only max', () => {
88
+ expect(validateRange(100, 50)).toBeNull();
89
+ expect(validateRange(100, undefined, 200)).toBeNull();
90
+ expect(validateRange(30, 50)).toBe('Value must be at least 50');
91
+ expect(validateRange(300, undefined, 200)).toBe('Value must be at most 200');
92
+ });
93
+ });
94
+
95
+ describe('validateDateFormat', () => {
96
+ it('should return null for valid dates', () => {
97
+ expect(validateDateFormat('2024-01-01')).toBeNull();
98
+ expect(validateDateFormat('2024-01-01 10:30:00')).toBeNull();
99
+ expect(validateDateFormat('January 1, 2024')).toBeNull();
100
+ });
101
+
102
+ it('should return error for invalid dates', () => {
103
+ expect(validateDateFormat('invalid')).toBe('Invalid date format');
104
+ expect(validateDateFormat('2024-13-01')).toBe('Invalid date format');
105
+ expect(validateDateFormat('')).toBe('Invalid date format');
106
+ });
107
+ });
108
+
109
+ describe('formatValidators', () => {
110
+ describe('threadTimestamp', () => {
111
+ it('should validate correct timestamp format', () => {
112
+ expect(formatValidators.threadTimestamp('1234567890.123456')).toBeNull();
113
+ expect(formatValidators.threadTimestamp('9999999999.999999')).toBeNull();
114
+ });
115
+
116
+ it('should reject invalid timestamp format', () => {
117
+ expect(formatValidators.threadTimestamp('123')).toBe('Invalid thread timestamp format');
118
+ expect(formatValidators.threadTimestamp('1234567890')).toBe('Invalid thread timestamp format');
119
+ expect(formatValidators.threadTimestamp('1234567890.12')).toBe('Invalid thread timestamp format');
120
+ });
121
+ });
122
+
123
+ describe('channelId', () => {
124
+ it('should validate correct channel ID format', () => {
125
+ expect(formatValidators.channelId('C1234567890')).toBeNull();
126
+ expect(formatValidators.channelId('D1234567890')).toBeNull();
127
+ expect(formatValidators.channelId('G1234567890')).toBeNull();
128
+ expect(formatValidators.channelId('C12345678901234')).toBeNull();
129
+ });
130
+
131
+ it('should reject invalid channel ID format', () => {
132
+ expect(formatValidators.channelId('A1234567890')).toBe('Invalid channel ID format');
133
+ expect(formatValidators.channelId('C123')).toBe('Invalid channel ID format');
134
+ expect(formatValidators.channelId('general')).toBe('Invalid channel ID format');
135
+ });
136
+ });
137
+
138
+ describe('outputFormat', () => {
139
+ it('should validate correct output formats', () => {
140
+ expect(formatValidators.outputFormat('table')).toBeNull();
141
+ expect(formatValidators.outputFormat('simple')).toBeNull();
142
+ expect(formatValidators.outputFormat('json')).toBeNull();
143
+ expect(formatValidators.outputFormat('compact')).toBeNull();
144
+ });
145
+
146
+ it('should reject invalid output formats', () => {
147
+ expect(formatValidators.outputFormat('xml')).toBe(
148
+ 'Invalid format. Must be one of: table, simple, json, compact'
149
+ );
150
+ expect(formatValidators.outputFormat('csv')).toBe(
151
+ 'Invalid format. Must be one of: table, simple, json, compact'
152
+ );
153
+ });
154
+ });
155
+ });
156
+
157
+ describe('createValidationHook', () => {
158
+ it('should call command.error when validation fails', () => {
159
+ const mockCommand = {
160
+ opts: vi.fn().mockReturnValue({ test: 'value' }),
161
+ error: vi.fn(),
162
+ } as unknown as Command;
163
+
164
+ const validation = vi.fn().mockReturnValue('Validation error');
165
+ const hook = createValidationHook([validation]);
166
+
167
+ hook(mockCommand);
168
+
169
+ expect(mockCommand.opts).toHaveBeenCalled();
170
+ expect(validation).toHaveBeenCalledWith({ test: 'value' }, mockCommand);
171
+ expect(mockCommand.error).toHaveBeenCalledWith('Error: Validation error');
172
+ });
173
+
174
+ it('should not call command.error when all validations pass', () => {
175
+ const mockCommand = {
176
+ opts: vi.fn().mockReturnValue({ test: 'value' }),
177
+ error: vi.fn(),
178
+ } as unknown as Command;
179
+
180
+ const validation1 = vi.fn().mockReturnValue(null);
181
+ const validation2 = vi.fn().mockReturnValue(null);
182
+ const hook = createValidationHook([validation1, validation2]);
183
+
184
+ hook(mockCommand);
185
+
186
+ expect(validation1).toHaveBeenCalled();
187
+ expect(validation2).toHaveBeenCalled();
188
+ expect(mockCommand.error).not.toHaveBeenCalled();
189
+ });
190
+
191
+ it('should stop at first validation error', () => {
192
+ const mockCommand = {
193
+ opts: vi.fn().mockReturnValue({ test: 'value' }),
194
+ error: vi.fn(),
195
+ } as unknown as Command;
196
+
197
+ const validation1 = vi.fn().mockReturnValue('First error');
198
+ const validation2 = vi.fn().mockReturnValue('Second error');
199
+ const hook = createValidationHook([validation1, validation2]);
200
+
201
+ hook(mockCommand);
202
+
203
+ expect(validation1).toHaveBeenCalled();
204
+ expect(validation2).not.toHaveBeenCalled();
205
+ expect(mockCommand.error).toHaveBeenCalledWith('Error: First error');
206
+ });
207
+ });
208
+
209
+ describe('optionValidators', () => {
210
+ describe('messageOrFile', () => {
211
+ it('should pass when message is provided', () => {
212
+ expect(optionValidators.messageOrFile({ message: 'text' })).toBeNull();
213
+ });
214
+
215
+ it('should pass when file is provided', () => {
216
+ expect(optionValidators.messageOrFile({ file: 'path.txt' })).toBeNull();
217
+ });
218
+
219
+ it('should fail when neither is provided', () => {
220
+ expect(optionValidators.messageOrFile({})).toBe(
221
+ 'You must specify either --message or --file'
222
+ );
223
+ });
224
+
225
+ it('should fail when both are provided', () => {
226
+ expect(optionValidators.messageOrFile({ message: 'text', file: 'path.txt' })).toBe(
227
+ 'Cannot use both --message and --file'
228
+ );
229
+ });
230
+ });
231
+
232
+ describe('threadTimestamp', () => {
233
+ it('should pass when no thread is provided', () => {
234
+ expect(optionValidators.threadTimestamp({})).toBeNull();
235
+ });
236
+
237
+ it('should validate thread format when provided', () => {
238
+ expect(optionValidators.threadTimestamp({ thread: '1234567890.123456' })).toBeNull();
239
+ expect(optionValidators.threadTimestamp({ thread: 'invalid' })).toBe(
240
+ 'Invalid thread timestamp format'
241
+ );
242
+ });
243
+ });
244
+
245
+ describe('messageCount', () => {
246
+ it('should pass when no number is provided', () => {
247
+ expect(optionValidators.messageCount({})).toBeNull();
248
+ });
249
+
250
+ it('should validate number range when provided', () => {
251
+ expect(optionValidators.messageCount({ number: '50' })).toBeNull();
252
+ expect(optionValidators.messageCount({ number: '0' })).toBe(
253
+ 'Message count must be at least 1'
254
+ );
255
+ expect(optionValidators.messageCount({ number: '1001' })).toBe(
256
+ 'Message count must be at most 1000'
257
+ );
258
+ expect(optionValidators.messageCount({ number: 'abc' })).toBe(
259
+ 'Message count must be a number'
260
+ );
261
+ });
262
+ });
263
+
264
+ describe('sinceDate', () => {
265
+ it('should pass when no date is provided', () => {
266
+ expect(optionValidators.sinceDate({})).toBeNull();
267
+ });
268
+
269
+ it('should validate date format when provided', () => {
270
+ expect(optionValidators.sinceDate({ since: '2024-01-01' })).toBeNull();
271
+ expect(optionValidators.sinceDate({ since: 'invalid' })).toBe('Invalid date format. Use YYYY-MM-DD HH:MM:SS');
272
+ });
273
+ });
274
+ });
275
+
276
+ describe('createOptionParser', () => {
277
+ it('should parse value and return it when validation passes', () => {
278
+ const parser = vi.fn().mockReturnValue(50);
279
+ const validator = vi.fn().mockReturnValue(null);
280
+ const optionParser = createOptionParser(parser, validator);
281
+
282
+ const result = optionParser('50', 100);
283
+
284
+ expect(parser).toHaveBeenCalledWith('50', 100);
285
+ expect(validator).toHaveBeenCalledWith(50);
286
+ expect(result).toBe(50);
287
+ });
288
+
289
+ it('should throw error when validation fails', () => {
290
+ const parser = vi.fn().mockReturnValue(50);
291
+ const validator = vi.fn().mockReturnValue('Validation failed');
292
+ const optionParser = createOptionParser(parser, validator);
293
+
294
+ expect(() => optionParser('50', 100)).toThrow('Validation failed');
295
+ });
296
+
297
+ it('should work without validator', () => {
298
+ const parser = vi.fn().mockReturnValue(50);
299
+ const optionParser = createOptionParser(parser);
300
+
301
+ const result = optionParser('50', 100);
302
+
303
+ expect(parser).toHaveBeenCalledWith('50', 100);
304
+ expect(result).toBe(50);
305
+ });
306
+ });
307
+ });
@@ -1,161 +0,0 @@
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
- };