@rsdk/cli.core 5.7.0-next.2 → 6.0.0-next.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.
Files changed (49) hide show
  1. package/README.MD +1 -1
  2. package/dist/cli.d.ts +17 -19
  3. package/dist/cli.js +37 -29
  4. package/dist/cli.js.map +1 -1
  5. package/dist/index.d.ts +4 -2
  6. package/dist/index.js +8 -1
  7. package/dist/index.js.map +1 -1
  8. package/dist/loaders/implementations/index.d.ts +2 -0
  9. package/dist/loaders/implementations/index.js +19 -0
  10. package/dist/loaders/implementations/index.js.map +1 -0
  11. package/dist/loaders/implementations/local-path.loader.d.ts +9 -0
  12. package/dist/loaders/implementations/local-path.loader.js +49 -0
  13. package/dist/loaders/implementations/local-path.loader.js.map +1 -0
  14. package/dist/loaders/implementations/pkg.loader.d.ts +11 -0
  15. package/dist/loaders/implementations/pkg.loader.js +31 -0
  16. package/dist/loaders/implementations/pkg.loader.js.map +1 -0
  17. package/dist/loaders/index.d.ts +1 -0
  18. package/dist/loaders/index.js +18 -0
  19. package/dist/loaders/index.js.map +1 -0
  20. package/dist/loaders/loader.interface.d.ts +5 -0
  21. package/dist/loaders/loader.interface.js +3 -0
  22. package/dist/loaders/loader.interface.js.map +1 -0
  23. package/dist/resolvers/command.resolver.d.ts +27 -0
  24. package/dist/resolvers/command.resolver.js +81 -0
  25. package/dist/resolvers/command.resolver.js.map +1 -0
  26. package/dist/resolvers/options.resolver.d.ts +35 -0
  27. package/dist/resolvers/options.resolver.js +80 -0
  28. package/dist/resolvers/options.resolver.js.map +1 -0
  29. package/jest.config.js +1 -0
  30. package/jest.config.unit.js +1 -0
  31. package/package.json +19 -6
  32. package/src/cli.ts +52 -38
  33. package/src/index.ts +11 -3
  34. package/src/loaders/implementations/index.ts +2 -0
  35. package/src/loaders/implementations/local-path.loader.ts +53 -0
  36. package/src/loaders/implementations/pkg.loader.ts +33 -0
  37. package/src/loaders/index.ts +1 -0
  38. package/src/loaders/loader.interface.ts +6 -0
  39. package/src/resolvers/command.resolver.ts +103 -0
  40. package/src/resolvers/options.resolver.ts +92 -0
  41. package/test/cli.spec.ts +131 -0
  42. package/test/commands/command.single.ts +30 -0
  43. package/test/commands/command.with-all.ts +93 -0
  44. package/test/commands/command.with-subcommands.ts +55 -0
  45. package/test/commands/command.with-variations.ts +55 -0
  46. package/test/commands/js/command.single.js +47 -0
  47. package/test/commands-resolver.spec.ts +125 -0
  48. package/test/loaders.spec.ts +61 -0
  49. package/test/options-resolver.spec.ts +119 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rsdk/cli.core",
3
- "version": "5.7.0-next.2",
3
+ "version": "6.0.0-next.0",
4
4
  "description": "entry package to cli functions",
5
5
  "main": "dist/index.js",
6
6
  "publishConfig": {
@@ -12,15 +12,28 @@
12
12
  ],
13
13
  "license": "Apache License 2.0",
14
14
  "dependencies": {
15
- "@rsdk/cli.common": "5.7.0-next.2",
16
- "@rsdk/logging": "5.7.0-next.2",
17
- "commander": "^12.1.0"
15
+ "@rsdk/cli.common": "6.0.0-next.0",
16
+ "@rsdk/common": "6.0.0-next.0",
17
+ "@rsdk/env": "6.0.0-next.0",
18
+ "@rsdk/logging": "6.0.0-next.0",
19
+ "commander": "^12.1.0",
20
+ "lodash": "^4.17.21"
18
21
  },
