Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

RegisterCommands method #706

Merged
merged 8 commits into from
Dec 30, 2022
5 changes: 5 additions & 0 deletions .changeset/popular-dodos-trade.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'nest-commander': minor
---

Add new api registerWithSubCommand to CommandRunner Class
19 changes: 19 additions & 0 deletions apps/docs/src/pages/en/api.md
Original file line number Diff line number Diff line change
Expand Up @@ -228,6 +228,25 @@ Options that can be passed to the `run` or `runWithoutClosing` method to modify
| usePlugins | `boolean` | false | The choice of if the built CLI should look for a config file and plugins or not. |
| cliName | `string` | false | The name of the CLI and the prefix for the config file to be looked for. Defaults to `"nest-commander"`. |

### CommandRunner

The `CommandRunner` is abstract class to define your command. You define the command in the class inherits it.

#### registerWithSubCommands

A static method that returns a list of the root command class, which calls this api, and all sub command classes set via the metadata of the `@Command()` and `@SubCommand()` decorators in the scope of module tree that the root command class traverses.

```typescript title="src/app.module.ts"
@Module({
providers: [...RunCommand.regsiterWithSubCommands()]
})
export class AppModule {}
```

| Parameter | Type | Required | Description |
| --- | --- | --- | --- |
| meta | `string` | false | `@Command` or `@SubCommand` decorator identifier is explicitly specified for extracting metadata,<br/> It is used internal this library and you shouldn't need to specify it normally. |

## nest-commander-testing

