@ceschiatti/redistail 0.0.1 → 0.0.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.
@@ -0,0 +1,455 @@
1
+ /**
2
+ * CLI Configuration Service for redistail utility.
3
+ *
4
+ * This module provides services for parsing command-line arguments,
5
+ * loading environment configuration, and displaying help/version information.
6
+ * Built using Effect-TS patterns with proper error handling and dependency injection.
7
+ */
8
+
9
+ import { Context, Effect, Layer, Config, Console } from 'effect';
10
+ import { BUILD_COMMIT, BUILD_VERSION } from './version-generated.js';
11
+ import {
12
+ CLIConfig,
13
+ RedistailConfig,
14
+ isValidConnectionType,
15
+ isValidTopicName,
16
+ createInvalidArgumentError,
17
+ createMissingArgumentError,
18
+ createEnvironmentError,
19
+ createDefaultRedistailConfig,
20
+ DEFAULT_CLI_REDIS_CONFIG,
21
+ DEFAULT_DISPLAY_CONFIG,
22
+ DEFAULT_MONITORING_CONFIG,
23
+ } from './types.js';
24
+ import type { CLIError, ConfigError } from './types.js';
25
+
26
+ // ============================================================================
27
+ // Service Interface
28
+ // ============================================================================
29
+
30
+ /**
31
+ * CLI Configuration Service interface
32
+ */
33
+ export interface CLIConfigService {
34
+ readonly parseArgs: (args: string[]) => Effect.Effect<CLIConfig, CLIError>;
35
+ readonly loadConfig: () => Effect.Effect<RedistailConfig, ConfigError>;
36
+ readonly showHelp: () => Effect.Effect<void, never>;
37
+ readonly showVersion: () => Effect.Effect<void, never>;
38
+ }
39
+
40
+ /**
41
+ * Service tag for dependency injection
42
+ */
43
+ export const CLIConfigService =
44
+ Context.GenericTag<CLIConfigService>('CLIConfigService');
45
+
46
+ // ============================================================================
47
+ // Environment Configuration Schema
48
+ // ============================================================================
49
+
50
+ /**
51
+ * Redis configuration from environment variables
52
+ */
53
+ const redisHost = Config.withDefault(
54
+ Config.string('REDIS_HOST'),
55
+ DEFAULT_CLI_REDIS_CONFIG.host,
56
+ );
57
+ const redisPort = Config.withDefault(
58
+ Config.integer('REDIS_PORT'),
59
+ DEFAULT_CLI_REDIS_CONFIG.port,
60
+ );
61
+ const redisUrl = Config.option(Config.string('REDIS_URL'));
62
+ const redisTimeout = Config.withDefault(
63
+ Config.integer('REDIS_TIMEOUT'),
64
+ DEFAULT_CLI_REDIS_CONFIG.timeout,
65
+ );
66
+ const redisRetryAttempts = Config.withDefault(
67
+ Config.integer('REDIS_RETRY_ATTEMPTS'),
68
+ DEFAULT_CLI_REDIS_CONFIG.retryAttempts,
69
+ );
70
+ const redisRetryDelay = Config.withDefault(
71
+ Config.integer('REDIS_RETRY_DELAY'),
72
+ DEFAULT_CLI_REDIS_CONFIG.retryDelay,
73
+ );
74
+
75
+ /**
76
+ * Display configuration from environment variables
77
+ */
78
+ const redistailColors = Config.withDefault(
79
+ Config.boolean('REDISTAIL_COLORS'),
80
+ DEFAULT_DISPLAY_CONFIG.colors,
81
+ );
82
+ const redistailTimestamps = Config.withDefault(
83
+ Config.boolean('REDISTAIL_TIMESTAMPS'),
84
+ DEFAULT_DISPLAY_CONFIG.timestamps,
85
+ );
86
+ const redistailPrettyJson = Config.withDefault(
87
+ Config.boolean('REDISTAIL_PRETTY_JSON'),
88
+ DEFAULT_DISPLAY_CONFIG.prettyJson,
89
+ );
90
+
91
+ /**
92
+ * Monitoring configuration from environment variables
93
+ */
94
+ const redistailBlockTimeout = Config.withDefault(
95
+ Config.integer('REDISTAIL_BLOCK_TIMEOUT'),
96
+ DEFAULT_MONITORING_CONFIG.blockTimeout,
97
+ );
98
+ const redistailMaxReconnectAttempts = Config.withDefault(
99
+ Config.integer('REDISTAIL_MAX_RECONNECT_ATTEMPTS'),
100
+ DEFAULT_MONITORING_CONFIG.maxReconnectAttempts,
101
+ );
102
+
103
+ // ============================================================================
104
+ // Implementation Functions
105
+ // ============================================================================
106
+
107
+ /**
108
+ * Parse command line arguments into CLIConfig
109
+ */
110
+ const parseCommandLineArgs = (
111
+ args: string[],
112
+ ): Effect.Effect<CLIConfig, CLIError> =>
113
+ Effect.gen(function* () {
114
+ // Handle help flags
115
+ if (args.includes('--help') || args.includes('-h')) {
116
+ return CLIConfig({
117
+ connectionType: 'pubsub',
118
+ topicName: '',
119
+ help: true,
120
+ });
121
+ }
122
+
123
+ // Handle version flags
124
+ if (args.includes('--version') || args.includes('-v')) {
125
+ return CLIConfig({
126
+ connectionType: 'pubsub',
127
+ topicName: '',
128
+ version: true,
129
+ });
130
+ }
131
+
132
+ // Check for minimum required arguments
133
+ if (args.length < 2) {
134
+ return yield* Effect.fail(
135
+ createMissingArgumentError(
136
+ 'Missing required arguments. Usage: redistail <pubsub|stream> <topic-name>',
137
+ 'ARGUMENT_PARSING',
138
+ ),
139
+ );
140
+ }
141
+
142
+ const [connectionType, topicName, ...extraArgs] = args;
143
+
144
+ // Validate connection type
145
+ if (!isValidConnectionType(connectionType)) {
146
+ return yield* Effect.fail(
147
+ createInvalidArgumentError(
148
+ `Invalid connection type: ${connectionType}. Must be 'pubsub' or 'stream'`,
149
+ 'ARGUMENT_PARSING',
150
+ ),
151
+ );
152
+ }
153
+
154
+ // Validate topic name
155
+ if (!isValidTopicName(topicName)) {
156
+ return yield* Effect.fail(
157
+ createInvalidArgumentError(
158
+ 'Topic name cannot be empty',
159
+ 'ARGUMENT_PARSING',
160
+ ),
161
+ );
162
+ }
163
+
164
+ // Check for unexpected extra arguments
165
+ if (extraArgs.length > 0) {
166
+ return yield* Effect.fail(
167
+ createInvalidArgumentError(
168
+ `Unexpected arguments: ${extraArgs.join(', ')}. Usage: redistail <pubsub|stream> <topic-name>`,
169
+ 'ARGUMENT_PARSING',
170
+ ),
171
+ );
172
+ }
173
+
174
+ return CLIConfig({
175
+ connectionType,
176
+ topicName: topicName.trim(),
177
+ });
178
+ });
179
+
180
+ /**
181
+ * Load configuration from environment variables
182
+ */
183
+ const loadRedistailConfig = (): Effect.Effect<RedistailConfig, ConfigError> =>
184
+ Effect.gen(function* () {
185
+ const config = yield* Effect.all({
186
+ redisHost,
187
+ redisPort,
188
+ redisUrl,
189
+ redisTimeout,
190
+ redisRetryAttempts,
191
+ redisRetryDelay,
192
+ redistailColors,
193
+ redistailTimestamps,
194
+ redistailPrettyJson,
195
+ redistailBlockTimeout,
196
+ redistailMaxReconnectAttempts,
197
+ }).pipe(
198
+ Effect.mapError((configError) =>
199
+ createEnvironmentError(
200
+ 'Failed to load configuration from environment',
201
+ configError,
202
+ 'CONFIG_LOADING',
203
+ ),
204
+ ),
205
+ );
206
+
207
+ // Validate configuration values
208
+ if (config.redisPort < 1 || config.redisPort > 65535) {
209
+ return yield* Effect.fail(
210
+ createEnvironmentError(
211
+ `Invalid REDIS_PORT: ${config.redisPort}. Must be between 1 and 65535`,
212
+ undefined,
213
+ 'CONFIG_VALIDATION',
214
+ ),
215
+ );
216
+ }
217
+
218
+ if (config.redisTimeout < 0) {
219
+ return yield* Effect.fail(
220
+ createEnvironmentError(
221
+ `Invalid REDIS_TIMEOUT: ${config.redisTimeout}. Must be non-negative`,
222
+ undefined,
223
+ 'CONFIG_VALIDATION',
224
+ ),
225
+ );
226
+ }
227
+
228
+ if (config.redisRetryAttempts < 0) {
229
+ return yield* Effect.fail(
230
+ createEnvironmentError(
231
+ `Invalid REDIS_RETRY_ATTEMPTS: ${config.redisRetryAttempts}. Must be non-negative`,
232
+ undefined,
233
+ 'CONFIG_VALIDATION',
234
+ ),
235
+ );
236
+ }
237
+
238
+ if (config.redisRetryDelay < 0) {
239
+ return yield* Effect.fail(
240
+ createEnvironmentError(
241
+ `Invalid REDIS_RETRY_DELAY: ${config.redisRetryDelay}. Must be non-negative`,
242
+ undefined,
243
+ 'CONFIG_VALIDATION',
244
+ ),
245
+ );
246
+ }
247
+
248
+ if (config.redistailBlockTimeout < 0) {
249
+ return yield* Effect.fail(
250
+ createEnvironmentError(
251
+ `Invalid REDISTAIL_BLOCK_TIMEOUT: ${config.redistailBlockTimeout}. Must be non-negative`,
252
+ undefined,
253
+ 'CONFIG_VALIDATION',
254
+ ),
255
+ );
256
+ }
257
+
258
+ if (config.redistailMaxReconnectAttempts < 0) {
259
+ return yield* Effect.fail(
260
+ createEnvironmentError(
261
+ `Invalid REDISTAIL_MAX_RECONNECT_ATTEMPTS: ${config.redistailMaxReconnectAttempts}. Must be non-negative`,
262
+ undefined,
263
+ 'CONFIG_VALIDATION',
264
+ ),
265
+ );
266
+ }
267
+
268
+ return RedistailConfig({
269
+ redis: {
270
+ host: config.redisHost,
271
+ port: config.redisPort,
272
+ url:
273
+ config.redisUrl._tag === 'Some' ? config.redisUrl.value : undefined,
274
+ timeout: config.redisTimeout,
275
+ retryAttempts: config.redisRetryAttempts,
276
+ retryDelay: config.redisRetryDelay,
277
+ },
278
+ display: {
279
+ colors: config.redistailColors,
280
+ timestamps: config.redistailTimestamps,
281
+ prettyJson: config.redistailPrettyJson,
282
+ },
283
+ monitoring: {
284
+ blockTimeout: config.redistailBlockTimeout,
285
+ maxReconnectAttempts: config.redistailMaxReconnectAttempts,
286
+ },
287
+ });
288
+ });
289
+
290
+ /**
291
+ * Display help information
292
+ */
293
+ const showHelp = (): Effect.Effect<void, never> =>
294
+ Console.log(`
295
+ redistail - Redis message monitoring utility
296
+
297
+ USAGE:
298
+ redistail <CONNECTION_TYPE> <TOPIC_NAME>
299
+
300
+ ARGUMENTS:
301
+ CONNECTION_TYPE Type of Redis connection: 'pubsub' or 'stream'
302
+ TOPIC_NAME Name of the channel (pubsub) or stream key (stream) to monitor
303
+
304
+ OPTIONS:
305
+ -h, --help Show this help message
306
+ -v, --version Show version information
307
+
308
+ ENVIRONMENT VARIABLES:
309
+ REDIS_HOST Redis hostname (default: ${DEFAULT_CLI_REDIS_CONFIG.host})
310
+ REDIS_PORT Redis port (default: ${DEFAULT_CLI_REDIS_CONFIG.port})
311
+ REDIS_URL Complete Redis connection URL (overrides host/port)
312
+ REDIS_TIMEOUT Connection timeout in ms (default: ${DEFAULT_CLI_REDIS_CONFIG.timeout})
313
+ REDIS_RETRY_ATTEMPTS Number of retry attempts (default: ${DEFAULT_CLI_REDIS_CONFIG.retryAttempts})
314
+ REDIS_RETRY_DELAY Delay between retries in ms (default: ${DEFAULT_CLI_REDIS_CONFIG.retryDelay})
315
+ REDISTAIL_COLORS Enable colored output (default: ${DEFAULT_DISPLAY_CONFIG.colors})
316
+ REDISTAIL_TIMESTAMPS Enable timestamps (default: ${DEFAULT_DISPLAY_CONFIG.timestamps})
317
+ REDISTAIL_PRETTY_JSON Pretty-print JSON content (default: ${DEFAULT_DISPLAY_CONFIG.prettyJson})
318
+ REDISTAIL_BLOCK_TIMEOUT Block timeout for streams in ms (default: ${DEFAULT_MONITORING_CONFIG.blockTimeout})
319
+ REDISTAIL_MAX_RECONNECT_ATTEMPTS Maximum reconnection attempts (default: ${DEFAULT_MONITORING_CONFIG.maxReconnectAttempts})
320
+
321
+ EXAMPLES:
322
+ redistail pubsub my-channel
323
+ redistail stream my-stream
324
+ REDIS_HOST=redis.example.com redistail pubsub notifications
325
+ REDIS_URL=redis://user:pass@redis.example.com:6380 redistail stream events
326
+ `);
327
+
328
+ /**
329
+ * Display version information
330
+ */
331
+ const showVersion = (): Effect.Effect<void, never> =>
332
+ Console.log(`redistail ${BUILD_VERSION}\nBuild: ${BUILD_COMMIT}`);
333
+
334
+ // ============================================================================
335
+ // Service Implementation
336
+ // ============================================================================
337
+
338
+ /**
339
+ * CLI Configuration Service implementation
340
+ */
341
+ const cliConfigServiceImpl: CLIConfigService = {
342
+ parseArgs: parseCommandLineArgs,
343
+ loadConfig: loadRedistailConfig,
344
+ showHelp,
345
+ showVersion,
346
+ };
347
+
348
+ /**
349
+ * Live layer for CLI Configuration Service
350
+ */
351
+ export const CLIConfigServiceLive: Layer.Layer<CLIConfigService> =
352
+ Layer.succeed(CLIConfigService, cliConfigServiceImpl);
353
+
354
+ // ============================================================================
355
+ // Convenience Functions
356
+ // ============================================================================
357
+
358
+ /**
359
+ * Parse arguments and load configuration in one operation
360
+ */
361
+ export const parseArgsAndLoadConfig = (
362
+ args: string[],
363
+ ): Effect.Effect<
364
+ { cli: CLIConfig; config: RedistailConfig },
365
+ CLIError | ConfigError,
366
+ CLIConfigService
367
+ > =>
368
+ Effect.gen(function* () {
369
+ const cliConfigService = yield* CLIConfigService;
370
+ const cli = yield* cliConfigService.parseArgs(args);
371
+ const config = yield* cliConfigService.loadConfig();
372
+ return { cli, config };
373
+ });
374
+
375
+ /**
376
+ * Handle help and version flags, returning true if handled
377
+ */
378
+ export const handleHelpAndVersion = (
379
+ cliConfig: CLIConfig,
380
+ ): Effect.Effect<boolean, never, CLIConfigService> =>
381
+ Effect.gen(function* () {
382
+ const cliConfigService = yield* CLIConfigService;
383
+
384
+ if (cliConfig.help) {
385
+ yield* cliConfigService.showHelp();
386
+ return true;
387
+ }
388
+
389
+ if (cliConfig.version) {
390
+ yield* cliConfigService.showVersion();
391
+ return true;
392
+ }
393
+
394
+ return false;
395
+ });
396
+
397
+ /**
398
+ * Validate CLI configuration for runtime use
399
+ */
400
+ export const validateCLIConfig = (
401
+ cliConfig: CLIConfig,
402
+ ): Effect.Effect<CLIConfig, CLIError> =>
403
+ Effect.gen(function* () {
404
+ if (cliConfig.help || cliConfig.version) {
405
+ return cliConfig;
406
+ }
407
+
408
+ if (!isValidConnectionType(cliConfig.connectionType)) {
409
+ return yield* Effect.fail(
410
+ createInvalidArgumentError(
411
+ `Invalid connection type: ${cliConfig.connectionType}`,
412
+ 'CONFIG_VALIDATION',
413
+ ),
414
+ );
415
+ }
416
+
417
+ if (!isValidTopicName(cliConfig.topicName)) {
418
+ return yield* Effect.fail(
419
+ createInvalidArgumentError(
420
+ 'Topic name cannot be empty',
421
+ 'CONFIG_VALIDATION',
422
+ ),
423
+ );
424
+ }
425
+
426
+ return cliConfig;
427
+ });
428
+
429
+ /**
430
+ * Create a complete configuration for the redistail application
431
+ */
432
+ export const createCompleteConfig = (
433
+ args: string[],
434
+ ): Effect.Effect<
435
+ { cli: CLIConfig; config: RedistailConfig; shouldExit: boolean },
436
+ CLIError | ConfigError,
437
+ CLIConfigService
438
+ > =>
439
+ Effect.gen(function* () {
440
+ const cliConfigService = yield* CLIConfigService;
441
+ const cli = yield* cliConfigService.parseArgs(args);
442
+
443
+ // Handle help and version early
444
+ if (cli.help || cli.version) {
445
+ const shouldExit = yield* handleHelpAndVersion(cli);
446
+ const config = createDefaultRedistailConfig();
447
+ return { cli, config, shouldExit };
448
+ }
449
+
450
+ // Load full configuration for normal operation
451
+ const config = yield* cliConfigService.loadConfig();
452
+ const validatedCli = yield* validateCLIConfig(cli);
453
+
454
+ return { cli: validatedCli, config, shouldExit: false };
455
+ });