19
22
  "peerDependencies": {
20
23
  "@rsdk/cli.common": "*",
21
24
  "@rsdk/logging": "*",
22
25
  "commander": "^12.1.0",
23
- "reflect-metadata": "^0.1.12 || ^0.2.0"
26
+ "reflect-metadata": "^0.1.12 || ^0.2.0",
27
+ "ts-node": "10.9.2",
28
+ "typescript": "5.7.3"
29
+ },
30
+ "peerDependenciesMeta": {
31
+ "ts-node": {
32
+ "optional": true
33
+ },
34
+ "typescript": {
35
+ "optional": true
36
+ }
24
37
  },
25
- "gitHead": "c85fca0b594a2ea168d06f1d2941a4fb05b59440"
38
+ "gitHead": "215cccea23d95118dd8b6af3ce11c6a35ce19c30"
26
39
  }
package/src/cli.ts CHANGED
@@ -1,54 +1,68 @@
1
- import type { RsdkCliCommand } from '@rsdk/cli.common';
2
- import { program } from 'commander';
1
+ import type { IRunnable } from '@rsdk/cli.common';
2
+ import { MetadataRegistry } from '@rsdk/cli.common';
3
+ import { Assert, type Constructor } from '@rsdk/common';
4
+ import { Env } from '@rsdk/env';
5
+ import type { ILogger } from '@rsdk/logging';
6
+ import type { Command } from 'commander';
7
+
8
+ import { CommandResolver } from './resolvers/command.resolver';
9
+ import { OptionsResolver } from './resolvers/options.resolver';
10
+ import { LocalPathLoader, PkgLoader } from './loaders';
11
+
12
+ export interface CliOptions {
13
+ plugins?: (string | { command: any; cmd: string })[];
14
+ discoverPlugins?: boolean;
15
+ cliPackageName: string;
16
+ }
3
17
 
