diff --git a/integration/repl/e2e/repl.spec.ts b/integration/repl/e2e/repl.spec.ts new file mode 100644 index 00000000000..8c20d3a79b7 --- /dev/null +++ b/integration/repl/e2e/repl.spec.ts @@ -0,0 +1,233 @@ +import { clc } from '@nestjs/common/utils/cli-colors.util'; +import { repl } from '@nestjs/core'; +import { ReplContext } from '@nestjs/core/repl/repl-context'; +import { + HelpReplFn, + GetReplFn, + ResolveReplFn, + SelectReplFn, + DebugReplFn, + MethodsReplFn, +} from '@nestjs/core/repl/native-functions'; +import { expect } from 'chai'; +import * as sinon from 'sinon'; +import { AppModule } from '../src/app.module'; + +const PROMPT = '\u001b[1G\u001b[0J> \u001b[3G'; + +describe('REPL', () => { + beforeEach(() => { + // To avoid coloring the output: + sinon.stub(clc, 'bold').callsFake(text => text); + sinon.stub(clc, 'green').callsFake(text => text); + sinon.stub(clc, 'yellow').callsFake(text => text); + sinon.stub(clc, 'red').callsFake(text => text); + sinon.stub(clc, 'magentaBright').callsFake(text => text); + sinon.stub(clc, 'cyanBright').callsFake(text => text); + }); + afterEach(() => { + sinon.restore(); + }); + + it('get()', async () => { + const server = await repl(AppModule); + server.context + let outputText = ''; + sinon.stub(process.stdout, 'write').callsFake(text => { + outputText += text; + return true; + }); + server.emit('line', 'get(UsersService)'); + + expect(outputText).to.equal( + `UsersService { usersRepository: UsersRepository {} } +${PROMPT}`, + ); + + outputText = ''; + server.emit('line', 'get(UsersService).findAll()'); + + expect(outputText).to + .equal(`\u001b[32m'This action returns all users'\u001b[39m +${PROMPT}`); + + outputText = ''; + server.emit('line', 'get(UsersRepository)'); + + expect(outputText).to.equal(`UsersRepository {} +${PROMPT}`); + }); + + it('debug()', async () => { + const server = await repl(AppModule); + + let outputText = ''; + sinon.stub(process.stdout, 'write').callsFake(text => { + outputText += text; + return true; + }); + server.emit('line', 'debug(UsersModule)'); + + expect(outputText).to.equal( + ` +UsersModule: + - controllers: + ◻ UsersController + - providers: + ◻ UsersService + ◻ UsersRepository + +${PROMPT}`, + ); + }); + + it('methods()', async () => { + const server = await repl(AppModule); + + let outputText = ''; + sinon.stub(process.stdout, 'write').callsFake(text => { + outputText += text; + return true; + }); + server.emit('line', 'methods(UsersRepository)'); + + expect(outputText).to.equal( + ` +Methods: + ◻ find + +${PROMPT}`, + ); + + outputText = ''; + server.emit('line', 'methods(UsersService)'); + + expect(outputText).to.equal( + ` +Methods: + ◻ create + ◻ findAll + ◻ findOne + ◻ update + ◻ remove + +${PROMPT}`, + ); + }); + + describe('.help', () => { + it(`Typing "help.help" should print function's description and interface`, async () => { + const replServer = await repl(AppModule); + + const { description, signature } = new HelpReplFn( + sinon.stub() as unknown as ReplContext, + ).fnDefinition; + let outputText = ''; + sinon.stub(process.stdout, 'write').callsFake(text => { + outputText += text; + return true; + }); + + replServer.emit('line', 'help.help'); + + expect(outputText).to.equal(`${description} +Interface: help${signature} +${PROMPT}`); + }); + + it(`Typing "get.help" should print function's description and interface`, async () => { + const replServer = await repl(AppModule); + + const { description, signature } = new GetReplFn( + sinon.stub() as unknown as ReplContext, + ).fnDefinition; + let outputText = ''; + sinon.stub(process.stdout, 'write').callsFake(text => { + outputText += text; + return true; + }); + + replServer.emit('line', 'get.help'); + + expect(outputText).to.equal(`${description} +Interface: get${signature} +${PROMPT}`); + }); + + it(`Typing "resolve.help" should print function's description and interface`, async () => { + const replServer = await repl(AppModule); + + const { description, signature } = new ResolveReplFn( + sinon.stub() as unknown as ReplContext, + ).fnDefinition; + let outputText = ''; + sinon.stub(process.stdout, 'write').callsFake(text => { + outputText += text; + return true; + }); + + replServer.emit('line', 'resolve.help'); + + expect(outputText).to.equal(`${description} +Interface: resolve${signature} +${PROMPT}`); + }); + + it(`Typing "select.help" should print function's description and interface`, async () => { + const replServer = await repl(AppModule); + + const { description, signature } = new SelectReplFn( + sinon.stub() as unknown as ReplContext, + ).fnDefinition; + let outputText = ''; + sinon.stub(process.stdout, 'write').callsFake(text => { + outputText += text; + return true; + }); + + replServer.emit('line', 'select.help'); + + expect(outputText).to.equal(`${description} +Interface: select${signature} +${PROMPT}`); + }); + + it(`Typing "debug.help" should print function's description and interface`, async () => { + const replServer = await repl(AppModule); + + const { description, signature } = new DebugReplFn( + sinon.stub() as unknown as ReplContext, + ).fnDefinition; + let outputText = ''; + sinon.stub(process.stdout, 'write').callsFake(text => { + outputText += text; + return true; + }); + + replServer.emit('line', 'debug.help'); + + expect(outputText).to.equal(`${description} +Interface: debug${signature} +${PROMPT}`); + }); + + it(`Typing "methods.help" should print function's description and interface`, async () => { + const replServer = await repl(AppModule); + + const { description, signature } = new MethodsReplFn( + sinon.stub() as unknown as ReplContext, + ).fnDefinition; + let outputText = ''; + sinon.stub(process.stdout, 'write').callsFake(text => { + outputText += text; + return true; + }); + + replServer.emit('line', 'methods.help'); + + expect(outputText).to.equal(`${description} +Interface: methods${signature} +${PROMPT}`); + }); + }); +}); diff --git a/integration/repl/src/app.module.ts b/integration/repl/src/app.module.ts new file mode 100644 index 00000000000..867c4b1b3c5 --- /dev/null +++ b/integration/repl/src/app.module.ts @@ -0,0 +1,7 @@ +import { Module } from '@nestjs/common'; +import { UsersModule } from './users/users.module'; + +@Module({ + imports: [UsersModule], +}) +export class AppModule {} diff --git a/integration/repl/src/users/dto/create-user.dto.ts b/integration/repl/src/users/dto/create-user.dto.ts new file mode 100644 index 00000000000..0311be1384d --- /dev/null +++ b/integration/repl/src/users/dto/create-user.dto.ts @@ -0,0 +1 @@ +export class CreateUserDto {} diff --git a/integration/repl/src/users/dto/update-user.dto.ts b/integration/repl/src/users/dto/update-user.dto.ts new file mode 100644 index 00000000000..dfd37fb1edb --- /dev/null +++ b/integration/repl/src/users/dto/update-user.dto.ts @@ -0,0 +1,4 @@ +import { PartialType } from '@nestjs/mapped-types'; +import { CreateUserDto } from './create-user.dto'; + +export class UpdateUserDto extends PartialType(CreateUserDto) {} diff --git a/integration/repl/src/users/entities/user.entity.ts b/integration/repl/src/users/entities/user.entity.ts new file mode 100644 index 00000000000..4f82c14571c --- /dev/null +++ b/integration/repl/src/users/entities/user.entity.ts @@ -0,0 +1 @@ +export class User {} diff --git a/integration/repl/src/users/users.controller.ts b/integration/repl/src/users/users.controller.ts new file mode 100644 index 00000000000..3eca7ebdeed --- /dev/null +++ b/integration/repl/src/users/users.controller.ts @@ -0,0 +1,34 @@ +import { Controller, Get, Post, Body, Patch, Param, Delete } from '@nestjs/common'; +import { UsersService } from './users.service'; +import { CreateUserDto } from './dto/create-user.dto'; +import { UpdateUserDto } from './dto/update-user.dto'; + +@Controller('users') +export class UsersController { + constructor(private readonly usersService: UsersService) {} + + @Post() + create(@Body() createUserDto: CreateUserDto) { + return this.usersService.create(createUserDto); + } + + @Get() + findAll() { + return this.usersService.findAll(); + } + + @Get(':id') + findOne(@Param('id') id: string) { + return this.usersService.findOne(+id); + } + + @Patch(':id') + update(@Param('id') id: string, @Body() updateUserDto: UpdateUserDto) { + return this.usersService.update(+id, updateUserDto); + } + + @Delete(':id') + remove(@Param('id') id: string) { + return this.usersService.remove(+id); + } +} diff --git a/integration/repl/src/users/users.module.ts b/integration/repl/src/users/users.module.ts new file mode 100644 index 00000000000..86d73a3f0e4 --- /dev/null +++ b/integration/repl/src/users/users.module.ts @@ -0,0 +1,16 @@ +import { Module } from '@nestjs/common'; +import { UsersController } from './users.controller'; +import { UsersRepository } from './users.repository'; +import { UsersService } from './users.service'; + +@Module({ + controllers: [UsersController], + providers: [ + UsersService, + { + provide: UsersRepository.name, + useValue: new UsersRepository(), + }, + ], +}) +export class UsersModule {} diff --git a/integration/repl/src/users/users.repository.ts b/integration/repl/src/users/users.repository.ts new file mode 100644 index 00000000000..4a616658654 --- /dev/null +++ b/integration/repl/src/users/users.repository.ts @@ -0,0 +1,8 @@ +import { Injectable } from '@nestjs/common'; + +@Injectable() +export class UsersRepository { + async find() { + return [{ id: 1, email: 'test@nestjs.com' }]; + } +} diff --git a/integration/repl/src/users/users.service.ts b/integration/repl/src/users/users.service.ts new file mode 100644 index 00000000000..ab3191152d8 --- /dev/null +++ b/integration/repl/src/users/users.service.ts @@ -0,0 +1,32 @@ +import { Inject, Injectable } from '@nestjs/common'; +import { CreateUserDto } from './dto/create-user.dto'; +import { UpdateUserDto } from './dto/update-user.dto'; +import { UsersRepository } from './users.repository'; + +@Injectable() +export class UsersService { + constructor( + @Inject('UsersRepository') + private readonly usersRepository: UsersRepository, + ) {} + + create(createUserDto: CreateUserDto) { + return 'This action adds a new user'; + } + + findAll() { + return `This action returns all users`; + } + + findOne(id: number) { + return `This action returns a #${id} user`; + } + + update(id: number, updateUserDto: UpdateUserDto) { + return `This action updates a #${id} user`; + } + + remove(id: number) { + return `This action removes a #${id} user`; + } +} diff --git a/integration/repl/tsconfig.json b/integration/repl/tsconfig.json new file mode 100644 index 00000000000..ea15af490a5 --- /dev/null +++ b/integration/repl/tsconfig.json @@ -0,0 +1,22 @@ +{ + "compilerOptions": { + "module": "commonjs", + "declaration": false, + "noImplicitAny": false, + "removeComments": true, + "noLib": false, + "emitDecoratorMetadata": true, + "experimentalDecorators": true, + "target": "es6", + "sourceMap": true, + "allowJs": true, + "outDir": "./dist" + }, + "include": [ + "src/**/*" + ], + "exclude": [ + "node_modules", + "**/*.spec.ts" + ] +} \ No newline at end of file diff --git a/packages/common/utils/cli-colors.util.ts b/packages/common/utils/cli-colors.util.ts index 8efd6167054..738fbc6d337 100644 --- a/packages/common/utils/cli-colors.util.ts +++ b/packages/common/utils/cli-colors.util.ts @@ -5,6 +5,7 @@ const colorIfAllowed = (colorFn: ColorTextFn) => (text: string) => isColorAllowed() ? colorFn(text) : text; export const clc = { + bold: colorIfAllowed((text: string) => `\x1B[1m${text}\x1B[0m`), green: colorIfAllowed((text: string) => `\x1B[32m${text}\x1B[39m`), yellow: colorIfAllowed((text: string) => `\x1B[33m${text}\x1B[39m`), red: colorIfAllowed((text: string) => `\x1B[31m${text}\x1B[39m`), diff --git a/packages/core/index.ts b/packages/core/index.ts index ebc30ca4ca7..1babf6c15f7 100644 --- a/packages/core/index.ts +++ b/packages/core/index.ts @@ -18,5 +18,6 @@ export * from './middleware'; export * from './nest-application'; export * from './nest-application-context'; export { NestFactory } from './nest-factory'; +export * from './repl'; export * from './router'; export * from './services'; diff --git a/packages/core/repl/assign-to-object.util.ts b/packages/core/repl/assign-to-object.util.ts new file mode 100644 index 00000000000..16f9379992f --- /dev/null +++ b/packages/core/repl/assign-to-object.util.ts @@ -0,0 +1,14 @@ +/** + * Similar to `Object.assign` but copying properties descriptors from `source` + * as well. + */ +export function assignToObject(target: T, source: U): T & U { + Object.defineProperties( + target, + Object.keys(source).reduce((descriptors, key) => { + descriptors[key] = Object.getOwnPropertyDescriptor(source, key); + return descriptors; + }, Object.create(null)), + ); + return target as T & U; +} diff --git a/packages/core/repl/constants.ts b/packages/core/repl/constants.ts new file mode 100644 index 00000000000..870e2ec5190 --- /dev/null +++ b/packages/core/repl/constants.ts @@ -0,0 +1 @@ +export const REPL_INITIALIZED_MESSAGE = 'REPL initialized'; diff --git a/packages/core/repl/index.ts b/packages/core/repl/index.ts new file mode 100644 index 00000000000..8a399bd09a5 --- /dev/null +++ b/packages/core/repl/index.ts @@ -0,0 +1 @@ +export * from './repl'; diff --git a/packages/core/repl/native-functions/debug-repl-fn.ts b/packages/core/repl/native-functions/debug-repl-fn.ts new file mode 100644 index 00000000000..910941584e1 --- /dev/null +++ b/packages/core/repl/native-functions/debug-repl-fn.ts @@ -0,0 +1,62 @@ +import type { Type, InjectionToken } from '@nestjs/common'; +import { clc } from '@nestjs/common/utils/cli-colors.util'; +import { ReplFunction } from '../repl-function'; +import type { ModuleDebugEntry } from '../repl-context'; +import type { ReplFnDefinition } from '../repl.interfaces'; + +export class DebugReplFn extends ReplFunction { + public fnDefinition: ReplFnDefinition = { + name: 'debug', + description: + 'Print all registered modules as a list together with their controllers and providers. If the argument is passed in, for example, "debug(MyModule)" then it will only print components of this specific module.', + signature: '(moduleCls?: ClassRef | string) => void', + }; + + action(moduleCls?: Type | string): void { + this.ctx.writeToStdout('\n'); + + if (moduleCls) { + const token = + typeof moduleCls === 'function' ? moduleCls.name : moduleCls; + const moduleEntry = this.ctx.debugRegistry[token]; + if (!moduleEntry) { + return this.logger.error( + `"${token}" has not been found in the modules registry`, + ); + } + this.printCtrlsAndProviders(token, moduleEntry); + } else { + Object.keys(this.ctx.debugRegistry).forEach(moduleKey => { + this.printCtrlsAndProviders( + moduleKey, + this.ctx.debugRegistry[moduleKey], + ); + }); + } + this.ctx.writeToStdout('\n'); + } + + private printCtrlsAndProviders( + moduleName: string, + moduleDebugEntry: ModuleDebugEntry, + ) { + this.ctx.writeToStdout(`${clc.green(moduleName)}:\n`); + this.printCollection('controllers', moduleDebugEntry['controllers']); + this.printCollection('providers', moduleDebugEntry['providers']); + } + + private printCollection( + title: string, + collectionValue: Record, + ) { + const collectionEntries = Object.keys(collectionValue); + if (collectionEntries.length <= 0) { + return; + } + + this.ctx.writeToStdout(` ${clc.yellow(`- ${title}`)}:\n`); + collectionEntries.forEach(provider => + this.ctx.writeToStdout(` ${clc.green('◻')} ${provider}\n`), + ); + } +} diff --git a/packages/core/repl/native-functions/get-relp-fn.ts b/packages/core/repl/native-functions/get-relp-fn.ts new file mode 100644 index 00000000000..6de27691896 --- /dev/null +++ b/packages/core/repl/native-functions/get-relp-fn.ts @@ -0,0 +1,17 @@ +import type { Type } from '@nestjs/common'; +import { ReplFunction } from '../repl-function'; +import type { ReplFnDefinition } from '../repl.interfaces'; + +export class GetReplFn extends ReplFunction { + public fnDefinition: ReplFnDefinition = { + name: 'get', + signature: '(token: InjectionToken) => any', + description: + 'Retrieves an instance of either injectable or controller, otherwise, throws exception.', + aliases: ['$'], + }; + + action(token: string | symbol | Function | Type): any { + return this.ctx.app.get(token); + } +} diff --git a/packages/core/repl/native-functions/help-repl-fn.ts b/packages/core/repl/native-functions/help-repl-fn.ts new file mode 100644 index 00000000000..913ced7ee91 --- /dev/null +++ b/packages/core/repl/native-functions/help-repl-fn.ts @@ -0,0 +1,32 @@ +import { iterate } from 'iterare'; +import { clc } from '@nestjs/common/utils/cli-colors.util'; +import { ReplFunction } from '../repl-function'; +import type { ReplFnDefinition } from '../repl.interfaces'; + +export class HelpReplFn extends ReplFunction { + public fnDefinition: ReplFnDefinition = { + name: 'help', + signature: '() => void', + description: 'Display all available REPL native functions.', + }; + + static buildHelpMessage = ({ name, description }: ReplFnDefinition) => + clc.cyanBright(name) + + (description ? ` ${clc.bold('-')} ${description}` : ''); + + action(): void { + const sortedNativeFunctions = iterate(this.ctx.nativeFunctions) + .map(([, nativeFunction]) => nativeFunction.fnDefinition) + .toArray() + .sort((a, b) => (a.name < b.name ? -1 : 1)); + + this.ctx.writeToStdout( + `You can call ${clc.bold( + '.help', + )} on any function listed below (e.g.: ${clc.bold('help.help')}):\n\n` + + sortedNativeFunctions.map(HelpReplFn.buildHelpMessage).join('\n') + + // Without the following LF the last item won't be displayed + '\n', + ); + } +} diff --git a/packages/core/repl/native-functions/index.ts b/packages/core/repl/native-functions/index.ts new file mode 100644 index 00000000000..8799e685d0c --- /dev/null +++ b/packages/core/repl/native-functions/index.ts @@ -0,0 +1,6 @@ +export * from './help-repl-fn'; +export * from './get-relp-fn'; +export * from './resolve-repl-fn'; +export * from './select-relp-fn'; +export * from './debug-repl-fn'; +export * from './methods-repl-fn'; diff --git a/packages/core/repl/native-functions/methods-repl-fn.ts b/packages/core/repl/native-functions/methods-repl-fn.ts new file mode 100644 index 00000000000..b8deb83fc5a --- /dev/null +++ b/packages/core/repl/native-functions/methods-repl-fn.ts @@ -0,0 +1,33 @@ +import type { Type } from '@nestjs/common'; +import { clc } from '@nestjs/common/utils/cli-colors.util'; +import { MetadataScanner } from '../../metadata-scanner'; +import { ReplFunction } from '../repl-function'; +import type { ReplFnDefinition } from '../repl.interfaces'; + +export class MethodsReplFn extends ReplFunction { + public fnDefinition: ReplFnDefinition = { + name: 'methods', + description: 'Display all public methods available on a given provider or controller.', + signature: '(token: ClassRef | string) => void', + }; + + private readonly metadataScanner = new MetadataScanner(); + + action(token: Type | string): void { + const proto = + typeof token !== 'function' + ? Object.getPrototypeOf(this.ctx.app.get(token)) + : token?.prototype; + + const methods = new Set( + this.metadataScanner.getAllFilteredMethodNames(proto), + ); + + this.ctx.writeToStdout('\n'); + this.ctx.writeToStdout(`${clc.green('Methods')}:\n`); + methods.forEach(methodName => + this.ctx.writeToStdout(` ${clc.yellow('◻')} ${methodName}\n`), + ); + this.ctx.writeToStdout('\n'); + } +} diff --git a/packages/core/repl/native-functions/resolve-repl-fn.ts b/packages/core/repl/native-functions/resolve-repl-fn.ts new file mode 100644 index 00000000000..fe587682d1d --- /dev/null +++ b/packages/core/repl/native-functions/resolve-repl-fn.ts @@ -0,0 +1,19 @@ +import type { Type } from '@nestjs/common'; +import { ReplFunction } from '../repl-function'; +import type { ReplFnDefinition } from '../repl.interfaces'; + +export class ResolveReplFn extends ReplFunction { + public fnDefinition: ReplFnDefinition = { + name: 'resolve', + description: + 'Resolves transient or request-scoped instance of either injectable or controller, otherwise, throws exception.', + signature: '(token: InjectionToken, contextId: any) => Promise', + }; + + action( + token: string | symbol | Function | Type, + contextId: any, + ): Promise { + return this.ctx.app.resolve(token, contextId); + } +} diff --git a/packages/core/repl/native-functions/select-relp-fn.ts b/packages/core/repl/native-functions/select-relp-fn.ts new file mode 100644 index 00000000000..f35346cd100 --- /dev/null +++ b/packages/core/repl/native-functions/select-relp-fn.ts @@ -0,0 +1,20 @@ +import type { + DynamicModule, + INestApplicationContext, + Type, +} from '@nestjs/common'; +import { ReplFunction } from '../repl-function'; +import type { ReplFnDefinition } from '../repl.interfaces'; + +export class SelectReplFn extends ReplFunction { + public fnDefinition: ReplFnDefinition = { + name: 'select', + description: + 'Allows navigating through the modules tree, for example, to pull out a specific instance from the selected module.', + signature: '(token: DynamicModule | ClassRef) => INestApplicationContext', + }; + + action(token: DynamicModule | Type): INestApplicationContext { + return this.ctx.app.select(token); + } +} diff --git a/packages/core/repl/repl-context.ts b/packages/core/repl/repl-context.ts new file mode 100644 index 00000000000..7a8696cae40 --- /dev/null +++ b/packages/core/repl/repl-context.ts @@ -0,0 +1,175 @@ +import { INestApplication, InjectionToken, Logger } from '@nestjs/common'; +import { ApplicationConfig } from '../application-config'; +import { ModuleRef, NestContainer } from '../injector'; +import { InternalCoreModule } from '../injector/internal-core-module'; +import { Module } from '../injector/module'; +import { + DebugReplFn, + GetReplFn, + HelpReplFn, + MethodsReplFn, + ResolveReplFn, + SelectReplFn, +} from './native-functions'; +import { ReplFunction } from './repl-function'; +import type { ReplFunctionClass } from './repl.interfaces'; + +type ModuleKey = string; +export type ModuleDebugEntry = { + controllers: Record; + providers: Record; +}; + +type ReplScope = Record; + +export class ReplContext { + public readonly logger = new Logger(ReplContext.name); + public debugRegistry: Record = {}; + public readonly globalScope: ReplScope = Object.create(null); + public readonly nativeFunctions = new Map< + string, + InstanceType + >(); + private readonly container: NestContainer; + + constructor( + public readonly app: INestApplication, + nativeFunctionsClassRefs?: ReplFunctionClass[], + ) { + this.container = (app as any).container; // Using `any` because `app.container` is not public. + + this.initializeContext(); + this.initializeNativeFunctions(nativeFunctionsClassRefs || []); + } + + public writeToStdout(text: string) { + process.stdout.write(text); + } + + private initializeContext() { + const modules = this.container.getModules(); + + modules.forEach(moduleRef => { + let moduleName = moduleRef.metatype.name; + if (moduleName === InternalCoreModule.name) { + return; + } + if (this.globalScope[moduleName]) { + moduleName += ` (${moduleRef.token})`; + } + + this.introspectCollection(moduleRef, moduleName, 'providers'); + this.introspectCollection(moduleRef, moduleName, 'controllers'); + + // For in REPL auto-complete functionality + Object.defineProperty(this.globalScope, moduleName, { + value: moduleRef.metatype, + configurable: false, + enumerable: true, + }); + }); + } + + private introspectCollection( + moduleRef: Module, + moduleKey: ModuleKey, + collection: keyof ModuleDebugEntry, + ) { + const moduleDebugEntry = {}; + moduleRef[collection].forEach(({ token }) => { + const stringifiedToken = this.stringifyToken(token); + if ( + stringifiedToken === ApplicationConfig.name || + stringifiedToken === moduleRef.metatype.name || + this.globalScope[stringifiedToken] + ) { + return; + } + // For in REPL auto-complete functionality + Object.defineProperty(this.globalScope, stringifiedToken, { + value: token, + configurable: false, + enumerable: true, + }); + + if (stringifiedToken === ModuleRef.name) { + return; + } + moduleDebugEntry[stringifiedToken] = token; + }); + + this.debugRegistry[moduleKey] = { + ...this.debugRegistry?.[moduleKey], + [collection]: moduleDebugEntry, + }; + } + + private stringifyToken(token: unknown): string { + return typeof token !== 'string' + ? typeof token === 'function' + ? token.name + : token?.toString() + : token; + } + + private addNativeFunction( + NativeFunctionRef: ReplFunctionClass, + ): InstanceType { + const nativeFunction = new NativeFunctionRef(this); + + this.nativeFunctions.set(nativeFunction.fnDefinition.name, nativeFunction); + + nativeFunction.fnDefinition.aliases?.forEach(aliaseName => { + const aliasNativeFunction: InstanceType = + Object.create(nativeFunction); + aliasNativeFunction.fnDefinition = { + name: aliaseName, + description: aliasNativeFunction.fnDefinition.description, + signature: aliasNativeFunction.fnDefinition.signature, + }; + this.nativeFunctions.set(aliaseName, aliasNativeFunction); + }); + + return nativeFunction; + } + + private registerFunctionIntoGlobalScope( + nativeFunction: InstanceType, + ) { + // Bind the method to REPL's context: + this.globalScope[nativeFunction.fnDefinition.name] = + nativeFunction.action.bind(nativeFunction); + + // Load the help trigger as a `help` getter on each native function: + const functionBoundRef: ReplFunction['action'] = + this.globalScope[nativeFunction.fnDefinition.name]; + Object.defineProperty(functionBoundRef, 'help', { + enumerable: false, + configurable: false, + get: () => + // Dynamically builds the help message as will unlikely to be called + // several times. + this.writeToStdout(nativeFunction.makeHelpMessage()), + }); + } + + private initializeNativeFunctions( + nativeFunctionsClassRefs: ReplFunctionClass[], + ): void { + const builtInFunctionsClassRefs: ReplFunctionClass[] = [ + HelpReplFn, + GetReplFn, + ResolveReplFn, + SelectReplFn, + DebugReplFn, + MethodsReplFn, + ]; + + builtInFunctionsClassRefs + .concat(nativeFunctionsClassRefs) + .forEach(NativeFunction => { + const nativeFunction = this.addNativeFunction(NativeFunction); + this.registerFunctionIntoGlobalScope(nativeFunction); + }); + } +} diff --git a/packages/core/repl/repl-function.ts b/packages/core/repl/repl-function.ts new file mode 100644 index 00000000000..aa90972c853 --- /dev/null +++ b/packages/core/repl/repl-function.ts @@ -0,0 +1,36 @@ +import { Logger } from '@nestjs/common'; +import { clc } from '@nestjs/common/utils/cli-colors.util'; +import { ReplContext } from './repl-context'; +import type { ReplFnDefinition } from './repl.interfaces'; + +export abstract class ReplFunction< + ActionParams extends Array = Array, + ActionReturn = any, +> { + /** Metadata that describes the built-in function itself. */ + public abstract fnDefinition: ReplFnDefinition; + + protected readonly logger: Logger; + + constructor(protected readonly ctx: ReplContext) { + this.logger = ctx.logger; + } + + /** + * Method called when the function is invoked from the REPL by the user. + */ + abstract action(...args: ActionParams): ActionReturn; + + /** + * @returns A message displayed by calling `.help` + */ + public makeHelpMessage(): string { + const { description, name, signature } = this.fnDefinition; + + const fnSignatureWithName = `${name}${signature}`; + + return `${clc.yellow(description)}\n${clc.magentaBright( + 'Interface:', + )} ${clc.bold(fnSignatureWithName)}\n`; + } +} diff --git a/packages/core/repl/repl-logger.ts b/packages/core/repl/repl-logger.ts new file mode 100644 index 00000000000..e6195269ffe --- /dev/null +++ b/packages/core/repl/repl-logger.ts @@ -0,0 +1,19 @@ +import { ConsoleLogger } from '@nestjs/common'; +import { NestApplication } from '../nest-application'; +import { RouterExplorer } from '../router/router-explorer'; +import { RoutesResolver } from '../router/routes-resolver'; + +export class ReplLogger extends ConsoleLogger { + private static readonly ignoredContexts = [ + RoutesResolver.name, + RouterExplorer.name, + NestApplication.name, + ]; + log(_message: any, context?: string) { + if (ReplLogger.ignoredContexts.includes(context)) { + return; + } + // eslint-disable-next-line + return super.log.apply(this, Array.from(arguments) as [any, string?]); + } +} diff --git a/packages/core/repl/repl.interfaces.ts b/packages/core/repl/repl.interfaces.ts new file mode 100644 index 00000000000..ad4b8fd646c --- /dev/null +++ b/packages/core/repl/repl.interfaces.ts @@ -0,0 +1,21 @@ +import type { ReplContext } from './repl-context'; +import type { ReplFunction } from './repl-function'; + +export type ReplFnDefinition = { + /** Function's name. Note that this should be a valid JavaScript function name. */ + name: string; + + /** Alternative names to the function. */ + aliases?: ReplFnDefinition['name'][]; + + /** Function's description to display when `.help` is entered. */ + description: string; + + /** + * Function's signature following TypeScript _function type expression_ syntax. + * @example '(token: InjectionToken) => any' + */ + signature: string; +}; + +export type ReplFunctionClass = new (replContext: ReplContext) => ReplFunction; diff --git a/packages/core/repl/repl.ts b/packages/core/repl/repl.ts new file mode 100644 index 00000000000..079f16f1fe2 --- /dev/null +++ b/packages/core/repl/repl.ts @@ -0,0 +1,27 @@ +import { Logger, Type } from '@nestjs/common'; +import * as _repl from 'repl'; +import { clc } from '@nestjs/common/utils/cli-colors.util'; +import { NestFactory } from '../nest-factory'; +import { REPL_INITIALIZED_MESSAGE } from './constants'; +import { ReplContext } from './repl-context'; +import { ReplLogger } from './repl-logger'; +import { assignToObject } from './assign-to-object.util'; + +export async function repl(module: Type) { + const app = await NestFactory.create(module, { + abortOnError: false, + logger: new ReplLogger(), + }); + await app.init(); + + const replContext = new ReplContext(app); + Logger.log(REPL_INITIALIZED_MESSAGE); + + const replServer = _repl.start({ + prompt: clc.green('> '), + ignoreUndefined: true, + }); + assignToObject(replServer.context, replContext.globalScope); + + return replServer; +} diff --git a/packages/core/test/repl/assign-to-object.util.spec.ts b/packages/core/test/repl/assign-to-object.util.spec.ts new file mode 100644 index 00000000000..812343aef90 --- /dev/null +++ b/packages/core/test/repl/assign-to-object.util.spec.ts @@ -0,0 +1,36 @@ +import { expect } from 'chai'; +import { assignToObject } from '@nestjs/core/repl/assign-to-object.util'; + +describe('assignToObject', () => { + it('should copy all enumerable properties and their descriptors', () => { + const sourceObj = {}; + Object.defineProperty(sourceObj, 'foo', { + value: 123, + configurable: true, + enumerable: true, + writable: true, + }); + Object.defineProperty(sourceObj, 'bar', { + value: 456, + configurable: true, + enumerable: true, + writable: false, + }); + const targetObj = {}; + + assignToObject(targetObj, sourceObj); + + expect(Object.getOwnPropertyDescriptor(targetObj, 'foo')).to.be.eql({ + value: 123, + configurable: true, + enumerable: true, + writable: true, + }); + expect(Object.getOwnPropertyDescriptor(targetObj, 'bar')).to.be.eql({ + value: 456, + configurable: true, + enumerable: true, + writable: false, + }); + }); +}); diff --git a/packages/core/test/repl/native-functions/debug-repl-fn.spec.ts b/packages/core/test/repl/native-functions/debug-repl-fn.spec.ts new file mode 100644 index 00000000000..e7ddc4a04bc --- /dev/null +++ b/packages/core/test/repl/native-functions/debug-repl-fn.spec.ts @@ -0,0 +1,122 @@ +import { expect } from 'chai'; +import * as sinon from 'sinon'; +import { clc } from '@nestjs/common/utils/cli-colors.util'; +import { DebugReplFn } from '../../../repl/native-functions'; +import { ReplContext } from '../../../repl/repl-context'; +import { NestContainer } from '../../../injector/container'; + +describe('DebugReplFn', () => { + let debugReplFn: DebugReplFn; + + let replContext: ReplContext; + let mockApp: { + container: NestContainer; + get: sinon.SinonStub; + resolve: sinon.SinonSpy; + select: sinon.SinonSpy; + }; + + before(async () => { + const container = new NestContainer(); + const aModuleRef = await container.addModule(class ModuleA {}, []); + const bModuleRef = await container.addModule(class ModuleB {}, []); + + container.addController(class ControllerA {}, aModuleRef.token); + container.addProvider(class ProviderA1 {}, aModuleRef.token); + container.addProvider(class ProviderA2 {}, aModuleRef.token); + + container.addProvider(class ProviderB1 {}, bModuleRef.token); + container.addProvider(class ProviderB2 {}, bModuleRef.token); + + mockApp = { + container, + get: sinon.stub(), + resolve: sinon.spy(), + select: sinon.spy(), + }; + replContext = new ReplContext(mockApp as any); + }); + + beforeEach(() => { + debugReplFn = replContext.nativeFunctions.get('debug') as DebugReplFn; + + // To avoid coloring the output: + sinon.stub(clc, 'yellow').callsFake(text => text); + sinon.stub(clc, 'green').callsFake(text => text); + }); + afterEach(() => sinon.restore()); + + it('the function name should be "debug"', () => { + expect(debugReplFn).to.not.be.undefined; + expect(debugReplFn.fnDefinition.name).to.eql('debug'); + }); + + describe('action', () => { + it('should print all modules along with their controllers and providers', () => { + let outputText = ''; + + sinon + .stub(replContext, 'writeToStdout') + .callsFake(text => (outputText += text)); + + debugReplFn.action(); + + expect(outputText).to.equal(` +ModuleA: + - controllers: + ◻ ControllerA + - providers: + ◻ ProviderA1 + ◻ ProviderA2 +ModuleB: + - providers: + ◻ ProviderB1 + ◻ ProviderB2 + +`); + }); + + describe('when module passed as a class reference', () => { + it("should print a specified module's controllers and providers", () => { + let outputText = ''; + + sinon + .stub(replContext, 'writeToStdout') + .callsFake(text => (outputText += text)); + + debugReplFn.action(class ModuleA {}); + + expect(outputText).to.equal(` +ModuleA: + - controllers: + ◻ ControllerA + - providers: + ◻ ProviderA1 + ◻ ProviderA2 + +`); + }); + }); + describe("when module passed as a string (module's key)", () => { + it("should print a specified module's controllers and providers", () => { + let outputText = ''; + + sinon + .stub(replContext, 'writeToStdout') + .callsFake(text => (outputText += text)); + + debugReplFn.action('ModuleA'); + + expect(outputText).to.equal(` +ModuleA: + - controllers: + ◻ ControllerA + - providers: + ◻ ProviderA1 + ◻ ProviderA2 + +`); + }); + }); + }); +}); diff --git a/packages/core/test/repl/native-functions/get-repl-fn.spec.ts b/packages/core/test/repl/native-functions/get-repl-fn.spec.ts new file mode 100644 index 00000000000..aec8f335fac --- /dev/null +++ b/packages/core/test/repl/native-functions/get-repl-fn.spec.ts @@ -0,0 +1,47 @@ +import { expect } from 'chai'; +import * as sinon from 'sinon'; +import { GetReplFn } from '../../../repl/native-functions'; +import { ReplContext } from '../../../repl/repl-context'; +import { NestContainer } from '../../../injector/container'; + +describe('GetReplFn', () => { + let getReplFn: GetReplFn; + + let replContext: ReplContext; + let mockApp: { + container: NestContainer; + get: sinon.SinonStub; + resolve: sinon.SinonSpy; + select: sinon.SinonSpy; + }; + + before(async () => { + const container = new NestContainer(); + + mockApp = { + container, + get: sinon.stub(), + resolve: sinon.spy(), + select: sinon.spy(), + }; + replContext = new ReplContext(mockApp as any); + }); + + beforeEach(() => { + getReplFn = replContext.nativeFunctions.get('get') as GetReplFn; + }); + afterEach(() => sinon.restore()); + + it('the function name should be "get"', () => { + expect(getReplFn).to.not.be.undefined; + expect(getReplFn.fnDefinition.name).to.eql('get'); + }); + + describe('action', () => { + it('should pass arguments down to the application context', () => { + const token = 'test'; + getReplFn.action(token); + expect(mockApp.get.calledWith(token)).to.be.true; + }); + }); +}); diff --git a/packages/core/test/repl/native-functions/help-repl-fn.spec.ts b/packages/core/test/repl/native-functions/help-repl-fn.spec.ts new file mode 100644 index 00000000000..441d14c43c9 --- /dev/null +++ b/packages/core/test/repl/native-functions/help-repl-fn.spec.ts @@ -0,0 +1,67 @@ +import { expect } from 'chai'; +import * as sinon from 'sinon'; +import { clc } from '@nestjs/common/utils/cli-colors.util'; +import { HelpReplFn } from '../../../repl/native-functions'; +import { ReplContext } from '../../../repl/repl-context'; +import { NestContainer } from '../../../injector/container'; + +describe('HelpReplFn', () => { + let helpReplFn: HelpReplFn; + + let replContext: ReplContext; + let mockApp: { + container: NestContainer; + get: sinon.SinonStub; + resolve: sinon.SinonSpy; + select: sinon.SinonSpy; + }; + + before(async () => { + const container = new NestContainer(); + + mockApp = { + container, + get: sinon.stub(), + resolve: sinon.spy(), + select: sinon.spy(), + }; + replContext = new ReplContext(mockApp as any); + }); + + beforeEach(() => { + helpReplFn = replContext.nativeFunctions.get('help') as HelpReplFn; + + // To avoid coloring the output: + sinon.stub(clc, 'bold').callsFake(text => text); + sinon.stub(clc, 'cyanBright').callsFake(text => text); + }); + afterEach(() => sinon.restore()); + + it('the function name should be "help"', () => { + expect(helpReplFn).to.not.be.undefined; + expect(helpReplFn.fnDefinition.name).to.eql('help'); + }); + + describe('action', () => { + it('should print all available native functions and their description', () => { + let outputText = ''; + sinon + .stub(replContext, 'writeToStdout') + .callsFake(text => (outputText += text)); + + helpReplFn.action(); + + expect(outputText).to + .equal(`You can call .help on any function listed below (e.g.: help.help): + +$ - Retrieves an instance of either injectable or controller, otherwise, throws exception. +debug - Print all registered modules as a list together with their controllers and providers. If the argument is passed in, for example, "debug(MyModule)" then it will only print components of this specific module. +get - Retrieves an instance of either injectable or controller, otherwise, throws exception. +help - Display all available REPL native functions. +methods - Display all public methods available on a given provider or controller. +resolve - Resolves transient or request-scoped instance of either injectable or controller, otherwise, throws exception. +select - Allows navigating through the modules tree, for example, to pull out a specific instance from the selected module. +`); + }); + }); +}); diff --git a/packages/core/test/repl/native-functions/methods-repl-fn.spec.ts b/packages/core/test/repl/native-functions/methods-repl-fn.spec.ts new file mode 100644 index 00000000000..a45f5c2551f --- /dev/null +++ b/packages/core/test/repl/native-functions/methods-repl-fn.spec.ts @@ -0,0 +1,108 @@ +import { expect } from 'chai'; +import * as sinon from 'sinon'; +import { clc } from '@nestjs/common/utils/cli-colors.util'; +import { MethodsReplFn } from '../../../repl/native-functions'; +import { ReplContext } from '../../../repl/repl-context'; +import { NestContainer } from '../../../injector/container'; + +describe('MethodsReplFn', () => { + let methodsReplFn: MethodsReplFn; + + let replContext: ReplContext; + let mockApp: { + container: NestContainer; + get: sinon.SinonStub; + resolve: sinon.SinonSpy; + select: sinon.SinonSpy; + }; + + before(async () => { + const container = new NestContainer(); + const aModuleRef = await container.addModule(class ModuleA {}, []); + const bModuleRef = await container.addModule(class ModuleB {}, []); + + container.addController(class ControllerA {}, aModuleRef.token); + container.addProvider(class ProviderA1 {}, aModuleRef.token); + container.addProvider(class ProviderA2 {}, aModuleRef.token); + + container.addProvider(class ProviderB1 {}, bModuleRef.token); + container.addProvider(class ProviderB2 {}, bModuleRef.token); + + mockApp = { + container, + get: sinon.stub(), + resolve: sinon.spy(), + select: sinon.spy(), + }; + replContext = new ReplContext(mockApp as any); + }); + + beforeEach(() => { + methodsReplFn = replContext.nativeFunctions.get('methods') as MethodsReplFn; + + // To avoid coloring the output: + sinon.stub(clc, 'yellow').callsFake(text => text); + sinon.stub(clc, 'green').callsFake(text => text); + }); + afterEach(() => sinon.restore()); + + it('the function name should be "methods"', () => { + expect(methodsReplFn).to.not.be.undefined; + expect(methodsReplFn.fnDefinition.name).to.eql('methods'); + }); + + describe('action', () => { + describe('when token is a class reference', () => { + it('should print all class methods', () => { + class BaseService { + create() {} + } + class TestService extends BaseService { + findAll() {} + findOne() {} + } + + let outputText = ''; + + sinon + .stub(replContext, 'writeToStdout') + .callsFake(text => (outputText += text)); + + methodsReplFn.action(TestService); + + expect(outputText).to.equal(` +Methods: + ◻ findAll + ◻ findOne + ◻ create + +`); + }); + }); + + describe('when token is a string', () => { + it('should grab provider from the container and print its all methods', () => { + class ProviderA1 { + findAll() {} + findOne() {} + } + let outputText = ''; + + sinon + .stub(replContext, 'writeToStdout') + .callsFake(text => (outputText += text)); + + mockApp.get.callsFake(() => new ProviderA1()); + + methodsReplFn.action('ProviderA1'); + + expect(outputText).to.equal(` +Methods: + ◻ findAll + ◻ findOne + +`); + }); + }); + }); +}); diff --git a/packages/core/test/repl/native-functions/resolve-repl-fn.spec.ts b/packages/core/test/repl/native-functions/resolve-repl-fn.spec.ts new file mode 100644 index 00000000000..cf78bbd40e3 --- /dev/null +++ b/packages/core/test/repl/native-functions/resolve-repl-fn.spec.ts @@ -0,0 +1,49 @@ +import { expect } from 'chai'; +import * as sinon from 'sinon'; +import { ResolveReplFn } from '../../../repl/native-functions'; +import { ReplContext } from '../../../repl/repl-context'; +import { NestContainer } from '../../../injector/container'; + +describe('ResolveReplFn', () => { + let resolveReplFn: ResolveReplFn; + + let replContext: ReplContext; + let mockApp: { + container: NestContainer; + get: sinon.SinonStub; + resolve: sinon.SinonSpy; + select: sinon.SinonSpy; + }; + + before(async () => { + const container = new NestContainer(); + + mockApp = { + container, + get: sinon.stub(), + resolve: sinon.spy(), + select: sinon.spy(), + }; + replContext = new ReplContext(mockApp as any); + }); + + beforeEach(() => { + resolveReplFn = replContext.nativeFunctions.get('resolve') as ResolveReplFn; + }); + afterEach(() => sinon.restore()); + + it('the function name should be "resolve"', () => { + expect(resolveReplFn).to.not.be.undefined; + expect(resolveReplFn.fnDefinition.name).to.eql('resolve'); + }); + + describe('action', () => { + it('should pass arguments down to the application context', async () => { + const token = 'test'; + const contextId = {}; + + await resolveReplFn.action(token, contextId); + expect(mockApp.resolve.calledWith(token, contextId)).to.be.true; + }); + }); +}); diff --git a/packages/core/test/repl/native-functions/select-repl-fn.spec.ts b/packages/core/test/repl/native-functions/select-repl-fn.spec.ts new file mode 100644 index 00000000000..22a033e542c --- /dev/null +++ b/packages/core/test/repl/native-functions/select-repl-fn.spec.ts @@ -0,0 +1,47 @@ +import { expect } from 'chai'; +import * as sinon from 'sinon'; +import { SelectReplFn } from '../../../repl/native-functions'; +import { ReplContext } from '../../../repl/repl-context'; +import { NestContainer } from '../../../injector/container'; + +describe('SelectReplFn', () => { + let selectReplFn: SelectReplFn; + + let replContext: ReplContext; + let mockApp: { + container: NestContainer; + get: sinon.SinonStub; + resolve: sinon.SinonSpy; + select: sinon.SinonSpy; + }; + + before(async () => { + const container = new NestContainer(); + + mockApp = { + container, + get: sinon.stub(), + resolve: sinon.spy(), + select: sinon.spy(), + }; + replContext = new ReplContext(mockApp as any); + }); + + beforeEach(() => { + selectReplFn = replContext.nativeFunctions.get('select') as SelectReplFn; + }); + afterEach(() => sinon.restore()); + + it('the function name should be "select"', () => { + expect(selectReplFn).to.not.be.undefined; + expect(selectReplFn.fnDefinition.name).to.eql('select'); + }); + + describe('action', () => { + it('should pass arguments down to the application context', () => { + const moduleCls = class TestModule {}; + selectReplFn.action(moduleCls); + expect(mockApp.select.calledWith(moduleCls)).to.be.true; + }); + }); +}); diff --git a/packages/core/test/repl/repl-context.spec.ts b/packages/core/test/repl/repl-context.spec.ts new file mode 100644 index 00000000000..9654dfce5e8 --- /dev/null +++ b/packages/core/test/repl/repl-context.spec.ts @@ -0,0 +1,38 @@ +import { expect } from 'chai'; +import * as sinon from 'sinon'; +import { NestContainer } from '../../injector/container'; +import { ReplContext } from '../../repl/repl-context'; + +describe('ReplContext', () => { + let replContext: ReplContext; + let mockApp: { + container: NestContainer; + get: sinon.SinonStub; + resolve: sinon.SinonSpy; + select: sinon.SinonSpy; + }; + + before(async () => { + const container = new NestContainer(); + + mockApp = { + container, + get: sinon.stub(), + resolve: sinon.spy(), + select: sinon.spy(), + }; + replContext = new ReplContext(mockApp as any); + }); + + afterEach(() => sinon.restore()); + + it('writeToStdout', () => { + const stdOutWrite = sinon.stub(process.stdout, 'write'); + const text = sinon.stub() as unknown as string; + + replContext.writeToStdout(text); + + expect(stdOutWrite.calledOnce).to.be.true; + expect(stdOutWrite.calledWith(text)).to.be.true; + }); +});