### CommandTestFactory
Expand Down
26 changes: 24 additions & 2 deletions apps/docs/src/pages/en/features/commander.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ export class TaskRunner extends CommandRunner {

You'll notice for the `arguments` we use angle brackets around the argument name. This specifies that the argument is required for the command, and commander will throw an error if it is not supplied. If, however, we wanted to make the argument optional, we could wrap it in square brackets instead like `[task]`.

Now, to run this command, we'll need to set up the [CommandFactory](./factory.md) and make use of one of the execution methods as described later in the docs. For now, we'll just assume this application is installed globally under the `crun` name. Running the above command would then look like
Now, to run this command, we'll need to set up the [CommandFactory](./factory) and make use of one of the execution methods as described later in the docs. For now, we'll just assume this application is installed globally under the `crun` name. Running the above command would then look like

```shell
crun my-exec 'echo Hello World!'
Expand Down Expand Up @@ -97,7 +97,7 @@ Options also allow for variadic inputs but you will need to provide an option pa

### Setting Choices for your Options

Commander also allows us to set up pre-defined choices for options. To do so we have two options: setting the `choices` array directly as a part of the `@Option()` decorator, or using the `@OptionChoiceFor()` decorator and a class method, similar to the [InquirerService](./inquirer.md). With using the `@OptionChoiceFor()` decorator, we are also able to make use of class providers that are injected into the command via Nest's DI which allows devs to read for the choices from a file or database if that happens to be necessary.
Commander also allows us to set up pre-defined choices for options. To do so we have two options: setting the `choices` array directly as a part of the `@Option()` decorator, or using the `@OptionChoiceFor()` decorator and a class method, similar to the [InquirerService](./inquirer). With using the `@OptionChoiceFor()` decorator, we are also able to make use of class providers that are injected into the command via Nest's DI which allows devs to read for the choices from a file or database if that happens to be necessary.

```typescript
import { Option, OptionChoiceFor } from 'nest-commander';
Expand Down Expand Up @@ -231,3 +231,25 @@ And now the `TaskRunner` is setup and ready to be used.
The above command is meant to be a basic example, and should not be taken as a fully fleshed out CLI example. There's error handling, input validation, and security that should all be considered. Please do not use the above command in a production environment without adding the mentioned necessities **at the very least**.

:::

## Register Commands

Though you'll find the implementation details in the [factory page](./factory), you must register all of your commands including the sub commands as providers in a module class that the `CommandFactory` ends up registering. For convenience, given that we register examples in Sub Commands section and set them as providers in `app.module.ts` that set as root module to `CommandFactory`.

```typescript title="src/app.module.ts"
@Module({
providers: [RunCommand, FooCommand]
})
export class AppModule {}
```

If you have many sub commands and nested directories for that, it may feel tough to import all of them. For this case, the static `regsiterWithSubCommands` method is available in all classes inheriting `CommandRunner` which returns a list of itself and all sub commands. This means you can write the setting like followed by example instead of the previous example.

```typescript title="src/app.module.ts"
@Module({
providers: [...RunCommand.regsiterWithSubCommands()]
})
export class AppModule {}
```

This example works even if the `RunCommand` has more and nested subcommands and doesn't interfare with registering other providers or commands if using spread operator or concat method of `Array`.
4 changes: 2 additions & 2 deletions apps/docs/src/pages/en/features/factory.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ Okay, so you've got this fancy command set up, it takes in some user input, and

## Registering Your Commands and Questions

You may have noticed in the [Inquirer](./inquirer.md) section a quick mention of adding the question set class to the `providers`. In fact, both command classes and question set classes are nothing more than specialized providers! Due to this, we can simply add these classes to a module's metadata and make sure that module is in the root module the `CommandFactory` uses.
You may have noticed in the [Inquirer](./inquirer) section a quick mention of adding the question set class to the `providers`. In fact, both command classes and question set classes are nothing more than specialized providers! Due to this, we can simply add these classes to a module's metadata and make sure that module is in the root module the `CommandFactory` uses.

```typescript title="src/app.module.ts"
@Module({
Expand Down Expand Up @@ -40,4 +40,4 @@ By default, there is no error handler for commander provided by `nest-commander`

The `CommandFactory` also allows you to set up an infinite runner, so that you can set up file watchers or similar. All you need to do is instead of using `run` use `runWithoutClosing`. All other options are the same.

For more information on the `CommandFactory`, please refer to the [API docs](../api.md#commandfactory).
For more information on the `CommandFactory`, please refer to the [API docs](../api#commandfactory).
2 changes: 1 addition & 1 deletion apps/docs/src/pages/en/features/inquirer.md
Original file line number Diff line number Diff line change
Expand Up @@ -91,4 +91,4 @@ If you need to ask a question dynamically, not something that can be set up with

:::

Visit the [api docs](../api.md) to learn more about the `InquirerService`'s `ask` command and extra decorators.
Visit the [api docs](../api) to learn more about the `InquirerService`'s `ask` command and extra decorators.
2 changes: 2 additions & 0 deletions integration/index.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import { SubCommandSuite } from './sub-commands/test/sub-commands.spec';
import { ThisCommandHandlerSuite } from './this-command/test/this-command.command.spec';
import { ThisOptionHandlerSuite } from './this-handler/test/this-handler.command.spec';
import { SetQuestionSuite } from './with-questions/test/hello.command.spec';
import { RegisterWithSubCommandsSuite } from './register-provider/test/register-with-subcommands.spec';

BasicFactorySuite.run();
StringCommandSuite.run();
Expand All @@ -31,3 +32,4 @@ ThisOptionHandlerSuite.run();
SetQuestionSuite.run();
OptionChoiceSuite.run();
DotCommandSuite.run();
RegisterWithSubCommandsSuite.run();
14 changes: 14 additions & 0 deletions integration/register-provider/src/bottom.command.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import { CommandRunner, SubCommand } from 'nest-commander';

import { LogService } from '../../common/log.service';

@SubCommand({ name: 'bottom' })
export class BottomCommand extends CommandRunner {
constructor(private readonly log: LogService) {
super();
}

async run() {
this.log.log('top mid-1 bottom command');
}
}
15 changes: 15 additions & 0 deletions integration/register-provider/src/mid-1.command.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import { CommandRunner, SubCommand } from 'nest-commander';

import { LogService } from '../../common/log.service';
import { BottomCommand } from './bottom.command';

@SubCommand({ name: 'mid-1', subCommands: [BottomCommand] })
export class Mid1Command extends CommandRunner {
constructor(private readonly log: LogService) {
super();
}

async run() {
this.log.log('top mid-1 command');
}
}
14 changes: 14 additions & 0 deletions integration/register-provider/src/mid-2.command.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import { CommandRunner, SubCommand } from 'nest-commander';

import { LogService } from '../../common/log.service';

@SubCommand({ name: 'mid-2', aliases: ['m'] })
export class Mid2Command extends CommandRunner {
constructor(private readonly log: LogService) {
super();
}

async run() {
this.log.log('top mid-2 command');
}
}
9 changes: 9 additions & 0 deletions integration/register-provider/src/nested.module.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import { Module } from '@nestjs/common';

import { LogService } from '../../common/log.service';
import { TopCommand } from './top.command';

@Module({
providers: [LogService, ...TopCommand.registerWithSubCommands()],
})
export class NestedModule {}
17 changes: 17 additions & 0 deletions integration/register-provider/src/top.command.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import { Command, CommandRunner } from 'nest-commander';

import { LogService } from '../../common/log.service';
import { Mid1Command } from './mid-1.command';
import { Mid2Command } from './mid-2.command';

@Command({ name: 'top', arguments: '[name]', subCommands: [Mid1Command, Mid2Command] })
export class TopCommand extends CommandRunner {
constructor(private readonly log: LogService) {
super();
}

async run(inputs: string[]) {
this.log.log('top command');
this.log.log(inputs);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
import { TestingModule } from '@nestjs/testing';
import { Stub, stubMethod } from 'hanbi';
import { CommandTestFactory } from 'nest-commander-testing';
import { suite } from 'uvu';
import { equal } from 'uvu/assert';
import { LogService } from '../../common/log.service';
import { NestedModule } from '../src/nested.module';

export const RegisterWithSubCommandsSuite = suite<{
logMock: Stub<typeof console.log>;
exitMock: Stub<typeof process.exit>;
commandInstance: TestingModule;
}>('Register With SubCommands Suite');
RegisterWithSubCommandsSuite.before(async (context) => {
context.exitMock = stubMethod(process, 'exit');
context.logMock = stubMethod(console, 'log');
context.commandInstance = await CommandTestFactory.createTestingCommand({
imports: [NestedModule],
})
.overrideProvider(LogService)
.useValue({
log: context.logMock.handler,
})
.compile();
});
RegisterWithSubCommandsSuite.after.each(({ logMock, exitMock }) => {
logMock.reset();
exitMock.reset();
});
RegisterWithSubCommandsSuite.after(({ exitMock }) => {
exitMock.restore();
});
for (const command of [['top'], ['top', 'mid-1'], ['top', 'mid-1', 'bottom'], ['top', 'mid-2']]) {
RegisterWithSubCommandsSuite(
`run the ${command} command`,
async ({ commandInstance, logMock }) => {
await CommandTestFactory.run(commandInstance, command);
equal(logMock.firstCall?.args[0], `${command.join(' ')} command`);
},
);
}
RegisterWithSubCommandsSuite(
'parameters should still be passable',
async ({ commandInstance, logMock }) => {
await CommandTestFactory.run(commandInstance, ['top', 'hello!']);
equal(logMock.callCount, 2);
equal(logMock.firstCall?.args[0], 'top command');
equal(logMock.getCall(1).args[0], ['hello!']);
},
);
for (const command of ['mid-1', 'mid-2', 'bottom']) {
RegisterWithSubCommandsSuite(
`write an error from ${command} command`,
async ({ commandInstance, logMock, exitMock }) => {
const errStub = stubMethod(process.stderr, 'write');
await CommandTestFactory.run(commandInstance, [command]);
equal(logMock.callCount, 0);
equal(exitMock.firstCall?.args[0], 1);
errStub.restore();
},
);
}
RegisterWithSubCommandsSuite(
'RegisterProvider mid-2 should be callable with "m"',
async ({ commandInstance, logMock }) => {
await CommandTestFactory.run(commandInstance, ['top', 'm']);
equal(logMock.firstCall?.args[0], 'top mid-2 command');
},
);
16 changes: 15 additions & 1 deletion packages/nest-commander/src/command-runner.interface.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { DiscoveredMethodWithMeta } from '@golevelup/nestjs-discovery';
import { Type } from '@nestjs/common';
import { ClassProvider, Type } from '@nestjs/common';
import { Command, CommandOptions } from 'commander';
import type {
CheckboxQuestion,
Expand All @@ -12,6 +12,7 @@ import type {
PasswordQuestion,
RawListQuestion,
} from 'inquirer';
import { CommandMeta, SubCommandMeta } from './constants';

export type InquirerKeysWithPossibleFunctionTypes =
| 'transformer'
Expand All @@ -23,7 +24,20 @@ export type InquirerKeysWithPossibleFunctionTypes =

type InquirerQuestionWithoutFilter<T> = Omit<T, 'filter'>;

type CommandRunnerClass = ClassProvider<CommandRunner> & typeof CommandRunner;

export abstract class CommandRunner {
static registerWithSubCommands(meta: string = CommandMeta): CommandRunnerClass[] {
// NOTE: "this' in the scope is inherited class
const subcommands: CommandRunnerClass[] = Reflect.getMetadata(meta, this)?.subCommands || [];
return subcommands.reduce(
(current: CommandRunnerClass[], subcommandClass: CommandRunnerClass) => {
const results = subcommandClass.registerWithSubCommands(SubCommandMeta);
return [...current, ...results];
},
[this] as CommandRunnerClass[],
);
}
protected command!: Command;
public setCommand(command: Command): this {
this.command = command;
Expand Down