4
18
  export class Cli {
5
19
  constructor(
6
- readonly opts?: {
7
- plugins?: (string | { command: RsdkCliCommand; cmd: string })[];
8
- discoverPlugins?: boolean;
9
- cliPackageName: string;
10
- },
20
+ private readonly program: Command,
21
+ private readonly logger: ILogger,
22
+ private readonly opts?: CliOptions,
11
23
  ) {}
12
24
 
13
- async handle(argv: string[]): Promise<void> {
14
- const rsdkCommand = argv.slice(2)[0];
25
+ async handle(arg: string | undefined): Promise<void> {
26
+ try {
27
+ if (!arg || arg.startsWith('-')) {
28
+ await this.program.parseAsync();
29
+ return;
30
+ }
15
31
 
16
- if (!rsdkCommand || rsdkCommand.startsWith('-')) {
17
- program.parse();
18
- return;
19
- }
20
- const cmd = rsdkCommand.split(':')[0];
32
+ const env = new Env();
33
+ const optionsResolver = new OptionsResolver(env, this.logger);
34
+ const commandResolver = new CommandResolver(optionsResolver, this.logger);
21
35
 
22
- const rsdkCliCommandName = this.getCmdPackageName(cmd);
23
- let rsdkCliCommand;
36
+ const command = await this.loadCommand(arg);
24
37
 
25
- try {
26
- // eslint-disable-next-line @typescript-eslint/no-var-requires
27
- rsdkCliCommand = require(rsdkCliCommandName);
38
+ const { subcommands, variations } = MetadataRegistry.getCommand(command);
39
+
40
+ this.program.addCommand(commandResolver.resolve(command, subcommands));
41
+
42
+ for (const command of variations || []) {
43
+ this.program.addCommand(commandResolver.resolve(command));
44
+ }
45
+
46
+ await this.program.parseAsync();
28
47
  } catch (error) {
29
- throw new Error(
30
- 'Error on require module, try install it `yarn add -D ' +
31
- rsdkCliCommandName,
32
- {
33
- cause: error,
34
- },
35
- );
36
- }
37
- if (!('Cmd' in rsdkCliCommand)) {
38
- throw new Error('Invalid cli cmd package', {
39
- cause: { rsdkCliCommand, rsdkCliCommandName },
40
- });
48
+ Assert.isError(error);
49
+ this.logger.error(error);
41
50
  }
42
- const cmds = rsdkCliCommand.Cmd.getCommands();
51
+ }
43
52
 
44
- for (const cmd of cmds) {
45
- program.addCommand(cmd);
46
- }
53
+ private async loadCommand(
54
+ rsdkCommand: string,
55
+ ): Promise<Constructor<IRunnable>> {
56
+ const [cmd] = rsdkCommand.split(':');
57
+
58
+ const loader = this.isLocalPath(cmd)
59
+ ? new LocalPathLoader()
60
+ : new PkgLoader(this.opts?.cliPackageName);
47
61
 
48
- program.parse();
62
+ return await loader.load(cmd);
49
63
  }
50
64
 
51
- private getCmdPackageName(cmd: string): string {
52
- return [this.opts?.cliPackageName, 'cmd', cmd].join('.');
65
+ private isLocalPath(cmd: string): boolean {
66
+ return cmd.startsWith('./') || cmd.startsWith('/') || cmd.includes('\\');
53
67
  }
54
68
  }
package/src/index.ts CHANGED
@@ -1,4 +1,5 @@
1
- import type { RsdkCliCommand } from '@rsdk/cli.common';
1
+ import { LoggerFactory } from '@rsdk/logging';
2
+ import { program } from 'commander';
2
3
  import path from 'node:path';
3
4
 
4
5
  import './reconfigure-logger';
@@ -14,13 +15,20 @@ const getCliPackageName = (): string => {
14
15
  ).name;
15
16
  };
16
17
 
18
+ /**
19
+ * Выполняет команду
20
+ */
17
21
  export const execute = async (opts?: {
18
- plugins?: (string | { command: RsdkCliCommand; cmd: string })[];
22
+ plugins?: (string | { command: any; cmd: string })[];
19
23
  discoverPlugins?: boolean;
20
24
  scope?: string;
21
25
  }): Promise<void> => {
22
26
  const cliPackageName = opts?.scope ?? getCliPackageName();
23
27
 
24
28
  process.title = cliPackageName.split('/')[0].slice(1);
25
- await new Cli({ ...opts, cliPackageName }).handle(process.argv);
29
+
30
+ const logger = LoggerFactory.create(cliPackageName);
31
+ const cli = new Cli(program, logger, { ...opts, cliPackageName });
32
+
33
+ await cli.handle(process.argv.at(2));
26
34
  };
@@ -0,0 +1,2 @@
1
+ export * from './local-path.loader';
2
+ export * from './pkg.loader';
@@ -0,0 +1,53 @@
1
+ import type { Constructor } from '@rsdk/common';
2
+ import path from 'node:path';
3
+
4
+ import type { ICommandLoader } from '../loader.interface';
5
+
6
+ /**
7
+ * Загружает команды из файла
8
+ */
9
+ export class LocalPathLoader implements ICommandLoader {
10
+ async load(filename: string): Promise<Constructor> {
11
+ const resolvedPath = path.isAbsolute(filename)
12
+ ? filename
13
+ : path.resolve(process.cwd(), filename);
14
+
15
+ try {
16
+ // Регистрируем ts-node до импорта файла
17
+ if (resolvedPath.endsWith('.ts')) {
18
+ this.registerTsNode(resolvedPath);
19
+ }
20
+
21
+ const { default: command } = await require(resolvedPath);
22
+
23
+ return command;
24
+ } catch (error) {
25
+ throw new Error(`Error loading command from file: ${resolvedPath}`, {
26
+ cause: error,
27
+ });
28
+ }
29
+ }
30
+
31
+ private registerTsNode(resolvedPath: string): void {
32
+ /* eslint-disable @typescript-eslint/no-var-requires */
33
+ const tsnode = require('ts-node');
34
+ const typescript = require('typescript');
35
+ /* eslint-enable @typescript-eslint/no-var-requires */
36
+
37
+ // Ищем ближайший tsconfig.json
38
+ const tsconfigPath = typescript.findConfigFile(
39
+ path.dirname(resolvedPath),
40
+ typescript.sys.fileExists,
41
+ );
42
+
43
+ tsnode.register({
44
+ transpileOnly: true, // Для более быстрой компиляции
45
+ project: tsconfigPath || undefined, // Используем найденный tsconfig или undefined
46
+ compilerOptions: {
47
+ // Базовые опции, которые будут перезаписаны настройками из tsconfig
48
+ module: 'commonjs',
49
+ target: 'es2017',
50
+ },
51
+ });
52
+ }
53
+ }
@@ -0,0 +1,33 @@
1
+ import type { IRunnable } from '@rsdk/cli.common';
2
+ import type { Constructor } from '@rsdk/common';
3
+
4
+ import type { ICommandLoader } from '../loader.interface';
5
+
6
+ /**
7
+ * Загружает команды из пакета
8
+ */
9
+ export class PkgLoader implements ICommandLoader {
10
+ constructor(private readonly cliPackageName?: string) {}
11
+
12
+ async load(cmd: string): Promise<Constructor<IRunnable>> {
13
+ /* Пример вызова из консоли:
14
+ * $ rsdk init
15
+ * где:
16
+ * cliPackageName = '@rsdk/cli'
17
+ * cmd = 'init'
18
+ * pkg = '@rsdk/cli.cmd.init'
19
+ */
20
+ const pkg = [this.cliPackageName, 'cmd', cmd].join('.');
21
+
22
+ try {
23
+ const { default: command } = await require(pkg);
24
+
25
+ return command;
26
+ } catch (error) {
27
+ throw new Error(
28
+ `Error loading package, try install it: yarn add -D ${pkg}`,
29
+ { cause: error },
30
+ );
31
+ }
32
+ }
33
+ }
@@ -0,0 +1 @@
1
+ export * from './implementations';
@@ -0,0 +1,6 @@
1
+ import type { IRunnable } from '@rsdk/cli.common';
2
+ import type { Constructor } from '@rsdk/common';
3
+
4
+ export interface ICommandLoader {
5
+ load(cmd: string): Promise<Constructor<IRunnable>>;
6
+ }
@@ -0,0 +1,103 @@
1
+ import type { Type } from '@nestjs/common';
2
+ import type {
3
+ IRunnable,
4
+ OptionMetadata,
5
+ ParameterIndex,
6
+ } from '@rsdk/cli.common';
7
+ import { MetadataRegistry } from '@rsdk/cli.common';
8
+ import type { Constructor } from '@rsdk/common';
9
+ import type { ILogger } from '@rsdk/logging';
10
+ import { Command } from 'commander';
11
+ import _ from 'lodash';
12
+
13
+ import type { OptionsResolver } from './options.resolver';
14
+
15
+ /**
16
+ * Класс для преобразования классов команд в стиле rsdk в объекты Commander.Command.
17
+ * Обрабатывает метаданные команд, опций и подкоманд,
18
+ * создавая соответствующую структуру.
19
+ */
20
+ export class CommandResolver {
21
+ constructor(
22
+ private readonly optionsResolver: OptionsResolver,
23
+ private readonly logger: ILogger,
24
+ ) {}
25
+
26
+ /**
27
+ * Преобразует класс с декоратором @Cmd в объект Commander.Command.
28
+ * Создает команду с опциями и подкомандами на основе метаданных класса.
29
+ *
30
+ * @param {Type} ctor - Конструктор класса команды
31
+ * @returns {Command} Объект Commander.Command с настроенными опциями и подкомандами
32
+ * @throws {Error} Если не найдены метаданные команды (отсутствует декоратор @Cmd)
33
+ */
34
+ resolve(
35
+ ctor: Constructor<IRunnable>,
36
+ subcommands: Constructor<IRunnable>[] = [],
37
+ ): Command {
38
+ this.logger.trace('resolving command', { ctor });
39
+ this.assertNoParams(ctor);
40
+
41
+ const command = this.createCommand(ctor);
42
+
43
+ for (const subcommand of subcommands) {
44
+ command.addCommand(this.createCommand(subcommand));
45
+ }
46
+
47
+ return command;
48
+ }
49
+
50
+ private createCommand(ctor: Type): Command {
51
+ // Duplication
52
+ const commandMetadata = MetadataRegistry.getRunnable(ctor);
53
+
54
+ const instance = Reflect.construct(ctor, []);
55
+ if (typeof instance['run'] !== 'function') {
56
+ throw new TypeError('Command must implement "run" method');
57
+ }
58
+
59
+ const { name, description } = commandMetadata;
60
+ const command = new Command(name).description(description);
61
+
62
+ const options = MetadataRegistry.getOptions(ctor);
63
+
64
+ for (const option of options.values()) {
65
+ const envKey = this.optionsResolver.createEnvKey(option);
66
+ const optionName = `--${option.name}${option.parser ? ' <value>' : ''}`;
67
+ const optionDescription = `${option.description} [eq ${envKey} in env]`;
68
+
69
+ command.option(optionName, optionDescription, option.defaultValue);
70
+ }
71
+
72
+ command.action(this.createHandler(options, instance['run'].bind(instance)));
73
+
74
+ return command;
75
+ }
76
+
77
+ private createHandler(
78
+ optionsByIndex: Map<ParameterIndex, OptionMetadata>,
79
+ method: (...args: any[]) => any,
80
+ ) {
81
+ return (__: any, cmd: Command): any => {
82
+ /* Преобразуем опции в массив аргументов для вызова метода:
83
+ * 1. Конвертируем Map в массив пар [индекс, метаданные]
84
+ * 2. Получаем значения опций через OptionsResolver
85
+ * (с учетом приоритета источников)
86
+ * 3. Сортируем по индексу для правильного порядка аргументов
87
+ */
88
+ const args = _([...optionsByIndex])
89
+ .map(([index, metadata]) => ({ index, metadata }))
90
+ .sortBy((x) => x.index)
91
+ .map((x) => this.optionsResolver.resolve(x.metadata, cmd.opts()));
92
+
93
+ return method(...args.value());
94
+ };
95
+ }
96
+
97
+ private assertNoParams(ctor: Type): void {
98
+ const paramTypes = Reflect.getMetadata('design:paramtypes', ctor);
99
+ if (paramTypes?.length > 0) {
100
+ throw new Error('Command constructor must have no parameters');
101
+ }
102
+ }
103
+ }
@@ -0,0 +1,92 @@
1
+ import type { OptionMetadata } from '@rsdk/cli.common';
2
+ import { BoolParser, Case } from '@rsdk/common';
3
+ import type { Env } from '@rsdk/env';
4
+ import type { ILogger } from '@rsdk/logging';
5
+ import type { OptionValues } from 'commander';
6
+ import _ from 'lodash';
7
+
8
+ /**
9
+ * Класс для разрешения значений опций командной строки.
10
+ * Обрабатывает значения из трех источников: переменные окружения, флаги командной строки и значения по умолчанию.
11
+ */
12
+ export class OptionsResolver {
13
+ constructor(
14
+ private readonly env: Env,
15
+ private readonly logger: ILogger,
16
+ ) {}
17
+
18
+ /**
19
+ * Разрешает значение опции командной строки на основе метаданных и переданных параметров.
20
+ * Проверяет три источника значений в порядке приоритета:
21
+ * 1. Переменные окружения
22
+ * 2. Флаги командной строки
23
+ * 3. Значения по умолчанию
24
+ *
25
+ * Такой приоритет выбран для удобства конфигурирования в разных окружениях:
26
+ * - В локальной разработке проблем с ENV обычно не возникает
27
+ * - В Kubernetes удобнее редактировать ConfigMap, чем Deployment
28
+ * @param {OptionMetadata} optionMetadata - Метаданные опции, содержащие имя, парсер и значение по умолчанию
29
+ * @param {OptionValues} passedOptions - Объект с переданными опциями командной строки
30
+ * @returns {any} Разрешенное значение опции
31
+ */
32
+ resolve(optionMetadata: OptionMetadata, passedOptions: OptionValues): any {
33
+ this.logger.trace('handle arg', { optionMetadata });
34
+
35
+ const parser = optionMetadata.parser ?? new BoolParser();
36
+
37
+ // Находим значение переменной окружения
38
+ const envKey = this.createEnvKey(optionMetadata);
39
+ const variable = this.env.optional(envKey, parser);
40
+
41
+ // Находим значение переданное в виде флага
42
+ const optsKey = _.camelCase(optionMetadata.name);
43
+ const isPassed = Object.hasOwn(passedOptions, optsKey);
44
+ const valueFromFlag = passedOptions[optsKey];
45
+
46
+ if (variable) {
47
+ if (isPassed) {
48
+ this.logger.warn(
49
+ `Environment variable ${envKey} overrides command flags`,
50
+ );
51
+ }
52
+
53
+ // Возвращаем значение переменной окружения
54
+ return variable.value;
55
+ }
56
+
57
+ this.logger.trace(`Environment variable ${envKey} not provided`);
58
+
59
+ if (isPassed) {
60
+ this.logger.trace(`Use flag value: ${valueFromFlag}`, {
61
+ key: optsKey,
62
+ opts: passedOptions,
63
+ });
64
+
65
+ return parser.parse(valueFromFlag);
66
+ }
67
+
68
+ const defaultValue = optionMetadata.defaultValue;
69
+
70
+ this.logger.trace(`Use default value: ${defaultValue}`, {
71
+ key: optsKey,
72
+ opts: passedOptions,
73
+ });
74
+
75
+ /**
76
+ * undefined - вполне нормально значение по умолчанию
77
+ */
78
+ return defaultValue === undefined ? undefined : parser.parse(defaultValue);
79
+ }
80
+
81
+ /**
82
+ * Создает ключ переменной окружения на основе метаданных опции.
83
+ *
84
+ * @param {OptionMetadata} optionMetadata - Метаданные опции, содержащие имя и стратегию.
85
+ * @returns {string} Ключ переменной окружения в формате верхнего регистра с подчеркиванием.
86
+ */
87
+ createEnvKey(optionMetadata: OptionMetadata): string {
88
+ return Case.toUpperSnake(
89
+ `${optionMetadata.name}${optionMetadata.parser ? '' : '_enabled'}`,
90
+ );
91
+ }
92
+ }
@@ -0,0 +1,131 @@
1
+ import type { ILogger } from '@rsdk/logging';
2
+ import { NoopLogger } from '@rsdk/logging';
3
+ import { Command } from 'commander';
4
+
5
+ import { Cli } from '../src/cli';
6
+
7
+ import WithAll from './commands/command.with-all';
8
+
9
+ describe('Cli', () => {
10
+ let logger: ILogger;
11
+ let program: Command;
12
+ let cli: Cli;
13
+
14
+ beforeEach(() => {
15
+ logger = new NoopLogger();
16
+ program = new Command();
17
+
18
+ // eslint-disable-next-line unicorn/no-useless-undefined
19
+ program.parseAsync = jest.fn().mockResolvedValue(undefined);
20
+
21
+ cli = new Cli(program, logger, {
22
+ cliPackageName: '@rsdk/cli.core',
23
+ });
24
+ });
25
+
26
+ beforeEach(() => {
27
+ // Переопределяем program перед каждым тестом
28
+ jest.resetModules();
29
+ });
30
+
31
+ describe('handle', () => {
32
+ describe('when loading command with subcommands and variations', () => {
33
+ beforeEach(async () => {
34
+ const mockLoadCommand = jest.spyOn(cli as any, 'loadCommand');
35
+
36
+ mockLoadCommand.mockResolvedValue(WithAll);
37
+
38
+ await cli.handle('with-all');
39
+ });
40
+
41
+ it('should load command correctly', () => {
42
+ const [mainCommand] = program.commands;
43
+
44
+ expect(mainCommand.name()).toBe('with-all');
45
+ expect(mainCommand.description()).toBe(
46
+ 'Test command with subcommands and variations',
47
+ );
48
+ });
49
+
50
+ it('should register variations correctly', () => {
51
+ const [, ...variations] = program.commands;
52
+
53
+ expect(variations).toHaveLength(2);
54
+
55
+ const firstVariation = variations.find(
56
+ (cmd) => cmd.name() === 'with-variations:first',
57
+ );
58
+
59
+ expect(firstVariation).toBeDefined();
60
+ expect(firstVariation?.description()).toBe('First variation example');
61
+
62
+ const secondVariation = variations.find(
63
+ (cmd) => cmd.name() === 'with-variations:second',
64
+ );
65
+
66
+ expect(secondVariation).toBeDefined();
67
+ expect(secondVariation?.description()).toBe('Second variation example');
68
+ });
69
+
70
+ it('should register subcommands correctly', () => {
71
+ const [mainCommand] = program.commands;
72
+ const subcommands = mainCommand.commands.filter(
73
+ (cmd) => cmd.name() !== 'with-all',
74
+ );
75
+
76
+ expect(subcommands).toHaveLength(2);
77
+
78
+ const first = subcommands.find(
79
+ (cmd) => cmd.name() === 'first-subcommand',
80
+ );
81
+
82
+ expect(first).toBeDefined();
83
+ expect(first?.description()).toBe('First subcommand example');
84
+
85
+ const second = subcommands.find(
86
+ (cmd) => cmd.name() === 'second-subcommand',
87
+ );
88
+
89
+ expect(second).toBeDefined();
90
+ expect(second?.description()).toBe('Second subcommand example');
91
+ });
92
+ });
93
+
94
+ describe('error handling', () => {
95
+ it('should handle command loading errors', async () => {
96
+ // Arrange
97
+ const error = new Error('Command load failed');
98
+ const mockLoadCommand = jest.spyOn(cli as any, 'loadCommand');
99
+
100
+ mockLoadCommand.mockRejectedValue(error);
101
+
102
+ // Act
103
+ await cli.handle('invalid-command');
104
+
105
+ expect(program.parseAsync).not.toHaveBeenCalled();
106
+ });
107
+
108
+ it('should skip command loading when no arg provided', async () => {
109
+ // Arrange
110
+ const mockLoadCommand = jest.spyOn(cli as any, 'loadCommand');
111
+
112
+ // Act
113
+ // eslint-disable-next-line unicorn/no-useless-undefined
114
+ await cli.handle(undefined);
115
+
116
+ expect(mockLoadCommand).not.toHaveBeenCalled();
117
+ });
118
+
119
+ it('should skip command loading when arg starts with -', async () => {
120
+ // Arrange
121
+ const mockLoadCommand = jest.spyOn(cli as any, 'loadCommand');
122
+
123
+ // Act
124
+ await cli.handle('--help');
125
+
126
+ expect(mockLoadCommand).not.toHaveBeenCalled();
127
+ expect(program.commands).toHaveLength(0);
128
+ });
129
+ });
130
+ });
131
+ });
@@ -0,0 +1,30 @@
1
+ import { Command, IRunnable, Option, ValueOption } from '@rsdk/cli.common';
2
+ import { StringParser } from '@rsdk/common';
3
+ import { LoggerFactory } from '@rsdk/logging';
4
+
5
+ @Command('cmd-single', {
6
+ description: 'Test command that logs parameters',
7
+ })
8
+ export default class SingleCommand implements IRunnable {
9
+ async run(
10
+ @ValueOption('message', new StringParser(), {
11
+ description: 'Message to log',
12
+ defaultValue: 'Hello World!',
13
+ alias: 'm',
14
+ })
15
+ message: string,
16
+
17
+ @Option('verbose', { description: 'Verbose output' })
18
+ verbose: boolean,
19
+ ): Promise<void> {
20
+ const logger = LoggerFactory.create(SingleCommand);
21
+
22
+ if (verbose) {
23
+ logger.info('Command parameters:');
24
+ logger.info(`message: ${message}`);
25
+ logger.info(`verbose: ${verbose}`);
26
+ }
27
+
28
+ logger.info(message);
29
+ }
30
+ }