NestJS service source root directory.
59 | --flat Whether or not a directory is created. (default: false)
60 | --spec Whether or not a spec file is generated. (default: true)
61 | ```
62 |
--------------------------------------------------------------------------------
/apps/docs/src/components/RightSidebar/MoreMenu.astro:
--------------------------------------------------------------------------------
1 | ---
2 | import ThemeToggleButton from './ThemeToggleButton';
3 | import * as CONFIG from '../../config';
4 |
5 | type Props = {
6 | editHref: string;
7 | };
8 |
9 | const { editHref } = Astro.props as Props;
10 | const showMoreSection = CONFIG.COMMUNITY_INVITE_URL;
11 | ---
12 |
13 | {showMoreSection && More
}
14 |
71 |
72 |
73 |
74 |
75 |
83 |
--------------------------------------------------------------------------------
/apps/docs/src/components/RightSidebar/ThemeToggleButton.tsx:
--------------------------------------------------------------------------------
1 | import type { FunctionalComponent } from 'preact';
2 | import { useState, useEffect } from 'preact/hooks';
3 | import './ThemeToggleButton.css';
4 |
5 | const themes = ['light', 'dark'];
6 |
7 | const icons = [
8 | ,
21 | ,
30 | ];
31 |
32 | const ThemeToggle: FunctionalComponent = () => {
33 | const [theme, setTheme] = useState(() => {
34 | if (import.meta.env.SSR) {
35 | return undefined;
36 | }
37 | if (typeof localStorage !== undefined && localStorage.getItem('theme')) {
38 | return localStorage.getItem('theme');
39 | }
40 | if (window.matchMedia('(prefers-color-scheme: dark)').matches) {
41 | return 'dark';
42 | }
43 | return 'light';
44 | });
45 |
46 | useEffect(() => {
47 | const root = document.documentElement;
48 | if (theme === 'light') {
49 | root.classList.remove('theme-dark');
50 | } else {
51 | root.classList.add('theme-dark');
52 | }
53 | }, [theme]);
54 |
55 | return (
56 |
57 | {themes.map((t, i) => {
58 | const icon = icons[i];
59 | const checked = t === theme;
60 | return (
61 |
76 | );
77 | })}
78 |
79 | );
80 | };
81 |
82 | export default ThemeToggle;
83 |
--------------------------------------------------------------------------------
/packages/nest-commander-testing/CHANGELOG.md:
--------------------------------------------------------------------------------
1 | # nest-commander-testing
2 |
3 | ## 3.5.1
4 |
5 | ### Patch Changes
6 |
7 | - 0e8288b: Fix relative directory to package in repository
8 |
9 | ## 3.5.0
10 |
11 | ### Minor Changes
12 |
13 | - ca7ee4b: Bump @golevelup/nestjs-discovery from v4 to v5
14 |
15 | ## 3.4.0
16 |
17 | ### Minor Changes
18 |
19 | - ce3d4f4: Support NestJS v11
20 |
21 | ## 3.3.0
22 |
23 | ### Minor Changes
24 |
25 | - 519018e: Add ability to set outputConfiguration.
26 |
27 | Now CommandFactory.run(), CommandFactory.runWithoutClosing() and
28 | CommandFactory.createWithoutRunning() accept the option `outputConfiguration`.
29 |
30 | ## 3.2.0
31 |
32 | ### Minor Changes
33 |
34 | - 1fa92a0: Support NestJS v10
35 |
36 | ## 3.1.0
37 |
38 | ### Minor Changes
39 |
40 | - c75ca13: Add runWithoutClosing for testing
41 |
42 | ## 3.0.1
43 |
44 | ### Patch Changes
45 |
46 | - 23b2f48: Add 3.0.0 to peer deps
47 |
48 | ## 3.0.0
49 |
50 | ### Major Changes
51 |
52 | - d6ebe0e: Migrate `CommandRunner` from interface to abstract class and add
53 | `.command`
54 |
55 | This change was made so that devs could access `this.command` inside the
56 | `CommandRunner` instance and have access to the base command object from
57 | commander. This allows for access to the `help` commands in a programatic
58 | fashion.
59 |
60 | To update to this version, any `implements CommandRunner` should be changed to
61 | `extends CommandRunner`. If there is a `constructor` to the `CommandRunner`
62 | then it should also use `super()`.
63 |
64 | ### Minor Changes
65 |
66 | - 3d2aa9e: Update NestJS package to version 9
67 |
68 | ## 2.0.1
69 |
70 | ### Patch Changes
71 |
72 | - 3831e52: Adds a new `@Help()` decorator for custom commander help output
73 |
74 | `nest-commander-testing` now also uses a `hex` instead of `utf-8` encoding
75 | when creating a random js file name during the `CommandTestFactory` command.
76 | This is to help create more predictable output names.
77 |
78 | ## 2.0.0
79 |
80 | ### Major Changes
81 |
82 | - ee001cc: Upgrade all Nest dependencies to version 8
83 |
84 | WHAT: Upgrade `@nestjs/` dependencies to v8 and RxJS to v7 WHY: To support the
85 | latest version of Nest HOW: upgrading to Nest v8 should be all that's
86 | necessary (along with rxjs to v7)
87 |
88 | ## 1.2.0
89 |
90 | ### Minor Changes
91 |
92 | - f3f687b: Allow for commands to be run indefinitely
93 |
94 | There is a new `runWithoutClosing` method in the `CommandFactory` class. This
95 | command allows for not having the created Nest Application get closed
96 | immediately, which should allow for the use of indefinitely runnable commands.
97 |
--------------------------------------------------------------------------------
/integration/sub-commands/test/sub-commands.spec.ts:
--------------------------------------------------------------------------------
1 | import { TestingModule } from '@nestjs/testing';
2 | import { Stub, stubMethod } from 'hanbi';
3 | import { CommandTestFactory } from 'nest-commander-testing';
4 | import { suite } from 'uvu';
5 | import { equal } from 'uvu/assert';
6 | import { LogService } from '../../common/log.service';
7 | import { NestedModule } from '../src/nested.module';
8 |
9 | export const SubCommandSuite = suite<{
10 | logMock: Stub;
11 | exitMock: Stub;
12 | commandInstance: TestingModule;
13 | }>('Sub Command Suite');
14 | SubCommandSuite.before(async (context) => {
15 | context.exitMock = stubMethod(process, 'exit');
16 | context.logMock = stubMethod(console, 'log');
17 | context.commandInstance = await CommandTestFactory.createTestingCommand({
18 | imports: [NestedModule],
19 | })
20 | .overrideProvider(LogService)
21 | .useValue({
22 | log: context.logMock.handler,
23 | })
24 | .compile();
25 | });
26 | SubCommandSuite.after.each(({ logMock, exitMock }) => {
27 | logMock.reset();
28 | exitMock.reset();
29 | });
30 | SubCommandSuite.after(({ exitMock }) => {
31 | exitMock.restore();
32 | });
33 | for (const command of [
34 | ['top'],
35 | ['top', 'mid-1'],
36 | ['top', 'mid-1', 'bottom'],
37 | ['top', 'mid-2'],
38 | ]) {
39 | SubCommandSuite(
40 | `run the ${command} command`,
41 | async ({ commandInstance, logMock }) => {
42 | await CommandTestFactory.run(commandInstance, command);
43 | equal(logMock.firstCall?.args[0], `${command.join(' ')} command`);
44 | },
45 | );
46 | }
47 | SubCommandSuite(
48 | 'parameters should still be passable',
49 | async ({ commandInstance, logMock }) => {
50 | await CommandTestFactory.run(commandInstance, ['top', 'hello!']);
51 | equal(logMock.callCount, 2);
52 | equal(logMock.firstCall?.args[0], 'top command');
53 | equal(logMock.getCall(1).args[0], ['hello!']);
54 | },
55 | );
56 | for (const command of ['mid-1', 'mid-2', 'bottom']) {
57 | SubCommandSuite(
58 | `write an error from ${command} command`,
59 | async ({ commandInstance, logMock, exitMock }) => {
60 | const errStub = stubMethod(process.stderr, 'write');
61 | await CommandTestFactory.run(commandInstance, [command]);
62 | equal(logMock.callCount, 0);
63 | equal(exitMock.firstCall?.args[0], 1);
64 | errStub.restore();
65 | },
66 | );
67 | }
68 | SubCommandSuite(
69 | 'SubCommand mid-2 should be callable with "m"',
70 | async ({ commandInstance, logMock }) => {
71 | await CommandTestFactory.run(commandInstance, ['top', 'm']);
72 | equal(logMock.firstCall?.args[0], 'top mid-2 command');
73 | },
74 | );
75 |
--------------------------------------------------------------------------------
/integration/register-provider/test/register-with-subcommands.spec.ts:
--------------------------------------------------------------------------------
1 | import { TestingModule } from '@nestjs/testing';
2 | import { Stub, stubMethod } from 'hanbi';
3 | import { CommandTestFactory } from 'nest-commander-testing';
4 | import { suite } from 'uvu';
5 | import { equal } from 'uvu/assert';
6 | import { LogService } from '../../common/log.service';
7 | import { NestedModule } from '../src/nested.module';
8 |
9 | export const RegisterWithSubCommandsSuite = suite<{
10 | logMock: Stub;
11 | exitMock: Stub;
12 | commandInstance: TestingModule;
13 | }>('Register With SubCommands Suite');
14 | RegisterWithSubCommandsSuite.before(async (context) => {
15 | context.exitMock = stubMethod(process, 'exit');
16 | context.logMock = stubMethod(console, 'log');
17 | context.commandInstance = await CommandTestFactory.createTestingCommand({
18 | imports: [NestedModule],
19 | })
20 | .overrideProvider(LogService)
21 | .useValue({
22 | log: context.logMock.handler,
23 | })
24 | .compile();
25 | });
26 | RegisterWithSubCommandsSuite.after.each(({ logMock, exitMock }) => {
27 | logMock.reset();
28 | exitMock.reset();
29 | });
30 | RegisterWithSubCommandsSuite.after(({ exitMock }) => {
31 | exitMock.restore();
32 | });
33 | for (const command of [
34 | ['top'],
35 | ['top', 'mid-1'],
36 | ['top', 'mid-1', 'bottom'],
37 | ['top', 'mid-2'],
38 | ]) {
39 | RegisterWithSubCommandsSuite(
40 | `run the ${command} command`,
41 | async ({ commandInstance, logMock }) => {
42 | await CommandTestFactory.run(commandInstance, command);
43 | equal(logMock.firstCall?.args[0], `${command.join(' ')} command`);
44 | },
45 | );
46 | }
47 | RegisterWithSubCommandsSuite(
48 | 'parameters should still be passable',
49 | async ({ commandInstance, logMock }) => {
50 | await CommandTestFactory.run(commandInstance, ['top', 'hello!']);
51 | equal(logMock.callCount, 2);
52 | equal(logMock.firstCall?.args[0], 'top command');
53 | equal(logMock.getCall(1).args[0], ['hello!']);
54 | },
55 | );
56 | for (const command of ['mid-1', 'mid-2', 'bottom']) {
57 | RegisterWithSubCommandsSuite(
58 | `write an error from ${command} command`,
59 | async ({ commandInstance, logMock, exitMock }) => {
60 | const errStub = stubMethod(process.stderr, 'write');
61 | await CommandTestFactory.run(commandInstance, [command]);
62 | equal(logMock.callCount, 0);
63 | equal(exitMock.firstCall?.args[0], 1);
64 | errStub.restore();
65 | },
66 | );
67 | }
68 | RegisterWithSubCommandsSuite(
69 | 'RegisterProvider mid-2 should be callable with "m"',
70 | async ({ commandInstance, logMock }) => {
71 | await CommandTestFactory.run(commandInstance, ['top', 'm']);
72 | equal(logMock.firstCall?.args[0], 'top mid-2 command');
73 | },
74 | );
75 |
--------------------------------------------------------------------------------
/apps/docs/src/components/Header/Search.tsx:
--------------------------------------------------------------------------------
1 | /** @jsxImportSource react */
2 | import { useState, useCallback, useRef } from 'react';
3 | import { ALGOLIA } from '../../config';
4 | import '@docsearch/css';
5 | import './Search.css';
6 |
7 | import { createPortal } from 'react-dom';
8 | import * as docSearchReact from '@docsearch/react';
9 |
10 | /** FIXME: This is still kinda nasty, but DocSearch is not ESM ready. */
11 | const DocSearchModal =
12 | docSearchReact.DocSearchModal || (docSearchReact as any).default.DocSearchModal;
13 | const useDocSearchKeyboardEvents =
14 | docSearchReact.useDocSearchKeyboardEvents ||
15 | (docSearchReact as any).default.useDocSearchKeyboardEvents;
16 |
17 | export default function Search() {
18 | const [isOpen, setIsOpen] = useState(false);
19 | const searchButtonRef = useRef(null);
20 | const [initialQuery, setInitialQuery] = useState('');
21 |
22 | const onOpen = useCallback(() => {
23 | setIsOpen(true);
24 | }, [setIsOpen]);
25 |
26 | const onClose = useCallback(() => {
27 | setIsOpen(false);
28 | }, [setIsOpen]);
29 |
30 | const onInput = useCallback(
31 | (e) => {
32 | setIsOpen(true);
33 | setInitialQuery(e.key);
34 | },
35 | [setIsOpen, setInitialQuery]
36 | );
37 |
38 | useDocSearchKeyboardEvents({
39 | isOpen,
40 | onOpen,
41 | onClose,
42 | onInput,
43 | searchButtonRef,
44 | });
45 |
46 | return (
47 | <>
48 |
69 |
70 | {isOpen &&
71 | createPortal(
72 | {
80 | return items.map((item) => {
81 | // We transform the absolute URL into a relative URL to
82 | // work better on localhost, preview URLS.
83 | const a = document.createElement('a');
84 | a.href = item.url;
85 | const hash = a.hash === '#overview' ? '' : a.hash;
86 | return {
87 | ...item,
88 | url: `${a.pathname}${hash}`,
89 | };
90 | });
91 | }}
92 | />,
93 | document.body
94 | )}
95 | >
96 | );
97 | }
98 |
--------------------------------------------------------------------------------
/packages/nest-commander-schematics/src/common/index.ts:
--------------------------------------------------------------------------------
1 | import { Path, strings } from '@angular-devkit/core';
2 | import {
3 | apply,
4 | applyTemplates,
5 | branchAndMerge,
6 | chain,
7 | filter,
8 | mergeWith,
9 | move,
10 | noop,
11 | Rule,
12 | SchematicContext,
13 | SchematicsException,
14 | Source,
15 | Tree,
16 | url,
17 | } from '@angular-devkit/schematics';
18 | import {
19 | DeclarationOptions,
20 | Location,
21 | mergeSourceRoot,
22 | ModuleDeclarator,
23 | ModuleFinder,
24 | NameParser,
25 | } from '@nestjs/schematics';
26 | import { join } from 'path';
27 | import { CommonOptions } from './common-options.interface';
28 |
29 | export class CommonSchematicFactory {
30 | templatePath = './files';
31 | type = 'service';
32 | metadata = 'providers';
33 | create(options: T): Rule {
34 | options = this.transform(options);
35 | return branchAndMerge(
36 | chain([
37 | mergeSourceRoot(options),
38 | this.addDeclarationToModule(options),
39 | mergeWith(this.generate(options)),
40 | ]),
41 | );
42 | }
43 | generate(options: T): Source {
44 | return (context: SchematicContext) =>
45 | apply(url(join(this.templatePath as Path)), [
46 | options.spec
47 | ? noop()
48 | : filter((path: string) => !path.endsWith('.spec.ts')),
49 | applyTemplates({
50 | ...strings,
51 | ...options,
52 | lowercase: (str: string) => str.toLowerCase(),
53 | }),
54 | move(options.path ?? ''),
55 | ])(context);
56 | }
57 | addDeclarationToModule(options: T): Rule {
58 | return (tree: Tree) => {
59 | options.module = new ModuleFinder(tree).find({
60 | name: options.name,
61 | path: options.path as Path,
62 | });
63 | if (options.module === undefined || options.module === null) {
64 | return tree;
65 | }
66 | const rawContent = tree.read(options.module);
67 | const content = rawContent?.toString() ?? '';
68 | const declarator: ModuleDeclarator = new ModuleDeclarator();
69 | tree.overwrite(
70 | options.module,
71 | declarator.declare(content, options as DeclarationOptions),
72 | );
73 | return tree;
74 | };
75 | }
76 |
77 | transform(source: T): T {
78 | const target: T = Object.assign({}, source);
79 | target.metadata = this.metadata;
80 | target.type = this.type;
81 |
82 | if (target.name === null || target.name === undefined) {
83 | throw new SchematicsException('Option (name) is required.');
84 | }
85 | const location: Location = new NameParser().parse(target);
86 | target.name = strings.dasherize(location.name);
87 | target.path = strings.dasherize(location.path);
88 |
89 | target.path = target.flat
90 | ? target.path
91 | : join(target.path as Path, target.name);
92 | return target;
93 | }
94 | }
95 |
--------------------------------------------------------------------------------
/apps/docs/src/pages/en/introduction/intro.md:
--------------------------------------------------------------------------------
1 | ---
2 | id: intro
3 | title: Why nest-commander?
4 | layout: ../../../layouts/MainLayout.astro
5 | ---
6 |
7 | ## Initial Motivation
8 |
9 | [NestJS](https://docs.nestjs.com) is a super powerful NodeJS framework that allows developers to
10 | have the same architecture across all their applications. But other than the mention of
11 | [Standalone Applications](https://docs.nestjs.com/standalone-applications) there's only a few
12 | packages that attempt to bring together Nest's opinionated architecture and popular CLI packages.
13 | `nest-commander` aims to bring the most unified experience when it comes to being inline with Nest's
14 | ideologies and opinions, by wrapping the popular [Commander](https://github.com/tj/commander.js) and
15 | [Inquirer](https://github.com/SBoudrias/Inquirer.js) packages, and providing its own decorators for
16 | integration with all the corresponding libraries.
17 |
18 | ## Plugins
19 |
20 | [Plugins](/en/features/plugins) raise nest-commander to the next level of CLI programming. With
21 | plugins, you, the CLI developer, are able to split out commands between global CLI and project
22 | specific CLI commands. Imagine, at some point, you'll notice that certain commands need to be
23 | separately evolved to run apace to a certain project. Or, you'll need different commands for
24 | different project types. So, instead of creating different versions of your one global CLI, with
25 | plugins, you could split out the local and global commands to their own packages. Plugins allow for
26 | version matching of a project's specialized CLI commands to different versions or types of projects.
27 | As you can imagine, this enables you to build very intricate CLIs.
28 |
29 | The plugins feature will more likely be needed later in your project's evolution. That is, once your
30 | CLI needs grow, this ability to "break out" commands is ready and waiting for you to go to the next
31 | level of CLI development.
32 |
33 | ## Code Reuse of Your Nest Code - in the CLI
34 |
35 | One of the biggest advantages to Nest's modularization techniques is the ability to separate
36 | standard or commonly used code to their own modules and build them as standalone libraries. With
37 | nest-commander, such libraries can also be used by your CLI commands too. Take, for instance, the
38 | scenario where you might need to build your own data seeding or data initialization scripts, where
39 | they only need to be ran once to start off a project. Running these scripts are perfect as CLI
40 | commands. And, instead of you creating new modules or having to copy code just for the CLI commands
41 | to gain access to your database layer to do the work, you simply use the same modules already built
42 | for your project, leveraging all of the great advantages Nest's DI system has to offer, at the same
43 | time.
44 |
45 | All these reasons are why nest-commander is THE perfect companion to allow you to build flexible and
46 | smart CLI applications for your Nest-based projects.
47 |
--------------------------------------------------------------------------------
/.github/ISSUE_TEMPLATE/bug_report.yml:
--------------------------------------------------------------------------------
1 | name: "\U0001F41B Bug Report"
2 | description: "If something isn't working as expected \U0001F914"
3 | labels: ["needs triage", "bug"]
4 | body:
5 | - type: checkboxes
6 | attributes:
7 | label: "Is there an existing issue for this?"
8 | description: "Please search [here](./?q=is%3Aissue) to see if an issue already exists for the bug you encountered"
9 | options:
10 | - label: "I have searched the existing issues"
11 | required: true
12 |
13 | - type: textarea
14 | validations:
15 | required: true
16 | attributes:
17 | label: "Current behavior"
18 | description: "How the issue manifests?"
19 |
20 | - type: textarea
21 | validations:
22 | required: true
23 | attributes:
24 | label: "Minimum reproduction code"
25 | description: |
26 | Please share a git repo, a gist, or step-by-step instructions. [Wtf is a minimum reproduction?](https://jmcdo29.github.io/wtf-is-a-minimum-reproduction)
27 | **Tip**: If you leave a minimum repository, we can help you faster!
28 | placeholder: |
29 | ```ts
30 |
31 | ```
32 |
33 | 1. `npm i`
34 | 2. `npm start:dev`
35 | 3. See error...
36 |
37 | - type: textarea
38 | validations:
39 | required: true
40 | attributes:
41 | label: "Expected behavior"
42 | description: "A clear and concise description of what you expected to happend (or code)"
43 |
44 | - type: markdown
45 | attributes:
46 | value: |
47 | ---
48 |
49 | - type: checkboxes
50 | validations:
51 | required: true
52 | attributes:
53 | label: "Package"
54 | description: |
55 | Which package (or packages) do you think your issue is related to?
56 | **Tip**: The first line of the stack trace can help you to figure out this
57 | options:
58 | - label: "nest-commander"
59 | - label: "nest-commander-schematics"
60 | - label: "nest-commander-testing"
61 |
62 | - type: input
63 | validations:
64 | required: true
65 | attributes:
66 | label: "Package version"
67 | description: "Which version of `nest-commander` are you using?"
68 | placeholder: "2.3.0"
69 |
70 | - type: markdown
71 | attributes:
72 | value: |
73 | ---
74 |
75 | - type: input
76 | attributes:
77 | label: "Node.js version"
78 | description: "Which version of Node.js are you using?"
79 | placeholder: "14.17.6"
80 |
81 | - type: checkboxes
82 | attributes:
83 | label: "In which operating systems have you tested?"
84 | options:
85 | - label: macOS
86 | - label: Windows
87 | - label: Linux
88 |
89 | - type: textarea
90 | attributes:
91 | label: "Other"
92 | description: |
93 | Anything else relevant? eg: Logs, OS version, IDE, package manager, etc.
94 | **Tip:** You can attach images, recordings or log files by clicking this area to highlight it and then dragging files in
95 |
--------------------------------------------------------------------------------
/integration/pizza/src/pizza.question.ts:
--------------------------------------------------------------------------------
1 | import { Question, QuestionSet, ValidateFor, WhenFor } from 'nest-commander';
2 |
3 | @QuestionSet({ name: 'pizza' })
4 | export class PizzaQuestion {
5 | @Question({
6 | type: 'expand',
7 | name: 'toppings',
8 | message: 'What about the toppings?',
9 | choices: [
10 | {
11 | key: 'p',
12 | name: 'Pepperoni and cheese',
13 | value: 'PepperoniCheese',
14 | },
15 | {
16 | key: 'a',
17 | name: 'All dressed',
18 | value: 'alldressed',
19 | },
20 | {
21 | key: 'w',
22 | name: 'Hawaiian',
23 | value: 'hawaiian',
24 | },
25 | ],
26 | })
27 | parseToppings(val: string) {
28 | return val;
29 | }
30 |
31 | @Question({
32 | type: 'confirm',
33 | name: 'toBeDelivered',
34 | message: 'Is this for delivery?',
35 | default: false,
36 | })
37 | parseToBeConfirmed(val: boolean) {
38 | return val;
39 | }
40 |
41 | @Question({
42 | type: 'input',
43 | name: 'phone',
44 | message: "What's your phone number?",
45 | })
46 | parsePhone(val: string) {
47 | return val;
48 | }
49 |
50 | @ValidateFor({ name: 'phone' })
51 | validatePhone(value: string) {
52 | const pass = value.match(
53 | /^([01]{1})?[-.\s]?\(?(\d{3})\)?[-.\s]?(\d{3})[-.\s]?(\d{4})\s?((?:#|ext\.?\s?|x\.?\s?){1}(?:\d+)?)?$/i,
54 | );
55 | if (pass) {
56 | return true;
57 | }
58 |
59 | return 'Please enter a valid phone number';
60 | }
61 |
62 | @Question({
63 | type: 'list',
64 | name: 'size',
65 | message: 'What size do you need?',
66 | choices: ['Large', 'Medium', 'Small'],
67 | })
68 | parseSize(val: string) {
69 | return val.toLowerCase();
70 | }
71 |
72 | @Question({
73 | type: 'input',
74 | name: 'quantity',
75 | message: 'How many do you need?',
76 | })
77 | parseQuantity(val: string) {
78 | return Number(val);
79 | }
80 |
81 | @ValidateFor({ name: 'quantity' })
82 | validateQuantity(val: string) {
83 | const valid = !isNaN(parseFloat(val));
84 | return valid || 'Please enter a number';
85 | }
86 |
87 | @Question({
88 | type: 'rawlist',
89 | name: 'beverage',
90 | message: 'You also get a free 2L beverage',
91 | choices: ['Pepsi', '7up', 'Coke'],
92 | })
93 | parseBeverage(val: string) {
94 | return val;
95 | }
96 |
97 | @Question({
98 | type: 'input',
99 | name: 'comments',
100 | message: 'Any comments on your purchase experience?',
101 | default: 'Nope, all good!',
102 | })
103 | parseComments(val: string) {
104 | return val;
105 | }
106 |
107 | @Question({
108 | type: 'list',
109 | name: 'prize',
110 | message: 'For leaving a comment, you get a freebie',
111 | choices: ['cake', 'fries'],
112 | })
113 | parsePrize(val: string) {
114 | return val;
115 | }
116 |
117 | @WhenFor({ name: 'prize' })
118 | whenPrize(answers: { comments: string }): boolean {
119 | return answers.comments !== 'Nope, all good!';
120 | }
121 | }
122 |
--------------------------------------------------------------------------------
/apps/docs/src/pages/en/testing/factory.md:
--------------------------------------------------------------------------------
1 | ---
2 | title: CommandTestFactory
3 | layout: ../../../layouts/MainLayout.astro
4 | ---
5 |
6 | To get started with the `CommandTestFactory` you need to make use of the `createTestingCommand`,
7 | similar to `TestingModule`'s `createTestingModule`. This command can take in general module
8 | metadata, including providers, but generally it's pretty easy to just take in the related module and
9 | use `overrideProvider` for mocking whatever providers are necessary to mock.
10 |
11 | ## Mocking Commands
12 |
13 | Normally when running a CLI you'd do something like
14 | ` [options]`, right, something like
15 | `crun run 'echo Hello World!'`, but that's harder to do in a testing environment. With our
16 | `CommandTestFactory` instead, we can do something like the following:
17 |
18 | ```typescript title="test/task.command.spec.ts"
19 | describe('Task Command', () => {
20 | let commandInstance: TestingModule;
21 |
22 | beforeAll(async () => {
23 | commandInstance = await CommandTestFactory.createTestingCommand({
24 | imports: [AppModule]
25 | }).compile();
26 | });
27 |
28 | it('should call the "run" method', async () => {
29 | const spawnSpy = jest.spyOn(childProcess, 'spawn');
30 | await CommandTestFactory.run(commandInstance, ['run', 'echo Hello World!']);
31 | expect(spawnSpy).toBeCalledWith(['echo Hello World!', { shell: os.userInfo().shell }]);
32 | });
33 | });
34 | ```
35 |
36 | :::tip
37 |
38 | `TestingModule` is imported from `@nestjs/testing` package.
39 |
40 | :::
41 |
42 | Aside from the Jest spies that we're using, you'll notice that we use the `CommandTestFactory` to
43 | set up a `TestingModule` and use it to run a test command. We pass the `run` command here to match
44 | our `@Command()` we already created, but because `run` is the default command, it can be omitted.
45 | Then we pass in our arguments as the next array value, and any flags would be array values after it.
46 | All of this gets passed on to the commander instance and is processed as usual.
47 |
48 | ## Mocking User Input
49 |
50 | Now this is great and all, but we also need to be able to mock user inputs, as we allow the
51 | `InquirerService` to take in responses to questions. For this, we can use
52 | `CommandTestFactory.setAnswers()`. We can pass an array of answers to the `setAnswers` method to
53 | mock the input gained from the user.
54 |
55 | ```typescript title="test/task.command.spec.ts"
56 | describe('Task Command', () => {
57 | let commandInstance: TestingModule;
58 |
59 | beforeAll(async () => {
60 | commandInstance = await CommandTestFactory.createTestingCommand({
61 | imports: [AppModule]
62 | }).compile();
63 | });
64 |
65 | it('should call the "run" method', async () => {
66 | CommandTestFactory.setAnswers(['echo Hello World!']);
67 | const spawnSpy = jest.spyOn(childProcess, 'spawn');
68 | await CommandTestFactory.run(commandInstance, ['run']);
69 | expect(spawnSpy).toBeCalledWith(['echo Hello World!', { shell: os.userInfo().shell }]);
70 | });
71 | });
72 | ```
73 |
74 | :::tip
75 |
76 | The answers passed in will be what are passed back from the `InquirerService`'s `ask` method, so
77 | make sure to have already transformed the input as the `InquirerService` would.
78 |
79 | :::
80 |
--------------------------------------------------------------------------------
/apps/docs/src/config.ts:
--------------------------------------------------------------------------------
1 | export const SITE = {
2 | title: 'Nest-Commander',
3 | description: 'Using NestJS as a CLI builder',
4 | defaultLanguage: 'en_US',
5 | };
6 |
7 | export const OPEN_GRAPH = {
8 | image: {
9 | src: 'https://repository-images.githubusercontent.com/328917508/8aed1c58-81dc-4561-9c52-14924e2dfb08',
10 | alt: 'nest-commander logo, the NestJS cat on top of a right facing arrow with an underscore under the cat',
11 | },
12 | twitter: 'jmcdo29',
13 | };
14 |
15 | // This is the type of the frontmatter you put in the docs markdown files.
16 | export type Frontmatter = {
17 | title: string;
18 | description: string;
19 | layout: string;
20 | image?: { src: string; alt: string };
21 | dir?: 'ltr' | 'rtl';
22 | ogLocale?: string;
23 | lang?: string;
24 | };
25 |
26 | export const KNOWN_LANGUAGES = {
27 | English: 'en',
28 | } as const;
29 | export const KNOWN_LANGUAGE_CODES = Object.values(KNOWN_LANGUAGES);
30 |
31 | export const GITHUB_EDIT_URL = `https://github.com/jmcdo29/nest-commander/tree/main/apps/docs`;
32 |
33 | export const COMMUNITY_INVITE_URL = `https://discord.gg/6byqVsXzaF`;
34 |
35 | export const GITHUB_DISCUSSIONS_URL = `https://github.com/jmcdo29/nest-commander/discussions`;
36 |
37 | // See "Algolia" section of the README for more information.
38 | export const ALGOLIA = {
39 | indexName: 'nest-commander',
40 | appId: '9O0K4CXI15',
41 | apiKey: '9689faf6550ca3133e69be1d9861ea92',
42 | };
43 |
44 | export type Sidebar = Record<
45 | (typeof KNOWN_LANGUAGE_CODES)[number],
46 | Record
47 | >;
48 | export const SIDEBAR: Sidebar = {
49 | en: {
50 | Introduction: [
51 | { text: 'Why nest-commander?', link: 'en/introduction/intro' },
52 | {
53 | text: 'Installation',
54 | link: 'en/introduction/installation',
55 | },
56 | ],
57 | Features: [
58 | {
59 | text: 'Commander',
60 | link: 'en/features/commander',
61 | },
62 | {
63 | text: 'Inquirer',
64 | link: 'en/features/inquirer',
65 | },
66 | {
67 | text: 'Command Factory',
68 | link: 'en/features/factory',
69 | },
70 | {
71 | text: 'Completion',
72 | link: 'en/features/completion',
73 | },
74 | {
75 | text: 'Plugins',
76 | link: 'en/features/plugins',
77 | },
78 | {
79 | text: 'Utility Service',
80 | link: 'en/features/utility',
81 | },
82 | ],
83 | Testing: [
84 | {
85 | text: 'Installation',
86 | link: 'en/testing/installation',
87 | },
88 | {
89 | text: 'TestFactory',
90 | link: 'en/testing/factory',
91 | },
92 | ],
93 | Schematics: [
94 | {
95 | text: 'Installation',
96 | link: 'en/schematics/installation',
97 | },
98 | {
99 | text: 'Usage',
100 | link: 'en/schematics/usage',
101 | },
102 | ],
103 | 'Execution and Publishing': [
104 | {
105 | text: 'Overview',
106 | link: 'en/execution',
107 | },
108 | ],
109 | API: [
110 | {
111 | text: 'Overview',
112 | link: 'en/api',
113 | },
114 | ],
115 | },
116 | };
117 |
--------------------------------------------------------------------------------
/apps/docs/src/components/LeftSidebar/LeftSidebar.astro:
--------------------------------------------------------------------------------
1 | ---
2 | import { getLanguageFromURL } from '../../languages';
3 | import { SIDEBAR } from '../../config';
4 |
5 | type Props = {
6 | currentPage: string;
7 | };
8 |
9 | const { currentPage } = Astro.props as Props;
10 | const currentPageMatch = currentPage.endsWith('/')
11 | ? currentPage.slice(1, -1)
12 | : currentPage.slice(1);
13 | const langCode = getLanguageFromURL(currentPage);
14 | const sidebar = SIDEBAR[langCode];
15 | ---
16 |
17 |
40 |
41 |
55 |
56 |
126 |
127 |
132 |
--------------------------------------------------------------------------------
/apps/docs/src/components/Header/Header.astro:
--------------------------------------------------------------------------------
1 | ---
2 | import { getLanguageFromURL, KNOWN_LANGUAGE_CODES } from '../../languages';
3 | import * as CONFIG from '../../config';
4 | import AstroLogo from './AstroLogo.astro';
5 | import SkipToContent from './SkipToContent.astro';
6 | import SidebarToggle from './SidebarToggle';
7 | import LanguageSelect from './LanguageSelect';
8 | import Search from './Search';
9 |
10 | type Props = {
11 | currentPage: string;
12 | };
13 |
14 | const { currentPage } = Astro.props as Props;
15 | const lang = getLanguageFromURL(currentPage);
16 | ---
17 |
18 |
19 |
20 |
36 |
37 |
38 |
144 |
145 |
150 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "nest-commander-monorepo",
3 | "version": "0.0.0",
4 | "description": "A module for making CLI applications with NestJS. Decorators for running commands and separating out config parsers included. This package works on top of commander.",
5 | "scripts": {
6 | "build": "nx affected:build --parallel",
7 | "lint": "eslint --ext .ts .",
8 | "format": "prettier \"{packages,integration}/**/{src,test}/*.ts\"",
9 | "format:check": "pnpm format -- --check",
10 | "format:write": "pnpm format -- --write",
11 | "postinstall": "husky install",
12 | "release": "changeset publish",
13 | "nx": "nx",
14 | "deploy": "nx deploy docs",
15 | "e2e": "nx e2e integration"
16 | },
17 | "private": true,
18 | "repository": {
19 | "type": "git",
20 | "url": "git+https://github.com/jmcdo29/nest-commander.git"
21 | },
22 | "keywords": [
23 | "cli",
24 | "nestjs",
25 | "application",
26 | "command",
27 | "command-line",
28 | "nest",
29 | "decorator"
30 | ],
31 | "author": "Jay McDoniel ",
32 | "license": "MIT",
33 | "bugs": {
34 | "url": "https://github.com/jmcdo29/nest-commander/issues"
35 | },
36 | "homepage": "https://jmcdo29.github.io/nest-commander",
37 | "devDependencies": {
38 | "@algolia/client-search": "^5.0.0",
39 | "@angular-devkit/core": "19.1.3",
40 | "@angular-devkit/schematics": "19.1.3",
41 | "@angular-devkit/schematics-cli": "19.1.3",
42 | "@astrojs/preact": "^4.0.0",
43 | "@astrojs/react": "^4.0.0",
44 | "@astrojs/sitemap": "^3.0.0",
45 | "@changesets/cli": "2.27.12",
46 | "@commitlint/cli": "19.6.1",
47 | "@commitlint/config-conventional": "19.6.0",
48 | "@docsearch/css": "^3.3.0",
49 | "@docsearch/react": "^3.3.0",
50 | "@golevelup/nestjs-discovery": "5.0.0",
51 | "@mdx-js/react": "3.1.0",
52 | "@nestjs/cli": "11.0.2",
53 | "@nestjs/common": "11.0.16",
54 | "@nestjs/core": "11.0.6",
55 | "@nestjs/schematics": "11.0.0",
56 | "@nestjs/testing": "11.0.6",
57 | "@nx/js": "20.2.1",
58 | "@nx/node": "20.2.1",
59 | "@nx/workspace": "20.2.1",
60 | "@swc/core": "1.10.11",
61 | "@swc/register": "0.1.10",
62 | "@types/inquirer": "8.2.12",
63 | "@types/node": "22.10.10",
64 | "@types/react": "^19.0.0",
65 | "@types/react-dom": "^19.0.0",
66 | "@typescript-eslint/eslint-plugin": "6.21.0",
67 | "@typescript-eslint/parser": "6.21.0",
68 | "astro": "^5.0.0",
69 | "c8": "10.1.3",
70 | "clsx": "2.1.1",
71 | "commander": "11.1.0",
72 | "conventional-changelog-cli": "5.0.0",
73 | "cosmiconfig": "8.3.6",
74 | "cz-conventional-changelog": "3.3.0",
75 | "eslint": "8.57.1",
76 | "eslint-config-prettier": "9.1.2",
77 | "eslint-plugin-prettier": "5.5.4",
78 | "hanbi": "1.0.3",
79 | "hastscript": "^9.0.0",
80 | "husky": "9.1.7",
81 | "inquirer": "8.2.7",
82 | "lint-staged": "15.2.10",
83 | "nx": "20.2.1",
84 | "nx-uvu": "1.3.1",
85 | "pinst": "3.0.0",
86 | "preact": "^10.13.2",
87 | "prettier": "3.6.2",
88 | "react": "19.0.0",
89 | "react-dom": "19.0.0",
90 | "reflect-metadata": "0.2.2",
91 | "remark-directive": "^3.0.0",
92 | "rxjs": "7.8.2",
93 | "typescript": "5.7.2",
94 | "unist-util-visit": "^5.0.0",
95 | "url-loader": "4.1.1",
96 | "uvu": "0.5.6"
97 | },
98 | "resolutions": {
99 | "terser": "^5.0.0",
100 | "reflect-metadata": "0.2.2"
101 | }
102 | }
103 |
--------------------------------------------------------------------------------
/packages/nest-commander/src/command-runner.interface.ts:
--------------------------------------------------------------------------------
1 | import { DiscoveredMethodWithMeta } from '@golevelup/nestjs-discovery';
2 | import { ClassProvider, Type } from '@nestjs/common';
3 | import { Command, CommandOptions } from 'commander';
4 | import type {
5 | CheckboxQuestion,
6 | ConfirmQuestion,
7 | EditorQuestion,
8 | ExpandQuestion,
9 | InputQuestion,
10 | ListQuestion,
11 | NumberQuestion,
12 | PasswordQuestion,
13 | RawListQuestion,
14 | } from 'inquirer';
15 | import { CommandMeta, SubCommandMeta } from './constants';
16 |
17 | export type InquirerKeysWithPossibleFunctionTypes =
18 | | 'transformer'
19 | | 'validate'
20 | | 'when'
21 | | 'choices'
22 | | 'message'
23 | | 'default';
24 |
25 | type InquirerQuestionWithoutFilter = Omit;
26 |
27 | type CommandRunnerClass = ClassProvider & typeof CommandRunner;
28 |
29 | export abstract class CommandRunner {
30 | static registerWithSubCommands(
31 | meta: string = CommandMeta,
32 | ): CommandRunnerClass[] {
33 | // NOTE: "this' in the scope is inherited class
34 | const subcommands: CommandRunnerClass[] =
35 | Reflect.getMetadata(meta, this)?.subCommands || [];
36 | return subcommands.reduce(
37 | (current: CommandRunnerClass[], subcommandClass: CommandRunnerClass) => {
38 | const results = subcommandClass.registerWithSubCommands(SubCommandMeta);
39 | return [...current, ...results];
40 | },
41 | [this] as CommandRunnerClass[],
42 | );
43 | }
44 | protected command!: Command;
45 | public setCommand(command: Command): this {
46 | this.command = command;
47 | return this;
48 | }
49 | abstract run(
50 | passedParams: string[],
51 | options?: Record,
52 | ): Promise;
53 | }
54 |
55 | export interface CommandMetadata {
56 | name: string;
57 | arguments?: string;
58 | description?: string;
59 | argsDescription?: Record;
60 | options?: CommandOptions;
61 | subCommands?: Array>;
62 | aliases?: string[];
63 | allowUnknownOptions?: boolean;
64 | allowExcessArgs?: boolean;
65 | }
66 |
67 | export type RootCommandMetadata = Omit & {
68 | name?: string;
69 | };
70 |
71 | export interface OptionMetadata {
72 | flags: string;
73 | description?: string;
74 | defaultValue?: string | boolean | number;
75 | required?: boolean;
76 | name?: string;
77 | choices?: string[] | true;
78 | env?: string;
79 | }
80 |
81 | export interface OptionChoiceForMetadata {
82 | name: string;
83 | }
84 |
85 | export interface RunnerMeta {
86 | instance: CommandRunner;
87 | command: RootCommandMetadata;
88 | params: DiscoveredMethodWithMeta[];
89 | help?: DiscoveredMethodWithMeta[];
90 | }
91 |
92 | export interface QuestionNameMetadata {
93 | name: string;
94 | }
95 |
96 | export type QuestionMetadata =
97 | | InquirerQuestionWithoutFilter
98 | | InquirerQuestionWithoutFilter
99 | | InquirerQuestionWithoutFilter
100 | | InquirerQuestionWithoutFilter
101 | | InquirerQuestionWithoutFilter
102 | | InquirerQuestionWithoutFilter
103 | | InquirerQuestionWithoutFilter
104 | | InquirerQuestionWithoutFilter
105 | | InquirerQuestionWithoutFilter;
106 |
107 | export type HelpOptions = 'before' | 'beforeAll' | 'after' | 'afterAll';
108 |
--------------------------------------------------------------------------------
/apps/docs/src/pages/en/features/factory.md:
--------------------------------------------------------------------------------
1 | ---
2 | title: CommandFactory
3 | layout: ../../../layouts/MainLayout.astro
4 | ---
5 |
6 | Okay, so you've got this fancy command set up, it takes in some user input, and
7 | is ready to go, but how do you start the CLI application? Well, just like in a
8 | Nest application where you can use `NestFactory.create()`, `nest-commander`
9 | comes with it's own `CommandFactory.run()` method. So let's wire everything up,
10 | set up the `main.ts` and see how this all works together.
11 |
12 | ## Registering Your Commands and Questions
13 |
14 | You may have noticed in the [Inquirer](./inquirer) section a quick mention of
15 | adding the question set class to the `providers`. In fact, both command classes
16 | and question set classes are nothing more than specialized providers! Due to
17 | this, we can simply add these classes to a module's metadata and make sure that
18 | module is in the root module the `CommandFactory` uses.
19 |
20 | ```typescript title="src/app.module.ts"
21 | @Module({
22 | providers: [TaskRunner, TaskQuestions]
23 | })
24 | export class AppModule {}
25 | ```
26 |
27 | Do note that these providers do not need to be in the root module, nor do they
28 | need to be added to the `exports` array, unless they are injected elsewhere. Now
29 | with the `AppModule` set up, we can create the `main.ts` with the
30 | `CommandFactory`.
31 |
32 | ```typescript title="src/main.ts"
33 | const bootstrap = async () => {
34 | await CommandFactory.run(AppModule);
35 | };
36 |
37 | bootstrap();
38 | ```
39 |
40 | And just like that, the command is hooked up and will run. You can use
41 | `typescript`, the NestJS CLI, or `ts-node` to compile and run the `dist/main.js`
42 | file (or `src/main.ts` in the case of `ts-node`). For a more in depth
43 | explanation on how to run the newly created commands, it is encouraged you check
44 | out the `Execution` portion of the docs.
45 |
46 | ## Logging
47 |
48 | By default, the `CommandFactory` turns **off** the Nest logger, due to the noise
49 | that the Nest logs create on startup with all of the modules and dependencies
50 | being resolved. If you'd like to turn logging back on, simple pass a valid
51 | logger configuration to the `CommandFactory` as a second parameter (e.g.:
52 | `new Logger()` from `@nestjs/common`).
53 |
54 | ## Error Handling
55 |
56 | By default, there is no error handler for commander provided by
57 | `nest-commander`. If there's a problem, it will fall back to commander's default
58 | functionality, which is to print the help information. If you want to provide
59 | your own handler though, simply pass an object with the `errorHandler` property
60 | that is a function taking in an `error` and returning `void`.
61 |
62 | ## Indefinite Running
63 |
64 | The `CommandFactory` also allows you to set up an infinite runner, so that you
65 | can set up file watchers or similar. All you need to do is instead of using
66 | `run` use `runWithoutClosing`. All other options are the same.
67 |
68 | For more information on the `CommandFactory`, please refer to the
69 | [API docs](../api#commandfactory).
70 |
71 | ## Creating an Application Without Running It
72 |
73 | There may come a time where you want to create a CLI application but not
74 | immediately run it, like wanting to use `app.useLogger()` to change the logger
75 | to one created by the DI process. You can achieve this by using
76 | `CommandFactory.createWithoutRunning()` using the same configuration that would
77 | be passed to `CommandFactory.run()`. This will create an
78 | `INestApplicationContext` for you without having to worry about all the internal
79 | modules and configuration used. To run the application later, simple call
80 | `await CommandFactory.runApplication(app)`.
81 |
--------------------------------------------------------------------------------
/integration/plugins/test/plugin.command.spec.ts:
--------------------------------------------------------------------------------
1 | import { restore, stubMethod } from 'hanbi';
2 | import { CommandFactory } from 'nest-commander';
3 | import { join } from 'path';
4 | import { suite } from 'uvu';
5 | import { equal, match, not } from 'uvu/assert';
6 | import { FooModule } from '../src/foo.module';
7 |
8 | const setArgv = (command: string, file = 'plugin.command.js') => {
9 | process.argv = [process.argv0, join(__dirname, file), command];
10 | };
11 |
12 | export const PluginSuite = suite('Plugin Command Suite');
13 | PluginSuite.after.each(() => {
14 | restore();
15 | });
16 | PluginSuite('command from the main module should work', async () => {
17 | const logSpy = stubMethod(console, 'log');
18 | setArgv('phooey', 'foo.command.js');
19 | await CommandFactory.run(FooModule);
20 | equal(logSpy.firstCall?.args[0], 'Foo!');
21 | logSpy.restore();
22 | });
23 | PluginSuite('command from the plugin module should be available', async () => {
24 | const logSpy = stubMethod(console, 'log');
25 | setArgv('plug');
26 | const cwdSpy = stubMethod(process, 'cwd');
27 | cwdSpy.returns(join(__dirname, '..'));
28 | await CommandFactory.run(FooModule, { usePlugins: true });
29 | equal(logSpy.firstCall?.args[0], 'This is from the plugin!');
30 | logSpy.restore();
31 | });
32 | PluginSuite('a custom config file should be allowed', async () => {
33 | const logSpy = stubMethod(console, 'log');
34 | setArgv('plug');
35 | const cwdSpy = stubMethod(process, 'cwd');
36 | cwdSpy.returns(join(__dirname, '..'));
37 | await CommandFactory.run(FooModule, {
38 | usePlugins: true,
39 | cliName: 'custom-name',
40 | });
41 | equal(logSpy.firstCall?.args[0], 'This is from the plugin!');
42 | logSpy.restore();
43 | });
44 | /**
45 | * Cosmiconfig uses `process.cwd()` as a basis for a search directory to find the config files
46 | * during tests, `process.cwd()` results in ~/ which doesn't have the config files, so we set it
47 | * to result to ~/integration/plugins so that the configuration files can be found. If the config
48 | * file is not found, there will be an error about it. If the config file is found, but the command
49 | * requested is not a known command, there will be an error, but not about the config file, it will
50 | * just be assumed to be an unknown command
51 | */
52 | PluginSuite(
53 | 'an error message should be written about not finding the config file',
54 | async () => {
55 | const errSpy = stubMethod(process.stderr, 'write');
56 | const exitSpy = stubMethod(process, 'exit');
57 | setArgv('foo');
58 | try {
59 | await CommandFactory.run(FooModule, { usePlugins: true });
60 | } finally {
61 | equal(exitSpy.firstCall?.args[0], 1);
62 | match(
63 | errSpy.getCall(1).args[0].toString() ?? '',
64 | "nest-commander is expecting a configuration file, but didn't find one. Are you in the right directory?",
65 | );
66 | }
67 | },
68 | );
69 | PluginSuite(
70 | 'an error message should be shown for the unknown command, but not about a missing config file',
71 | async () => {
72 | const errSpy = stubMethod(process.stderr, 'write');
73 | const exitSpy = stubMethod(process, 'exit');
74 | setArgv('bar');
75 | const cwdSpy = stubMethod(process, 'cwd');
76 | cwdSpy.returns(join(__dirname, '..'));
77 | try {
78 | await CommandFactory.run(FooModule, { usePlugins: true });
79 | } finally {
80 | console.log(errSpy);
81 | equal(exitSpy.firstCall?.args[0], 1);
82 | not.match(
83 | errSpy.firstCall?.args[0].toString() ?? '',
84 | "nest-commander is expecting a configuration file, but didn't find one. Are you in the right directory?",
85 | );
86 | }
87 | },
88 | );
89 |
--------------------------------------------------------------------------------
/packages/nest-commander/src/command.decorators.ts:
--------------------------------------------------------------------------------
1 | import { Inject, Type } from '@nestjs/common';
2 | import {
3 | CommandMetadata,
4 | CommandRunner,
5 | HelpOptions,
6 | OptionChoiceForMetadata,
7 | OptionMetadata,
8 | QuestionMetadata,
9 | QuestionNameMetadata,
10 | RootCommandMetadata,
11 | } from './command-runner.interface';
12 | import {
13 | ChoicesMeta,
14 | Commander,
15 | CommandMeta,
16 | DefaultMeta,
17 | HelpMeta,
18 | MessageMeta,
19 | OptionChoiceMeta,
20 | OptionMeta,
21 | QuestionMeta,
22 | QuestionSetMeta,
23 | RootCommandMeta,
24 | SubCommandMeta,
25 | TransformMeta,
26 | ValidateMeta,
27 | WhenMeta,
28 | } from './constants';
29 |
30 | type CommandDecorator = >(
31 | target: TFunction,
32 | ) => void | TFunction;
33 |
34 | const applyMethodMetadata = (
35 | options: any,
36 | metadataKey: string,
37 | ): MethodDecorator => {
38 | return (
39 | _target: Record,
40 | _propertyKey: string | symbol,
41 | descriptor: PropertyDescriptor,
42 | ) => {
43 | Reflect.defineMetadata(metadataKey, options, descriptor.value);
44 | return descriptor;
45 | };
46 | };
47 |
48 | const applyClassMetadata = (
49 | options: any,
50 | metadataKey: string,
51 | ): ClassDecorator => {
52 | return (target) => {
53 | Reflect.defineMetadata(metadataKey, options, target);
54 | return target;
55 | };
56 | };
57 | export const Command = (options: CommandMetadata): CommandDecorator => {
58 | return applyClassMetadata(options, CommandMeta);
59 | };
60 |
61 | export const SubCommand = (options: CommandMetadata): CommandDecorator => {
62 | return applyClassMetadata(options, SubCommandMeta);
63 | };
64 |
65 | export const RootCommand = (options: RootCommandMetadata): CommandDecorator => {
66 | return applyClassMetadata(options, RootCommandMeta);
67 | };
68 |
69 | export const DefaultCommand = RootCommand;
70 |
71 | export const Option = (options: OptionMetadata): MethodDecorator => {
72 | return applyMethodMetadata(options, OptionMeta);
73 | };
74 |
75 | export const OptionChoiceFor = (
76 | options: OptionChoiceForMetadata,
77 | ): MethodDecorator => {
78 | return applyMethodMetadata(options, OptionChoiceMeta);
79 | };
80 |
81 | export const QuestionSet = (options: QuestionNameMetadata): ClassDecorator => {
82 | return applyClassMetadata(options, QuestionSetMeta);
83 | };
84 |
85 | export const Question = (options: QuestionMetadata): MethodDecorator => {
86 | return applyMethodMetadata(options, QuestionMeta);
87 | };
88 |
89 | export const ValidateFor = (options: QuestionNameMetadata): MethodDecorator => {
90 | return applyMethodMetadata(options, ValidateMeta);
91 | };
92 |
93 | export const TransformFor = (
94 | options: QuestionNameMetadata,
95 | ): MethodDecorator => {
96 | return applyMethodMetadata(options, TransformMeta);
97 | };
98 |
99 | export const WhenFor = (options: QuestionNameMetadata): MethodDecorator => {
100 | return applyMethodMetadata(options, WhenMeta);
101 | };
102 |
103 | export const MessageFor = (options: QuestionNameMetadata): MethodDecorator => {
104 | return applyMethodMetadata(options, MessageMeta);
105 | };
106 |
107 | export const ChoicesFor = (options: QuestionNameMetadata): MethodDecorator => {
108 | return applyMethodMetadata(options, ChoicesMeta);
109 | };
110 |
111 | export const DefaultFor = (options: QuestionNameMetadata): MethodDecorator => {
112 | return applyMethodMetadata(options, DefaultMeta);
113 | };
114 |
115 | export const Help = (options: HelpOptions): MethodDecorator => {
116 | return applyMethodMetadata(options, HelpMeta);
117 | };
118 |
119 | export const InjectCommander = () => Inject(Commander);
120 |
--------------------------------------------------------------------------------
/.github/workflows/ci.yml:
--------------------------------------------------------------------------------
1 | name: CI
2 |
3 | on:
4 | push:
5 | branches:
6 | - 'main'
7 | pull_request:
8 | branches:
9 | - 'main'
10 | schedule:
11 | - cron: '0 0 * * *'
12 |
13 | env:
14 | NX_CLOUD_DISTRIBUTED_EXECUTION: ${{ !contains(github.event.pull_request.user.login, 'dependabot') && !contains(github.event.pull_request.user.login, 'renovate') }}
15 | NX_CLOUD_AUTH_TOKEN: ${{ startsWith(github.repository, 'jmcdo29') && secrets.NX_CLOUD_TOKEN || 'OTRjNTE0ZTgtNmVjOC00NjNmLWFkYzctYWM0MDlhM2VmNzMyfHJlYWQ=' }}
16 |
17 | jobs:
18 | main:
19 | runs-on: ubuntu-latest
20 | if: ${{ github.event_name != 'pull_request' }}
21 | steps:
22 | - uses: actions/checkout@v4
23 | name: Checkout [main]
24 | with:
25 | fetch-depth: 0
26 | - name: Derive appropriate SHAs for base and head for `nx affected` commands
27 | uses: nrwl/nx-set-shas@v4
28 | - name: Setup
29 | uses: ./.github/actions/setup
30 | - name: Lint, Build, Test
31 | uses: ./.github/actions/lint-build-test
32 | - name: Stop Nx Cloud Agents
33 | run: pnpm nx-cloud stop-all-agents
34 | - name: Tag main branch if all jobs succeed
35 | uses: nrwl/nx-tag-successful-ci-run@v1
36 | pr:
37 | runs-on: ubuntu-latest
38 | if: ${{ github.event_name == 'pull_request' }}
39 | steps:
40 | - uses: actions/checkout@v4
41 | with:
42 | # ref: ${{ github.event.pull_request.head.ref }}
43 | fetch-depth: 0
44 | - name: Derive appropriate SHAs for base and head for `nx affected` commands
45 | uses: nrwl/nx-set-shas@v4
46 | - name: Setup
47 | uses: ./.github/actions/setup
48 | - name: Lint, Build, Test
49 | uses: ./.github/actions/lint-build-test
50 | - name: Stop Nx Cloud Agents
51 | run: pnpm nx-cloud stop-all-agents
52 |
53 | analyze:
54 | name: Analyze
55 | runs-on: ubuntu-latest
56 |
57 | steps:
58 | - name: Checkout repository
59 | uses: actions/checkout@v4
60 | with:
61 | fetch-depth: 0
62 |
63 | - name: Initialize CodeQL
64 | uses: github/codeql-action/init@v3
65 | with:
66 | languages: javascript
67 |
68 | - name: Autobuild
69 | uses: github/codeql-action/autobuild@v3
70 |
71 | - name: Perform CodeQL Analysis
72 | uses: github/codeql-action/analyze@v3
73 |
74 | auto-merge:
75 | needs: pr
76 | if: contains(github.event.pull_request.user.login, 'dependabot') || contains(github.event.pull_request.user.login, 'renovate')
77 | runs-on: ubuntu-latest
78 | steps:
79 | - name: automerge
80 | uses: pascalgn/automerge-action@v0.16.4
81 | env:
82 | GITHUB_TOKEN: '${{ secrets.GITHUB_TOKEN }}'
83 | MERGE_LABELS: ''
84 | MERGE_METHOD: rebase
85 |
86 | send-coverage:
87 | runs-on: ubuntu-latest
88 | needs: [pr, main]
89 | if: always()
90 | steps:
91 | - name: Download Coverage
92 | uses: actions/download-artifact@v4
93 | with:
94 | name: coverage
95 | path: coverage/
96 |
97 | - name: Send Coverage
98 | run: bash <(curl -Ls https://coverage.codacy.com/get.sh) report -r coverage/lcov.info
99 | shell: bash
100 | env:
101 | CODACY_PROJECT_TOKEN: ${{ secrets.CODACY_PROJECT_TOKEN }}
102 |
103 | agents:
104 | runs-on: ubuntu-latest
105 | name: Nx Agent
106 | strategy:
107 | matrix:
108 | agent: [1, 2, 3]
109 | steps:
110 | - uses: actions/checkout@v4
111 | - name: Setup
112 | uses: ./.github/actions/setup
113 | - name: Start Nx Agent ${{ matrix.agent }}
114 | run: pnpm nx-cloud start-agent
115 |
--------------------------------------------------------------------------------
/CONTRIBUTING.md:
--------------------------------------------------------------------------------
1 | # Contributing
2 |
3 | ## Reporting Issues
4 |
5 | If you find an issue, please
6 | [report it here](https://github.com/jmcdo29/nest-commander/issues/new/choose). Follow the issue
7 | template and please fill out the information. Especially if it is a bug, please provide a
8 | [minimum reproduction repository](https://minimum-reproduction.wtf).
9 |
10 | ## Pre-Requisites
11 |
12 | - [pnpm installed](https://pnpm.io) (you can try using `yarn` or `npm`, but sometimes they don't
13 | play nicely with the pnpm workspace)
14 | - [Basic knowledge of Nx](https://nx.dev)
15 |
16 | ### Getting Started
17 |
18 | The following steps are all it should really take to get started
19 |
20 | 1. Create a fork of the project
21 | 2. `git clone `
22 | 3. `cd nest-commander`
23 | 4. `pnpm i`
24 |
25 | And now you should be good to go
26 |
27 | ### Project Structure
28 |
29 | `nest-commander` uses an [`nx`](https://nx.dev) workspace for development. This means that the
30 | packages can be found under `packages/` and each package has its own set of commands that can be
31 | found in the [`workspace.json`](./workspace.json) file. All of the docs for `nest-commander` can be
32 | found at `apps/docs/docs` and are all markdown files [thanks to docusaurus](https://docusaurus.io/).
33 | All integration tests can be found at `integration/`
34 |
35 | ### Making changes
36 |
37 | Generally changes and improvements are appreciated, especially if they make logic less complex or
38 | they end up causing [codebeat](https://codebeat.co/) report a major (greater than .2) loss in code
39 | GPA. Other than that, follow the lint rules set up in the project, and make sure the git hooks run
40 | before [opening a Pull Request](https://github.com/jmcdo29/nest-commander/compare). Also, make sure
41 | if it's a new feature or a bug fix that a test is added to the integration tests. Lastly, please
42 | resolve any merge conflicts by [rebasing](https://git-scm.com/book/en/v2/Git-Branching-Rebasing).
43 | The easiest way to go about this is to use `git pull --rebase upstream main` where `upstream` is a
44 | remote set to `https://github.com/jmcdo29/nest-commander.git` or
45 | `git@github.com:jmcdo29/nest-commander.git` depending on if you use HTTPS or SSH with your git
46 | client.
47 |
48 | #### Adding a Changeset
49 |
50 | If you're making a change that you'd like to be published, please consider running `pnpm changeset`
51 | and following the wizard for setting up a changeset for the change. When this gets merged into
52 | `main`, the GitHub Actions will end up opening a new PR afterwards for updating the version and
53 | publishing to npm.
54 | [If you're interested in how, there's a blog post about it here](https://dev.to/jmcdo29/automating-your-package-deployment-in-an-nx-monorepo-with-changeset-4em8).
55 | The wizard is pretty straight forward, I do ask that you try to follow [semver](https://semver.org/)
56 | as much as possible and don't make major changes unless absolutely necessary.
57 |
58 | ### Building just one project
59 |
60 | If you need to just build a single project, you can use `pnpm nx build `. If you want
61 | to build everything that has been affected you can instead use `pnpm build` which will use
62 | `nx affected:build` instead.
63 |
64 | ### Testing just one suite
65 |
66 | To run the tests, you can use `pnpm e2e` or `pnpm nx e2e integration`, this will run all of the
67 | integration tests, it shouldn't take more than 15 seconds. If it does, there's most likely an `exit`
68 | call that was mocked and not restored. Ping me on discord and we can try to find it.
69 |
70 | If you need to just run one of the test suites you can use `pnpm e2e -- --testFile=`.
71 |
72 | ## Keeping in touch
73 |
74 | You can get a hold of me (Jay) by [emailing me](mailto:me@jaymcdoniel.dev) or by contacting me on
75 | [the Nest Discord](https://discord.gg/6byqVsXzaF) (there's a channel dedicated to `nest-commander`)
76 |
--------------------------------------------------------------------------------
/packages/nest-commander-testing/src/command-test.factory.ts:
--------------------------------------------------------------------------------
1 | import { ModuleMetadata } from '@nestjs/common';
2 | import { Test, TestingModule, TestingModuleBuilder } from '@nestjs/testing';
3 | import { randomBytes } from 'crypto';
4 | import { Answers, DistinctQuestion, ListQuestion } from 'inquirer';
5 | import {
6 | CommandRunnerModule,
7 | CommandRunnerService,
8 | Inquirer,
9 | } from 'nest-commander';
10 | import { CommanderOptionsType } from 'nest-commander';
11 |
12 | export type CommandModuleMetadata = Exclude & {
13 | imports: NonNullable;
14 | };
15 |
16 | export class CommandTestFactory {
17 | private static testAnswers = [];
18 | private static useOriginalInquirer = false;
19 |
20 | static useDefaultInquirer() {
21 | this.useOriginalInquirer = true;
22 | return this;
23 | }
24 | static createTestingCommand(
25 | moduleMetadata: CommandModuleMetadata,
26 | options?: CommanderOptionsType,
27 | ): TestingModuleBuilder {
28 | moduleMetadata.imports.push(
29 | CommandRunnerModule.forModule(undefined, options),
30 | );
31 | const testingModule = Test.createTestingModule(moduleMetadata);
32 | if (!this.useOriginalInquirer) {
33 | testingModule.overrideProvider(Inquirer).useValue({
34 | prompt: this.promptMock.bind(this),
35 | });
36 | }
37 | return testingModule;
38 | }
39 |
40 | private static async promptMock(
41 | questions: ReadonlyArray,
42 | answers: Answers = {},
43 | ) {
44 | for (let i = 0; i < questions.length; i++) {
45 | const question = questions[i];
46 | if ((question.name && answers[question.name]) || !this.testAnswers[i]) {
47 | continue;
48 | }
49 | let answer;
50 | if (question.validate) {
51 | await question.validate(this.testAnswers[i]);
52 | }
53 | if (question.when && typeof question.when === 'function') {
54 | await question.when(answers);
55 | }
56 | if ((question as ListQuestion).choices) {
57 | let choices = (question as ListQuestion).choices;
58 | if (typeof choices === 'function') {
59 | choices = await choices(answers);
60 | }
61 | const choice = (
62 | choices as Array<{ key?: string; value?: string; name?: string }>
63 | ).find((c) => c.key === this.testAnswers[i]);
64 | answer = choice?.value || this.testAnswers[i];
65 | } else {
66 | answer = this.testAnswers[i];
67 | }
68 | if (question.default && typeof question.default === 'function') {
69 | await question.default(this.testAnswers);
70 | }
71 | if (question.message && typeof question.message === 'function') {
72 | await question.message(this.testAnswers);
73 | }
74 | answers[question.name ?? 'default'] =
75 | (await question.filter?.(answer, answers)) ?? answer;
76 | }
77 | return answers;
78 | }
79 |
80 | static async run(app: TestingModule, args: string[] = []) {
81 | const application = await this.runApplication(app, args);
82 | await application.close();
83 | }
84 |
85 | static async runWithoutClosing(app: TestingModule, args: string[] = []) {
86 | return this.runApplication(app, args);
87 | }
88 |
89 | private static async runApplication(app: TestingModule, args: string[] = []) {
90 | if (args?.length && args[0] !== 'node') {
91 | args = ['node', randomBytes(8).toString('hex') + '.js'].concat(args);
92 | }
93 | await app.init();
94 | const runner = app.get(CommandRunnerService);
95 | await runner.run(args);
96 | return app;
97 | }
98 |
99 | static setAnswers(value: any | any[]): void {
100 | if (!Array.isArray(value)) {
101 | value = [value];
102 | }
103 | this.testAnswers = value;
104 | }
105 | }
106 |
--------------------------------------------------------------------------------
/apps/docs/src/layouts/MainLayout.astro:
--------------------------------------------------------------------------------
1 | ---
2 | import HeadCommon from '../components/HeadCommon.astro';
3 | import HeadSEO from '../components/HeadSEO.astro';
4 | import Header from '../components/Header/Header.astro';
5 | import PageContent from '../components/PageContent/PageContent.astro';
6 | import LeftSidebar from '../components/LeftSidebar/LeftSidebar.astro';
7 | import RightSidebar from '../components/RightSidebar/RightSidebar.astro';
8 | import * as CONFIG from '../config';
9 | import type { MarkdownHeading } from 'astro';
10 | import Footer from '../components/Footer/Footer.astro';
11 |
12 | type Props = {
13 | frontmatter: CONFIG.Frontmatter;
14 | headings: MarkdownHeading[];
15 | };
16 |
17 | const { frontmatter, headings } = Astro.props as Props;
18 | const canonicalURL = new URL(Astro.url.pathname, Astro.site);
19 | const currentPage = Astro.url.pathname;
20 | const currentFile = `src/pages${currentPage.replace(/\/$/, '')}.md`;
21 | const githubEditUrl = `${CONFIG.GITHUB_EDIT_URL}/${currentFile}`;
22 | ---
23 |
24 |
25 |
26 |
27 |
28 |
29 | {frontmatter.title ? `${frontmatter.title} | ${CONFIG.SITE.title}` : CONFIG.SITE.title}
30 |
31 |
105 |
120 |
121 |
122 |
123 |
124 |
125 |
128 |
133 |
136 |
137 |
138 |
139 |
140 |
--------------------------------------------------------------------------------
/apps/docs/astro.config.mjs:
--------------------------------------------------------------------------------
1 | import { defineConfig } from 'astro/config';
2 | import preact from '@astrojs/preact';
3 | import react from '@astrojs/react';
4 | import sitemap from '@astrojs/sitemap';
5 | import { h } from 'hastscript';
6 | import directive from 'remark-directive';
7 | import { visit } from 'unist-util-visit';
8 |
9 | const acceptableCalloutTypes = {
10 | note: { cssClass: '', iconClass: 'comment-alt-lines' },
11 | tip: { cssClass: 'is-success', iconClass: 'lightbulb' },
12 | info: { cssClass: 'is-info', iconClass: 'info-circle' },
13 | warning: { cssClass: 'is-warning', iconClass: 'exclamation-triangle' },
14 | danger: { cssClass: 'is-danger', iconClass: 'siren-on' },
15 | };
16 |
17 | /**
18 | * Plugin to generate callout blocks.
19 | */
20 | function calloutsPlugin() {
21 | return (tree) => {
22 | visit(tree, (node) => {
23 | if (
24 | node.type === 'textDirective' ||
25 | node.type === 'leafDirective' ||
26 | node.type === 'containerDirective'
27 | ) {
28 | if (!Object.keys(acceptableCalloutTypes).includes(node.name)) {
29 | return;
30 | }
31 |
32 | const boxInfo = acceptableCalloutTypes[node.name];
33 |
34 | // Adding CSS classes according to the type.
35 | const data = node.data || (node.data = {});
36 | const tagName = node.type === 'textDirective' ? 'span' : 'div';
37 | data.hName = tagName;
38 | data.hProperties = h(tagName, {
39 | class: `message ${boxInfo.cssClass}`,
40 | }).properties;
41 |
42 | // Creating the icon.
43 | const icon = h('i');
44 | const iconData = icon.data || (icon.data = {});
45 | iconData.hName = 'i';
46 | iconData.hProperties = h('i', {
47 | class: `far fa-${boxInfo.iconClass} md-callout-icon`,
48 | }).properties;
49 |
50 | // Creating the icon's column.
51 | const iconWrapper = h('div');
52 | const iconWrapperData = iconWrapper.data || (iconWrapper.data = {});
53 | iconWrapperData.hName = 'div';
54 | iconWrapperData.hProperties = h('div', {
55 | class: 'column is-narrow',
56 | }).properties;
57 | iconWrapper.children = [icon];
58 |
59 | // Creating the content's column.
60 | const contentColWrapper = h('div');
61 | const contentColWrapperData =
62 | contentColWrapper.data || (contentColWrapper.data = {});
63 | contentColWrapperData.hName = 'div';
64 | contentColWrapperData.hProperties = h('div', {
65 | class: 'column',
66 | }).properties;
67 | contentColWrapper.children = [...node.children]; // Adding markdown's content block.
68 |
69 | // Creating the column's wrapper.
70 | const columnsWrapper = h('div');
71 | const columnsWrapperData =
72 | columnsWrapper.data || (columnsWrapper.data = {});
73 | columnsWrapperData.hName = 'div';
74 | columnsWrapperData.hProperties = h('div', {
75 | class: 'columns',
76 | }).properties;
77 | columnsWrapper.children = [iconWrapper, contentColWrapper];
78 |
79 | // Creating the wrapper for the callout's content.
80 | const contentWrapper = h('div');
81 | const wrapperData = contentWrapper.data || (contentWrapper.data = {});
82 | wrapperData.hName = 'div';
83 | wrapperData.hProperties = h('div', {
84 | class: 'message-body',
85 | }).properties;
86 | contentWrapper.children = [columnsWrapper];
87 | node.children = [contentWrapper];
88 | }
89 | });
90 | };
91 | }
92 |
93 | // https://astro.build/config
94 | export default defineConfig({
95 | outDir: '../../dist/apps/docs',
96 | site: 'https://nest-commander.jaymcdoniel.dev',
97 | integrations: [
98 | // Enable Preact to support Preact JSX components.
99 | preact(),
100 | // Enable React for the Algolia search component.
101 | react(),
102 | sitemap(),
103 | ],
104 | markdown: {
105 | extendDefaultPlugins: true,
106 | remarkPlugins: [directive, calloutsPlugin],
107 | },
108 | });
109 |
--------------------------------------------------------------------------------