diff --git a/package.json b/package.json index 5eb35ed..5bdb716 100644 --- a/package.json +++ b/package.json @@ -45,6 +45,7 @@ "tag": "next" }, "devDependencies": { + "@adonisjs/dev-utils": "^1.4.0", "@adonisjs/mrm-preset": "^2.0.3", "@types/node": "^12.0.2", "commitizen": "^3.1.1", diff --git a/src/Contracts/index.ts b/src/Contracts/index.ts index 4cabd85..deb3fd4 100644 --- a/src/Contracts/index.ts +++ b/src/Contracts/index.ts @@ -70,3 +70,17 @@ export interface CommandContract { parsed?: ParsedOptions, handle (): Promise, } + +/** + * Shape of a command inside the manifest file. + */ +export type ManifestCommand = Pick< + CommandConstructorContract, Exclude +> & { commandPath: string } + +/** + * Shape of manifest JSON file + */ +export type ManifestNode = { + [command: string]: ManifestCommand, +} diff --git a/src/Exceptions/CommandValidationException.ts b/src/Exceptions/CommandValidationException.ts new file mode 100644 index 0000000..3d8f7be --- /dev/null +++ b/src/Exceptions/CommandValidationException.ts @@ -0,0 +1,32 @@ +/* +* @adonisjs/ace +* +* (c) Harminder Virk +* +* For the full copyright and license information, please view the LICENSE +* file that was distributed with this source code. +*/ + +import { Exception } from '@poppinss/utils' + +/** + * CommandValidationException is used when validating a command before + * registering it with Ace. + */ +export class CommandValidationException extends Exception { + public static invalidManifestExport (commandPath: string) { + return new this(`make sure to have a default export from {${commandPath}}`) + } + + public static missingCommandName (className: string) { + return new this(`missing command name for {${className}} class`) + } + + public static invalidSpreadArgOrder (arg: string) { + return new this(`spread argument {${arg}} must be at last position`) + } + + public static invalidOptionalArgOrder (optionalArg: string, currentArg: string) { + return new this(`optional argument {${optionalArg}} must be after required argument {${currentArg}}`) + } +} diff --git a/src/Exceptions/InvalidArgumentException.ts b/src/Exceptions/InvalidArgumentException.ts index c130574..67abf54 100644 --- a/src/Exceptions/InvalidArgumentException.ts +++ b/src/Exceptions/InvalidArgumentException.ts @@ -9,14 +9,25 @@ import { Exception } from '@poppinss/utils' +/** + * InvalidArgumentException is raised when command arguments + * or flags doesn't satisfy the requirements of a given + * command. + */ export class InvalidArgumentException extends Exception { + /** + * Argument or flag type validation failed. + */ public static invalidType (prop: string, expected: string) { - const message = `${prop} must be defined as a ${expected}` + const message = `{${prop}} must be defined as a {${expected}}` return new InvalidArgumentException(message, 500) } + /** + * A required argument is missing + */ public static missingArgument (name: string) { - const message = `missing required argument ${name}` + const message = `missing required argument {${name}}` return new InvalidArgumentException(message, 500) } } diff --git a/src/Kernel/index.ts b/src/Kernel/index.ts index 81176e2..f6fb22a 100644 --- a/src/Kernel/index.ts +++ b/src/Kernel/index.ts @@ -6,16 +6,13 @@ * For the full copyright and license information, please view the LICENSE * file that was distributed with this source code. */ -import { Parser } from '../Parser' -import { - CommandConstructorContract, - CommandFlag, - GlobalFlagHandler, - CommandArg, -} from '../Contracts' import * as getopts from 'getopts' +import { Parser } from '../Parser' +import { validateCommand } from '../utils/validateCommand' +import { CommandConstructorContract, CommandFlag, GlobalFlagHandler } from '../Contracts' + /** * Ace kernel class is used to register, find and invoke commands by * parsing `process.argv.splice(2)` value. @@ -31,46 +28,6 @@ export class Kernel { */ public flags: { [name: string]: CommandFlag & { handler: GlobalFlagHandler } } = {} - /** - * Since arguments are matched based on their position, we need to make - * sure that the command author doesn't put optional args before the - * required args. - * - * The concept is similar to Javascript function arguments, you cannot have a - * required argument after an optional argument. - */ - private _validateCommand (command: CommandConstructorContract) { - /** - * Ensure command has a name - */ - if (!command.commandName) { - throw new Error(`missing command name for ${command.name} class`) - } - - let optionalArg: CommandArg - - command.args.forEach((arg, index) => { - /** - * Ensure optional arguments comes after required - * arguments - */ - if (optionalArg && arg.required) { - throw new Error(`option argument {${optionalArg.name}} must be after required argument {${arg.name}}`) - } - - /** - * Ensure spread arg is the last arg - */ - if (arg.type === 'spread' && command.args.length > index + 1) { - throw new Error('spread arguments must be last') - } - - if (!arg.required) { - optionalArg = arg - } - }) - } - /** * Executing global flag handlers. The global flag handlers are * not async as of now, but later we can look into making them @@ -101,7 +58,7 @@ export class Kernel { */ public register (commands: CommandConstructorContract[]): this { commands.forEach((command) => { - this._validateCommand(command) + validateCommand(command) this.commands[command.commandName] = command }) diff --git a/src/Manifest/index.ts b/src/Manifest/index.ts new file mode 100644 index 0000000..15139bc --- /dev/null +++ b/src/Manifest/index.ts @@ -0,0 +1,92 @@ +/* +* @adonisjs/cli +* +* (c) Harminder Virk +* +* For the full copyright and license information, please view the LICENSE +* file that was distributed with this source code. +*/ + +import { join } from 'path' +import { esmRequire } from '@poppinss/utils' +import { writeFile, readFile } from 'fs' + +import { CommandValidationException } from '../Exceptions/CommandValidationException' +import { validateCommand } from '../utils/validateCommand' +import { ManifestNode, CommandConstructorContract } from '../Contracts' + +/** + * Manifest class drastically improves the commands performance, by generating + * a manifest file for all the commands and lazy load only the executed + * command. + */ +export class Manifest { + constructor (private _appRoot: string) { + } + + /** + * Require and return command + */ + private _getCommand (commandPath: string): CommandConstructorContract { + const command = esmRequire(join(this._appRoot, commandPath)) + if (!command.name) { + throw CommandValidationException.invalidManifestExport(commandPath) + } + + validateCommand(command) + return command + } + + /** + * Write file to the disk + */ + private _writeManifest (manifest: ManifestNode): Promise { + return new Promise((resolve, reject) => { + writeFile(join(this._appRoot, 'ace-manifest.json'), JSON.stringify(manifest), (error) => { + if (error) { + reject(error) + } else { + resolve() + } + }) + }) + } + + /** + * Generates the manifest file for the given command paths + */ + public async generate (commandPaths: string[]) { + const manifest = commandPaths.reduce((manifest: ManifestNode, commandPath) => { + const command = this._getCommand(commandPath) + + manifest[command.commandName] = { + commandPath: commandPath, + commandName: command.commandName, + description: command.description, + args: command.args, + flags: command.flags, + } + + return manifest + }, {}) + + await this._writeManifest(manifest) + } + + /** + * Load the manifest file from the disk. An exception is raised + * when `manifest` file is missing. So the consumer must ensure + * that file exists before calling this method. + */ + public load (): Promise { + return new Promise((resolve, reject) => { + readFile(join(this._appRoot, 'ace-manifest.json'), 'utf-8', (error, contents) => { + if (error) { + reject(error) + } else { + resolve(JSON.parse(contents)) + } + }) + }) + } +} diff --git a/src/utils/validateCommand.ts b/src/utils/validateCommand.ts new file mode 100644 index 0000000..fac821f --- /dev/null +++ b/src/utils/validateCommand.ts @@ -0,0 +1,47 @@ +/* +* @adonisjs/ace +* +* (c) Harminder Virk +* +* For the full copyright and license information, please view the LICENSE +* file that was distributed with this source code. +*/ + +import { CommandConstructorContract, CommandArg } from '../Contracts' +import { CommandValidationException } from '../Exceptions/CommandValidationException' + +/** + * Validates the command static properties to ensure that all the + * values are correctly defined for a command to be executed. + */ +export function validateCommand (command: CommandConstructorContract) { + /** + * Ensure command has a name + */ + if (!command.commandName) { + throw CommandValidationException.missingCommandName(command.name) + } + + let optionalArg: CommandArg + + command.args.forEach((arg, index) => { + /** + * Ensure optional arguments comes after required + * arguments + */ + if (optionalArg && arg.required) { + throw CommandValidationException.invalidOptionalArgOrder(optionalArg.name, arg.name) + } + + /** + * Ensure spread arg is the last arg + */ + if (arg.type === 'spread' && command.args.length > index + 1) { + throw CommandValidationException.invalidSpreadArgOrder(arg.name) + } + + if (!arg.required) { + optionalArg = arg + } + }) +} diff --git a/test/kernel.spec.ts b/test/kernel.spec.ts index 590dbb4..3259cd3 100644 --- a/test/kernel.spec.ts +++ b/test/kernel.spec.ts @@ -27,7 +27,7 @@ test.group('Kernel | register', () => { const kernel = new Kernel() const fn = () => kernel.register([Greet]) - assert.throw(fn, 'option argument {name} must be after required argument {age}') + assert.throw(fn, 'optional argument {name} must be after required argument {age}') }) test('raise error when command name is missing', (assert) => { @@ -36,7 +36,7 @@ test.group('Kernel | register', () => { const kernel = new Kernel() const fn = () => kernel.register([Greet]) - assert.throw(fn, 'missing command name for Greet class') + assert.throw(fn, 'missing command name for {Greet} class') }) test('raise error when spread argument isn\'t the last one', (assert) => { @@ -52,7 +52,7 @@ test.group('Kernel | register', () => { const kernel = new Kernel() const fn = () => kernel.register([Greet]) - assert.throw(fn, 'spread arguments must be last') + assert.throw(fn, 'spread argument {files} must be at last position') }) test('return command suggestions for a given string', (assert) => { @@ -107,7 +107,7 @@ test.group('Kernel | handle', () => { try { await kernel.handle(argv) } catch ({ message }) { - assert.equal(message, 'missing required argument name') + assert.equal(message, 'missing required argument {name}') } }) diff --git a/test/manifest.spec.ts b/test/manifest.spec.ts new file mode 100644 index 0000000..ea4aab6 --- /dev/null +++ b/test/manifest.spec.ts @@ -0,0 +1,121 @@ +/* +* @adonisjs/ace +* +* (c) Harminder Virk +* +* For the full copyright and license information, please view the LICENSE +* file that was distributed with this source code. +*/ + +import * as test from 'japa' +import { Manifest } from '../src/Manifest' +import { join } from 'path' +import { Filesystem } from '@adonisjs/dev-utils' + +const fs = new Filesystem(join(__dirname, '__app')) + +test.group('Manifest', (group) => { + group.afterEach(async () => { + await fs.cleanup() + }) + + test('generated manifest from command paths', async (assert) => { + await fs.add('Commands/Make.ts', ` + import { args, flags } from '../../../index' + + export default class Greet { + public static commandName = 'greet' + public static description = 'Greet a user' + + @args.string() + public name: string + + @flags.boolean() + public adult: boolean + }`) + + const manifest = new Manifest(fs.basePath) + await manifest.generate(['Commands/Make.ts']) + + const manifestJSON = require(join(fs.basePath, 'ace-manifest.json')) + assert.deepEqual(manifestJSON, { + greet: { + commandPath: 'Commands/Make.ts', + commandName: 'greet', + description: 'Greet a user', + args: [{ + name: 'name', + type: 'string', + required: true, + }], + flags: [{ + name: 'adult', + type: 'boolean', + }], + }, + }) + }) + + test('raise exception when commandPath doesnt exports a command', async (assert) => { + assert.plan(1) + + await fs.add('Commands/Make.ts', ` + import { args, flags } from '../../../index' + + export class Greet { + public static commandName = 'greet' + public static description = 'Greet a user' + + @args.string() + public name: string + + @flags.boolean() + public adult: boolean + }`) + + const manifest = new Manifest(fs.basePath) + + try { + await manifest.generate(['Commands/Make.ts']) + } catch ({ message }) { + assert.equal(message, 'make sure to have a default export from {Commands/Make.ts}') + } + }) + + test('read manifest file', async (assert) => { + await fs.add('Commands/Make.ts', ` + import { args, flags } from '../../../index' + + export default class Greet { + public static commandName = 'greet' + public static description = 'Greet a user' + + @args.string() + public name: string + + @flags.boolean() + public adult: boolean + }`) + + const manifest = new Manifest(fs.basePath) + await manifest.generate(['Commands/Make.ts']) + + const manifestJSON = await manifest.load() + assert.deepEqual(manifestJSON, { + greet: { + commandPath: 'Commands/Make.ts', + commandName: 'greet', + description: 'Greet a user', + args: [{ + name: 'name', + type: 'string', + required: true, + }], + flags: [{ + name: 'adult', + type: 'boolean', + }], + }, + }) + }) +}) diff --git a/test/parser.spec.ts b/test/parser.spec.ts index 0d21fb9..074c133 100644 --- a/test/parser.spec.ts +++ b/test/parser.spec.ts @@ -127,7 +127,7 @@ test.group('Parser | flags', () => { }) const output = () => parser.parse(['--age=foo']) - assert.throw(output, 'age must be defined as a number') + assert.throw(output, '{age} must be defined as a {number}') }) test('parse value as an array of strings', (assert) => { @@ -192,10 +192,10 @@ test.group('Parser | flags', () => { }) const output = () => parser.parse(['--scores=10', '--scores=foo']) - assert.throw(output, 'scores must be defined as a numArray') + assert.throw(output, '{scores} must be defined as a {numArray}') const fn = () => parser.parse(['--scores=foo']) - assert.throw(fn, 'scores must be defined as a numArray') + assert.throw(fn, '{scores} must be defined as a {numArray}') }) }) @@ -221,7 +221,7 @@ test.group('Parser | args', () => { const parser = new Parser({}) const output = () => parser.parse([], Greet) - assert.throw(output, 'missing required argument name') + assert.throw(output, 'missing required argument {name}') }) test('mark argument as optional', (assert) => {