@elysiumjs/core 0.1.0
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.
- package/.prettierignore +5 -0
- package/.prettierrc +25 -0
- package/README.md +112 -0
- package/bun.lock +262 -0
- package/index.ts +50 -0
- package/package.json +49 -0
- package/src/app.ts +503 -0
- package/src/cache.ts +218 -0
- package/src/command.ts +908 -0
- package/src/console.ts +487 -0
- package/src/database.ts +130 -0
- package/src/event.ts +225 -0
- package/src/http.ts +669 -0
- package/src/job.ts +196 -0
- package/src/middleware.ts +122 -0
- package/src/model.ts +312 -0
- package/src/module.ts +98 -0
- package/src/redis.ts +407 -0
- package/src/repository.ts +261 -0
- package/src/service.ts +349 -0
- package/src/utils.ts +41 -0
- package/src/wamp.ts +388 -0
- package/src/websocket.ts +186 -0
- package/src/worker.ts +842 -0
package/src/command.ts
ADDED
|
@@ -0,0 +1,908 @@
|
|
|
1
|
+
// Copyright (c) 2025-present Workbud Technologies Inc. All rights reserved.
|
|
2
|
+
//
|
|
3
|
+
// Licensed under the Apache License, Version 2.0 (the "License");
|
|
4
|
+
// you may not use this file except in compliance with the License.
|
|
5
|
+
// You may obtain a copy of the License at
|
|
6
|
+
//
|
|
7
|
+
// http://www.apache.org/licenses/LICENSE-2.0
|
|
8
|
+
//
|
|
9
|
+
// Unless required by applicable law or agreed to in writing, software
|
|
10
|
+
// distributed under the License is distributed on an "AS IS" BASIS,
|
|
11
|
+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
12
|
+
// See the License for the specific language governing permissions and
|
|
13
|
+
// limitations under the License.
|
|
14
|
+
|
|
15
|
+
import type { Class } from 'type-fest';
|
|
16
|
+
|
|
17
|
+
import { isArray, trim } from 'radash';
|
|
18
|
+
|
|
19
|
+
import { ConsoleFormat, InteractsWithConsole } from './console';
|
|
20
|
+
import { Symbols } from './utils';
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Supported command argument types.
|
|
24
|
+
* @author Axel Nana <axel.nana@workbud.com>
|
|
25
|
+
*/
|
|
26
|
+
export enum CommandArgumentType {
|
|
27
|
+
/**
|
|
28
|
+
* String argument.
|
|
29
|
+
*/
|
|
30
|
+
STRING = 'string',
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Number argument.
|
|
34
|
+
*/
|
|
35
|
+
NUMBER = 'number',
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Boolean argument.
|
|
39
|
+
*/
|
|
40
|
+
BOOLEAN = 'boolean',
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Enum argument.
|
|
44
|
+
*/
|
|
45
|
+
ENUM = 'enum',
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Array argument.
|
|
49
|
+
*/
|
|
50
|
+
ARRAY = 'array'
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Properties required when declaring a command argument.
|
|
55
|
+
* @author Axel Nana <axel.nana@workbud.com>
|
|
56
|
+
*
|
|
57
|
+
* These properties are used to define command arguments using the `@arg()` decorator. The
|
|
58
|
+
* values of these properties are used to generate the help message for the command.
|
|
59
|
+
*/
|
|
60
|
+
export type CommandArgumentProps = {
|
|
61
|
+
/**
|
|
62
|
+
* The name of the argument.
|
|
63
|
+
*/
|
|
64
|
+
name: string;
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* The description of the argument.
|
|
68
|
+
*/
|
|
69
|
+
description?: string;
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Whether the argument is required.
|
|
73
|
+
*/
|
|
74
|
+
required?: boolean;
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* The default value for the argument.
|
|
78
|
+
*/
|
|
79
|
+
default?: any;
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* The type of the argument.
|
|
83
|
+
*/
|
|
84
|
+
type?: CommandArgumentType;
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* The list of allowed values for the argument.
|
|
88
|
+
*/
|
|
89
|
+
enum?: string[];
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* The type of elements in the array.
|
|
93
|
+
*/
|
|
94
|
+
arrayType?: CommandArgumentType; // Type of elements in the array
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Metadata for storing command arguments.
|
|
99
|
+
* @author Axel Nana <axel.nana@workbud.com>
|
|
100
|
+
*/
|
|
101
|
+
type CommandArgumentMetadata = CommandArgumentProps & {
|
|
102
|
+
/**
|
|
103
|
+
* The name of the class property that the argument is associated with.
|
|
104
|
+
*/
|
|
105
|
+
propertyKey: string;
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Type for a command class.
|
|
110
|
+
* @author Axel Nana <axel.nana@workbud.com>
|
|
111
|
+
*/
|
|
112
|
+
export type CommandClass = Class<Command> & {
|
|
113
|
+
/**
|
|
114
|
+
* The name of the command.
|
|
115
|
+
*/
|
|
116
|
+
readonly command: string;
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* The description of the command.
|
|
120
|
+
*/
|
|
121
|
+
readonly description: string;
|
|
122
|
+
};
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Base class for commands.
|
|
126
|
+
*
|
|
127
|
+
* Commands are classes that can be executed through the CLI. They can have arguments
|
|
128
|
+
* that can be passed to them using the `--` syntax.
|
|
129
|
+
*
|
|
130
|
+
* @author Axel Nana <axel.nana@workbud.com>
|
|
131
|
+
*/
|
|
132
|
+
export abstract class Command extends InteractsWithConsole {
|
|
133
|
+
//#region Command Interface
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* The name of the command.
|
|
137
|
+
*
|
|
138
|
+
* This is used to identify the command in the CLI when using the `exec` command.
|
|
139
|
+
*/
|
|
140
|
+
public static readonly command: string = 'command';
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* The description of the command.
|
|
144
|
+
*
|
|
145
|
+
* This is displayed when displaying help messages for the command.
|
|
146
|
+
*/
|
|
147
|
+
public static readonly description: string = 'Command description';
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Decorator for registering a command argument.
|
|
151
|
+
* @author Axel Nana <axel.nana@workbud.com>
|
|
152
|
+
* @param name The name of the argument.
|
|
153
|
+
* @param props Additional options for the argument.
|
|
154
|
+
*/
|
|
155
|
+
public static arg(props: Partial<CommandArgumentProps> = {}): PropertyDecorator {
|
|
156
|
+
return function (target, propertyKey) {
|
|
157
|
+
const args: CommandArgumentMetadata[] =
|
|
158
|
+
Reflect.getMetadata(Symbols.arg, target.constructor) ?? [];
|
|
159
|
+
|
|
160
|
+
const propertyType = Reflect.getMetadata('design:type', target, propertyKey);
|
|
161
|
+
|
|
162
|
+
// Infer argument type from property type if not explicitly specified
|
|
163
|
+
let inferredType = props.type;
|
|
164
|
+
|
|
165
|
+
if (!inferredType && propertyType) {
|
|
166
|
+
if (propertyType === Array) {
|
|
167
|
+
inferredType = CommandArgumentType.ARRAY;
|
|
168
|
+
|
|
169
|
+
if (!props.arrayType) {
|
|
170
|
+
props = {
|
|
171
|
+
...props,
|
|
172
|
+
arrayType: CommandArgumentType.STRING
|
|
173
|
+
};
|
|
174
|
+
}
|
|
175
|
+
} else if (propertyType.constructor !== Object) {
|
|
176
|
+
switch (propertyType.name.toLowerCase()) {
|
|
177
|
+
case 'number':
|
|
178
|
+
inferredType = CommandArgumentType.NUMBER;
|
|
179
|
+
break;
|
|
180
|
+
case 'boolean':
|
|
181
|
+
inferredType = CommandArgumentType.BOOLEAN;
|
|
182
|
+
break;
|
|
183
|
+
case 'string':
|
|
184
|
+
inferredType = CommandArgumentType.STRING;
|
|
185
|
+
break;
|
|
186
|
+
default:
|
|
187
|
+
inferredType = undefined;
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
// Check if it's an enum by examining if the property type has values
|
|
191
|
+
else if (Object.values(propertyType).length > 0) {
|
|
192
|
+
inferredType = CommandArgumentType.ENUM;
|
|
193
|
+
|
|
194
|
+
if (!props.enum) {
|
|
195
|
+
props = {
|
|
196
|
+
...props,
|
|
197
|
+
enum: Object.values(propertyType)
|
|
198
|
+
};
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
args.push({
|
|
204
|
+
name: props.name ?? (propertyKey as string),
|
|
205
|
+
description: props.description,
|
|
206
|
+
required: props.required ?? false,
|
|
207
|
+
default: props.default,
|
|
208
|
+
type: inferredType,
|
|
209
|
+
enum: props.enum,
|
|
210
|
+
arrayType: props.arrayType,
|
|
211
|
+
propertyKey: propertyKey as string
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
Reflect.defineMetadata(Symbols.arg, args, target.constructor);
|
|
215
|
+
};
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/**
|
|
219
|
+
* The command entry point. This method is called when the command is executed using the CLI.
|
|
220
|
+
*/
|
|
221
|
+
public abstract run(): Promise<void>;
|
|
222
|
+
|
|
223
|
+
/**
|
|
224
|
+
* Initialize the command before it is executed. This method is called before the `run()` method.
|
|
225
|
+
* and is used to validate the arguments and initialize the command.
|
|
226
|
+
* @param argv The command line arguments.
|
|
227
|
+
* @returns Whether the command was initialized successfully.
|
|
228
|
+
*/
|
|
229
|
+
public async init(...argv: string[]): Promise<boolean> {
|
|
230
|
+
const args = this.processArgs(argv);
|
|
231
|
+
const validation = this.validateArgs(args);
|
|
232
|
+
|
|
233
|
+
if (!validation.valid) {
|
|
234
|
+
if (validation.missing.length > 0) {
|
|
235
|
+
console.error(`Missing required arguments: ${validation.missing.join(', ')}\n`);
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
return false;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
// Return false if help argument is provided
|
|
242
|
+
if (Object.keys(args).includes('help')) {
|
|
243
|
+
return false;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
// Map CLI arguments to class properties using propertyKey from metadata
|
|
247
|
+
const commandArgs = this.getArguments();
|
|
248
|
+
for (const arg of commandArgs) {
|
|
249
|
+
if (args[arg.name] !== undefined) {
|
|
250
|
+
// Use the propertyKey to set the correct class property
|
|
251
|
+
(this as any)[arg.propertyKey] = args[arg.name];
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
return true;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/**
|
|
259
|
+
* Get all registered arguments for this command.
|
|
260
|
+
* @returns The list of registered arguments.
|
|
261
|
+
*/
|
|
262
|
+
protected getArguments(): CommandArgumentMetadata[] {
|
|
263
|
+
return Reflect.getMetadata(Symbols.arg, this.constructor) || [];
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
/**
|
|
267
|
+
* Generate help text for the command.
|
|
268
|
+
* @returns Formatted help text.
|
|
269
|
+
*/
|
|
270
|
+
public async help(): Promise<string> {
|
|
271
|
+
let helpText = `${this.bold('Command:')} ${this.format((this.constructor as CommandClass).command, ConsoleFormat.MAGENTA)}\n`;
|
|
272
|
+
helpText += `${this.bold('Description:')} ${(this.constructor as CommandClass).description}\n\n`;
|
|
273
|
+
|
|
274
|
+
const args = this.getArguments();
|
|
275
|
+
|
|
276
|
+
if (args.length > 0) {
|
|
277
|
+
helpText += `${this.bold('Arguments:')}\n`;
|
|
278
|
+
|
|
279
|
+
for (const arg of args) {
|
|
280
|
+
const requiredText = this.format(
|
|
281
|
+
arg.required ? '(required)' : '(optional)',
|
|
282
|
+
ConsoleFormat.RED,
|
|
283
|
+
ConsoleFormat.BOLD
|
|
284
|
+
);
|
|
285
|
+
const defaultText =
|
|
286
|
+
arg.default !== undefined
|
|
287
|
+
? this.format(`[default: ${arg.default}]`, ConsoleFormat.GRAY, ConsoleFormat.ITALIC)
|
|
288
|
+
: '';
|
|
289
|
+
|
|
290
|
+
// Generate type text
|
|
291
|
+
let typeText = '';
|
|
292
|
+
if (arg.type === CommandArgumentType.ARRAY) {
|
|
293
|
+
typeText = `<array of ${arg.arrayType || 'string'}s>`;
|
|
294
|
+
} else if (arg.type) {
|
|
295
|
+
typeText = `<${arg.type}>`;
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
// Add enum values to help text if applicable
|
|
299
|
+
const enumText = arg.enum ? ` [${arg.enum.join('|')}]` : '';
|
|
300
|
+
|
|
301
|
+
helpText += ` ${this.format(`--${arg.name}`, ConsoleFormat.CYAN)} ${typeText}${enumText} ${requiredText} ${defaultText}\n`;
|
|
302
|
+
|
|
303
|
+
if (arg.description) {
|
|
304
|
+
helpText += ` ${arg.description}\n\n`;
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
} else {
|
|
308
|
+
helpText += 'This command has no arguments.\n';
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
return trim(helpText, '\n');
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
/**
|
|
315
|
+
* Validate provided arguments against registered arguments.
|
|
316
|
+
* @param args Arguments to validate.
|
|
317
|
+
* @returns Object with validation result and missing required args if any.
|
|
318
|
+
*/
|
|
319
|
+
protected validateArgs(args: Record<string, any>): { valid: boolean; missing: string[] } {
|
|
320
|
+
const missing: string[] = [];
|
|
321
|
+
const invalid: string[] = [];
|
|
322
|
+
const commandArgs = this.getArguments();
|
|
323
|
+
|
|
324
|
+
// Skip validation if help argument is provided
|
|
325
|
+
if (Object.keys(args).includes('help')) {
|
|
326
|
+
return { valid: true, missing: [] };
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
for (const arg of commandArgs) {
|
|
330
|
+
// Check for missing required arguments
|
|
331
|
+
if (arg.required && (args[arg.name] === undefined || args[arg.name] === null)) {
|
|
332
|
+
missing.push(arg.name);
|
|
333
|
+
continue;
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
// Skip validation if argument is not provided
|
|
337
|
+
if (args[arg.name] === undefined) {
|
|
338
|
+
continue;
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
// Validate enum values
|
|
342
|
+
if (arg.type === CommandArgumentType.ENUM && arg.enum && !arg.enum.includes(args[arg.name])) {
|
|
343
|
+
invalid.push(`${arg.name} (must be one of: ${arg.enum.join(', ')})`);
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
// Validate array of enum values
|
|
347
|
+
if (
|
|
348
|
+
arg.type === CommandArgumentType.ARRAY &&
|
|
349
|
+
arg.arrayType === CommandArgumentType.ENUM &&
|
|
350
|
+
arg.enum
|
|
351
|
+
) {
|
|
352
|
+
const invalidItems = (args[arg.name] as any[]).filter((item) => !arg.enum!.includes(item));
|
|
353
|
+
if (invalidItems.length > 0) {
|
|
354
|
+
invalid.push(
|
|
355
|
+
`${arg.name} (contains invalid values: ${invalidItems.join(', ')}. Must be one of: ${arg.enum.join(', ')})`
|
|
356
|
+
);
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
if (invalid.length > 0) {
|
|
362
|
+
console.error(`Invalid argument values: ${invalid.join(', ')}\n`);
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
return {
|
|
366
|
+
valid: missing.length === 0 && invalid.length === 0,
|
|
367
|
+
missing
|
|
368
|
+
};
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
/**
|
|
372
|
+
* Process command line arguments and apply default values.
|
|
373
|
+
* @param argv The command line arguments.
|
|
374
|
+
* @returns Processed arguments with defaults applied.
|
|
375
|
+
*/
|
|
376
|
+
protected processArgs(argv: string[]): Record<string, any> {
|
|
377
|
+
const processed: Record<string, any> = {};
|
|
378
|
+
const commandArgs = this.getArguments();
|
|
379
|
+
|
|
380
|
+
// Process command line arguments
|
|
381
|
+
for (let i = 0; i < argv.length; i++) {
|
|
382
|
+
const arg = argv[i];
|
|
383
|
+
|
|
384
|
+
// Check if it's an argument (starts with --)
|
|
385
|
+
if (arg.startsWith('--')) {
|
|
386
|
+
const argName = arg.slice(2); // Remove -- prefix
|
|
387
|
+
|
|
388
|
+
// Check if the next item is a value (not another argument)
|
|
389
|
+
if (i + 1 < argv.length && !argv[i + 1].startsWith('--')) {
|
|
390
|
+
processed[argName] = argv[i + 1];
|
|
391
|
+
i++; // Skip the value in the next iteration
|
|
392
|
+
} else {
|
|
393
|
+
// Flag argument (boolean)
|
|
394
|
+
processed[argName] = true;
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
// Apply default values and type conversions
|
|
400
|
+
for (const arg of commandArgs) {
|
|
401
|
+
// Apply default values for missing arguments
|
|
402
|
+
if (processed[arg.name] === undefined && arg.default !== undefined) {
|
|
403
|
+
processed[arg.name] = arg.default;
|
|
404
|
+
continue;
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
// Skip if no value to convert
|
|
408
|
+
if (processed[arg.name] === undefined) {
|
|
409
|
+
continue;
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
// Convert values based on type
|
|
413
|
+
if (typeof processed[arg.name] === 'string') {
|
|
414
|
+
// Determine the type to convert to
|
|
415
|
+
const type = arg.type || this.inferType(arg.default);
|
|
416
|
+
|
|
417
|
+
switch (type) {
|
|
418
|
+
case CommandArgumentType.NUMBER:
|
|
419
|
+
processed[arg.name] = Number(processed[arg.name]);
|
|
420
|
+
break;
|
|
421
|
+
|
|
422
|
+
case CommandArgumentType.BOOLEAN:
|
|
423
|
+
const value = processed[arg.name].toLowerCase();
|
|
424
|
+
processed[arg.name] = value === 'true' || value === '1' || value === 'yes';
|
|
425
|
+
break;
|
|
426
|
+
|
|
427
|
+
case CommandArgumentType.ENUM:
|
|
428
|
+
// For enum types, we keep the string value but validate it in validateArgs
|
|
429
|
+
// If it's a number enum, convert to number
|
|
430
|
+
if (arg.enum && arg.enum.length > 0 && typeof arg.enum[0] === 'number') {
|
|
431
|
+
processed[arg.name] = Number(processed[arg.name]);
|
|
432
|
+
}
|
|
433
|
+
break;
|
|
434
|
+
|
|
435
|
+
case CommandArgumentType.ARRAY:
|
|
436
|
+
// Split comma-separated values into an array
|
|
437
|
+
const arrayValues = processed[arg.name].split(',').map((item: string) => item.trim());
|
|
438
|
+
|
|
439
|
+
// Convert array elements based on arrayType
|
|
440
|
+
if (arg.arrayType) {
|
|
441
|
+
processed[arg.name] = arrayValues.map((item: string) => {
|
|
442
|
+
switch (arg.arrayType) {
|
|
443
|
+
case CommandArgumentType.NUMBER:
|
|
444
|
+
return Number(item);
|
|
445
|
+
case CommandArgumentType.BOOLEAN:
|
|
446
|
+
return (
|
|
447
|
+
item.toLowerCase() === 'true' ||
|
|
448
|
+
item.toLowerCase() === '1' ||
|
|
449
|
+
item.toLowerCase() === 'yes'
|
|
450
|
+
);
|
|
451
|
+
case CommandArgumentType.ENUM:
|
|
452
|
+
// For enum arrays, validate each value in validateArgs
|
|
453
|
+
return item;
|
|
454
|
+
default:
|
|
455
|
+
return item; // Default to string
|
|
456
|
+
}
|
|
457
|
+
});
|
|
458
|
+
} else {
|
|
459
|
+
processed[arg.name] = arrayValues; // Default to string array
|
|
460
|
+
}
|
|
461
|
+
break;
|
|
462
|
+
|
|
463
|
+
case CommandArgumentType.STRING:
|
|
464
|
+
default:
|
|
465
|
+
// Keep as string
|
|
466
|
+
break;
|
|
467
|
+
}
|
|
468
|
+
}
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
return processed;
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
//#endregion
|
|
475
|
+
|
|
476
|
+
//#region UI
|
|
477
|
+
|
|
478
|
+
/**
|
|
479
|
+
* Start a progress bar in the console.
|
|
480
|
+
* @param total The total number of items to process.
|
|
481
|
+
* @param title Optional title for the progress bar.
|
|
482
|
+
*/
|
|
483
|
+
protected progress(total: number, title: string = 'Processing'): CommandProgressBar {
|
|
484
|
+
return new CommandProgressBar(total, title);
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
/**
|
|
488
|
+
* Start a spinner in the console.
|
|
489
|
+
* @param title Optional title for the spinner.
|
|
490
|
+
* @param frames Optional custom frames for the spinner animation.
|
|
491
|
+
* @param frameDelay Optional delay between frames in milliseconds.
|
|
492
|
+
*/
|
|
493
|
+
protected spinner(
|
|
494
|
+
title: string = 'Processing',
|
|
495
|
+
frames?: string[],
|
|
496
|
+
frameDelay?: number
|
|
497
|
+
): CommandSpinner {
|
|
498
|
+
return new CommandSpinner(title, frames, frameDelay);
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
//#endregion
|
|
502
|
+
|
|
503
|
+
//#region Utilities
|
|
504
|
+
|
|
505
|
+
/**
|
|
506
|
+
* Infer the type of an argument based on its default value.
|
|
507
|
+
* @param defaultValue The default value.
|
|
508
|
+
* @returns The inferred argument type.
|
|
509
|
+
*/
|
|
510
|
+
private inferType(defaultValue: any): CommandArgumentType {
|
|
511
|
+
if (defaultValue === undefined) {
|
|
512
|
+
return CommandArgumentType.STRING;
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
const type = typeof defaultValue;
|
|
516
|
+
|
|
517
|
+
switch (type) {
|
|
518
|
+
case 'number':
|
|
519
|
+
return CommandArgumentType.NUMBER;
|
|
520
|
+
case 'boolean':
|
|
521
|
+
return CommandArgumentType.BOOLEAN;
|
|
522
|
+
default:
|
|
523
|
+
return isArray(defaultValue) ? CommandArgumentType.ARRAY : CommandArgumentType.STRING;
|
|
524
|
+
}
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
//#endregion
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
/**
|
|
531
|
+
* A class for managing and displaying a progress bar in the console.
|
|
532
|
+
* @author Axel Nana <axel.nana@workbud.com>
|
|
533
|
+
*/
|
|
534
|
+
class CommandProgressBar extends InteractsWithConsole {
|
|
535
|
+
/**
|
|
536
|
+
* Whether the progress bar is currently active.
|
|
537
|
+
*/
|
|
538
|
+
#active: boolean = false;
|
|
539
|
+
|
|
540
|
+
/**
|
|
541
|
+
* The total number of steps to process.
|
|
542
|
+
*/
|
|
543
|
+
#total: number = 0;
|
|
544
|
+
|
|
545
|
+
/**
|
|
546
|
+
* The current progress value.
|
|
547
|
+
* This is incremented by the `update()` method.
|
|
548
|
+
*/
|
|
549
|
+
#current: number = 0;
|
|
550
|
+
|
|
551
|
+
/**
|
|
552
|
+
* The title of the progress bar.
|
|
553
|
+
*/
|
|
554
|
+
#title: string = '';
|
|
555
|
+
|
|
556
|
+
/**
|
|
557
|
+
* The start time of the progress bar.
|
|
558
|
+
*/
|
|
559
|
+
#startTime: number = 0;
|
|
560
|
+
|
|
561
|
+
/**
|
|
562
|
+
* Create a new progress bar instance.
|
|
563
|
+
* @param total The total number of items to process.
|
|
564
|
+
* @param title Optional title for the progress bar.
|
|
565
|
+
*/
|
|
566
|
+
constructor(total: number = 0, title: string = 'Processing') {
|
|
567
|
+
super();
|
|
568
|
+
|
|
569
|
+
this.#total = total;
|
|
570
|
+
this.#title = title;
|
|
571
|
+
|
|
572
|
+
this.#active = true;
|
|
573
|
+
this.#current = 0;
|
|
574
|
+
this.#startTime = Date.now();
|
|
575
|
+
|
|
576
|
+
// Draw initial progress bar
|
|
577
|
+
this.update(0);
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
/**
|
|
581
|
+
* Update the progress bar with a new value.
|
|
582
|
+
* @param increment The number of items to increment by (default: 1).
|
|
583
|
+
* @param newTitle Optional new title for the progress bar.
|
|
584
|
+
*/
|
|
585
|
+
public update(increment: number = 1, newTitle?: string): void {
|
|
586
|
+
if (!this.#active) return;
|
|
587
|
+
|
|
588
|
+
// Update progress
|
|
589
|
+
this.#current += increment;
|
|
590
|
+
if (newTitle) this.#title = newTitle;
|
|
591
|
+
|
|
592
|
+
// Calculate percentage
|
|
593
|
+
const percentage = Math.min(100, Math.floor((this.#current / this.#total) * 100));
|
|
594
|
+
|
|
595
|
+
// Calculate elapsed time and estimated time remaining
|
|
596
|
+
const elapsedMs = Date.now() - this.#startTime;
|
|
597
|
+
const elapsedSec = elapsedMs / 1000;
|
|
598
|
+
|
|
599
|
+
let estimatedTotalSec = 0;
|
|
600
|
+
let estimatedRemainingSec = 0;
|
|
601
|
+
let timeInfo = '';
|
|
602
|
+
|
|
603
|
+
if (this.#current > 0) {
|
|
604
|
+
estimatedTotalSec = (elapsedSec / this.#current) * this.#total;
|
|
605
|
+
estimatedRemainingSec = Math.max(0, estimatedTotalSec - elapsedSec);
|
|
606
|
+
|
|
607
|
+
timeInfo = ` | ${this.formatTime(elapsedSec)} elapsed | ${this.formatTime(estimatedRemainingSec)} remaining`;
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
// Create the progress bar
|
|
611
|
+
const barWidth = 30;
|
|
612
|
+
const completedWidth = Math.floor((percentage / 100) * barWidth);
|
|
613
|
+
const remainingWidth = barWidth - completedWidth;
|
|
614
|
+
|
|
615
|
+
const bar =
|
|
616
|
+
'[' +
|
|
617
|
+
'='.repeat(completedWidth) +
|
|
618
|
+
(remainingWidth > 0 ? '>' : '') +
|
|
619
|
+
' '.repeat(Math.max(0, remainingWidth - (remainingWidth > 0 ? 1 : 0))) +
|
|
620
|
+
']';
|
|
621
|
+
|
|
622
|
+
// Create the full progress line
|
|
623
|
+
const progressLine = `${this.#title}: ${bar} ${percentage}% (${this.#current}/${this.#total})${timeInfo}`;
|
|
624
|
+
|
|
625
|
+
// Clear the current line and write the new progress
|
|
626
|
+
this.clearLine();
|
|
627
|
+
this.write(progressLine, false);
|
|
628
|
+
|
|
629
|
+
// If we're done, add a newline
|
|
630
|
+
if (this.#current >= this.#total) {
|
|
631
|
+
this.complete();
|
|
632
|
+
}
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
/**
|
|
636
|
+
* Complete the progress bar and reset its state.
|
|
637
|
+
* @param message Optional completion message to display.
|
|
638
|
+
*/
|
|
639
|
+
public complete(message?: string): void {
|
|
640
|
+
if (!this.#active) return;
|
|
641
|
+
|
|
642
|
+
// Ensure we show 100% completion
|
|
643
|
+
this.#current = this.#total;
|
|
644
|
+
|
|
645
|
+
// Calculate final elapsed time
|
|
646
|
+
const elapsedMs = Date.now() - this.#startTime;
|
|
647
|
+
const elapsedSec = elapsedMs / 1000;
|
|
648
|
+
|
|
649
|
+
// Clear the current line
|
|
650
|
+
this.clearLine();
|
|
651
|
+
|
|
652
|
+
// Write completion message
|
|
653
|
+
if (message) {
|
|
654
|
+
this.write(`${message} (completed in ${this.formatTime(elapsedSec)})`);
|
|
655
|
+
} else {
|
|
656
|
+
this.write(`${this.#title} completed in ${this.formatTime(elapsedSec)}`);
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
// Reset progress bar state
|
|
660
|
+
this.#active = false;
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
/**
|
|
664
|
+
* Pause the progress bar temporarily to display a message.
|
|
665
|
+
* @param message The message to display.
|
|
666
|
+
*/
|
|
667
|
+
public pause(message: string): void {
|
|
668
|
+
if (!this.#active) return;
|
|
669
|
+
|
|
670
|
+
// Add a newline to move below the progress bar
|
|
671
|
+
this.newLine();
|
|
672
|
+
|
|
673
|
+
// Display the message
|
|
674
|
+
this.write(message);
|
|
675
|
+
|
|
676
|
+
// Redraw the progress bar
|
|
677
|
+
this.update(0);
|
|
678
|
+
}
|
|
679
|
+
|
|
680
|
+
/**
|
|
681
|
+
* Check if the progress bar is currently active.
|
|
682
|
+
* @returns True if the progress bar is active, false otherwise.
|
|
683
|
+
*/
|
|
684
|
+
public isActive(): boolean {
|
|
685
|
+
return this.#active;
|
|
686
|
+
}
|
|
687
|
+
|
|
688
|
+
/**
|
|
689
|
+
* Get the current progress value.
|
|
690
|
+
* @returns The current progress value.
|
|
691
|
+
*/
|
|
692
|
+
public getCurrent(): number {
|
|
693
|
+
return this.#current;
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
/**
|
|
697
|
+
* Get the total progress value.
|
|
698
|
+
* @returns The total progress value.
|
|
699
|
+
*/
|
|
700
|
+
public getTotal(): number {
|
|
701
|
+
return this.#total;
|
|
702
|
+
}
|
|
703
|
+
|
|
704
|
+
/**
|
|
705
|
+
* Get the current progress percentage.
|
|
706
|
+
* @returns The current progress percentage (0-100).
|
|
707
|
+
*/
|
|
708
|
+
public getPercentage(): number {
|
|
709
|
+
return Math.min(100, Math.floor((this.#current / this.#total) * 100));
|
|
710
|
+
}
|
|
711
|
+
}
|
|
712
|
+
|
|
713
|
+
/**
|
|
714
|
+
* A class for displaying a spinner in the console for indeterminate operations.
|
|
715
|
+
* @author Axel Nana <axel.nana@workbud.com>
|
|
716
|
+
*/
|
|
717
|
+
export class CommandSpinner extends InteractsWithConsole {
|
|
718
|
+
/**
|
|
719
|
+
* Whether the spinner is currently active.
|
|
720
|
+
*/
|
|
721
|
+
#active: boolean = false;
|
|
722
|
+
|
|
723
|
+
/**
|
|
724
|
+
* The title for the spinner.
|
|
725
|
+
*/
|
|
726
|
+
#title: string = '';
|
|
727
|
+
|
|
728
|
+
/**
|
|
729
|
+
* The start time of the spinner.
|
|
730
|
+
*/
|
|
731
|
+
#startTime: number = 0;
|
|
732
|
+
|
|
733
|
+
/**
|
|
734
|
+
* The interval ID for the spinner animation.
|
|
735
|
+
*/
|
|
736
|
+
#intervalId: Timer | null = null;
|
|
737
|
+
|
|
738
|
+
/**
|
|
739
|
+
* The current frame of the spinner.
|
|
740
|
+
*/
|
|
741
|
+
#currentFrame: number = 0;
|
|
742
|
+
|
|
743
|
+
/**
|
|
744
|
+
* The custom frames for the spinner animation.
|
|
745
|
+
*/
|
|
746
|
+
readonly #frames: string[] = [
|
|
747
|
+
'🕐',
|
|
748
|
+
'🕑',
|
|
749
|
+
'🕒',
|
|
750
|
+
'🕓',
|
|
751
|
+
'🕔',
|
|
752
|
+
'🕕',
|
|
753
|
+
'🕖',
|
|
754
|
+
'🕗',
|
|
755
|
+
'🕘',
|
|
756
|
+
'🕙',
|
|
757
|
+
'🕚',
|
|
758
|
+
'🕛'
|
|
759
|
+
];
|
|
760
|
+
|
|
761
|
+
/**
|
|
762
|
+
* The delay between frames in milliseconds.
|
|
763
|
+
*/
|
|
764
|
+
readonly #frameDelay: number = 80; // milliseconds between frames
|
|
765
|
+
|
|
766
|
+
/**
|
|
767
|
+
* Create a new spinner instance.
|
|
768
|
+
* @param title Optional title for the spinner.
|
|
769
|
+
* @param frames Optional custom frames for the spinner animation.
|
|
770
|
+
* @param frameDelay Optional delay between frames in milliseconds.
|
|
771
|
+
*/
|
|
772
|
+
constructor(title: string = 'Processing', frames?: string[], frameDelay?: number) {
|
|
773
|
+
super();
|
|
774
|
+
|
|
775
|
+
this.#title = title;
|
|
776
|
+
if (frames) this.#frames = frames;
|
|
777
|
+
if (frameDelay) this.#frameDelay = frameDelay;
|
|
778
|
+
|
|
779
|
+
if (this.#active) return;
|
|
780
|
+
|
|
781
|
+
this.#active = true;
|
|
782
|
+
this.#startTime = Date.now();
|
|
783
|
+
this.#currentFrame = 0;
|
|
784
|
+
|
|
785
|
+
// Start the animation
|
|
786
|
+
this.#intervalId = setInterval(() => {
|
|
787
|
+
this.render();
|
|
788
|
+
}, this.#frameDelay);
|
|
789
|
+
|
|
790
|
+
// Initial render
|
|
791
|
+
this.render();
|
|
792
|
+
}
|
|
793
|
+
|
|
794
|
+
/**
|
|
795
|
+
* Update the spinner with a new title.
|
|
796
|
+
* @param newTitle New title for the spinner.
|
|
797
|
+
*/
|
|
798
|
+
public update(newTitle: string): void {
|
|
799
|
+
if (!this.#active) return;
|
|
800
|
+
this.#title = newTitle;
|
|
801
|
+
}
|
|
802
|
+
|
|
803
|
+
/**
|
|
804
|
+
* Complete the spinner and reset its state.
|
|
805
|
+
* @param message Optional completion message to display.
|
|
806
|
+
* @param success Whether the operation was successful (affects the symbol shown).
|
|
807
|
+
*/
|
|
808
|
+
public complete(message?: string, success: boolean = true): void {
|
|
809
|
+
if (!this.#active) return;
|
|
810
|
+
|
|
811
|
+
// Stop the animation
|
|
812
|
+
if (this.#intervalId) {
|
|
813
|
+
clearInterval(this.#intervalId);
|
|
814
|
+
this.#intervalId = null;
|
|
815
|
+
}
|
|
816
|
+
|
|
817
|
+
// Calculate elapsed time
|
|
818
|
+
const elapsedMs = Date.now() - this.#startTime;
|
|
819
|
+
const elapsedSec = elapsedMs / 1000;
|
|
820
|
+
|
|
821
|
+
// Clear the current line
|
|
822
|
+
this.clearLine();
|
|
823
|
+
|
|
824
|
+
// Determine completion symbol
|
|
825
|
+
const time = this.formatTime(elapsedSec);
|
|
826
|
+
|
|
827
|
+
// Write the completion message
|
|
828
|
+
if (message) {
|
|
829
|
+
this[success ? 'success' : 'error'](`${message} (${time})`);
|
|
830
|
+
} else {
|
|
831
|
+
this[success ? 'success' : 'error'](`${this.#title} completed in ${time}`);
|
|
832
|
+
}
|
|
833
|
+
|
|
834
|
+
// Reset spinner state
|
|
835
|
+
this.#active = false;
|
|
836
|
+
}
|
|
837
|
+
|
|
838
|
+
/**
|
|
839
|
+
* Fail the spinner and reset its state.
|
|
840
|
+
* @param message Optional failure message to display.
|
|
841
|
+
*/
|
|
842
|
+
public fail(message?: string): void {
|
|
843
|
+
this.complete(message, false);
|
|
844
|
+
}
|
|
845
|
+
|
|
846
|
+
/**
|
|
847
|
+
* Pause the spinner temporarily to display a message.
|
|
848
|
+
* @param message The message to display.
|
|
849
|
+
*/
|
|
850
|
+
public pause(message: string): void {
|
|
851
|
+
if (!this.#active) return;
|
|
852
|
+
|
|
853
|
+
// Temporarily stop the animation
|
|
854
|
+
if (this.#intervalId) {
|
|
855
|
+
clearInterval(this.#intervalId);
|
|
856
|
+
this.#intervalId = null;
|
|
857
|
+
}
|
|
858
|
+
|
|
859
|
+
// Clear the current line
|
|
860
|
+
this.clearLine();
|
|
861
|
+
|
|
862
|
+
// Display the message
|
|
863
|
+
this.write(message);
|
|
864
|
+
|
|
865
|
+
// Restart the animation
|
|
866
|
+
this.#intervalId = setInterval(() => {
|
|
867
|
+
this.render();
|
|
868
|
+
}, this.#frameDelay);
|
|
869
|
+
}
|
|
870
|
+
|
|
871
|
+
/**
|
|
872
|
+
* Check if the spinner is currently active.
|
|
873
|
+
* @returns True if the spinner is active, false otherwise.
|
|
874
|
+
*/
|
|
875
|
+
public isActive(): boolean {
|
|
876
|
+
return this.#active;
|
|
877
|
+
}
|
|
878
|
+
|
|
879
|
+
/**
|
|
880
|
+
* Get the elapsed time since the spinner started.
|
|
881
|
+
* @returns The elapsed time in seconds.
|
|
882
|
+
*/
|
|
883
|
+
public getElapsedTime(): number {
|
|
884
|
+
return (Date.now() - this.#startTime) / 1000;
|
|
885
|
+
}
|
|
886
|
+
|
|
887
|
+
/**
|
|
888
|
+
* Render the current frame of the spinner.
|
|
889
|
+
*/
|
|
890
|
+
private render(): void {
|
|
891
|
+
if (!this.#active) return;
|
|
892
|
+
|
|
893
|
+
// Calculate elapsed time
|
|
894
|
+
const elapsedSec = this.getElapsedTime();
|
|
895
|
+
|
|
896
|
+
// Get the current frame
|
|
897
|
+
const frame = this.#frames[this.#currentFrame];
|
|
898
|
+
|
|
899
|
+
// Increment frame counter
|
|
900
|
+
this.#currentFrame = (this.#currentFrame + 1) % this.#frames.length;
|
|
901
|
+
|
|
902
|
+
// Clear the current line
|
|
903
|
+
this.clearLine();
|
|
904
|
+
|
|
905
|
+
// Write the spinner frame and title
|
|
906
|
+
this.write(`${frame} ${this.#title} (${this.formatTime(elapsedSec)})`, false);
|
|
907
|
+
}
|
|
908
|
+
}
|