From 06b4833cf33981bb99aa12fa5d77599de1db3a18 Mon Sep 17 00:00:00 2001 From: kakha urigashvili Date: Mon, 2 Nov 2020 12:21:51 -0800 Subject: [PATCH] feat: add autocomplete --- .gitignore | 2 + bin/ask-autocomplete.js | 29 +++++++ bin/ask.js | 8 +- docs/concepts/Autocompletion.md | 35 ++++++++ lib/commands/abstract-command.js | 1 + lib/commands/autocomplete/helper.js | 61 +++++++++++++ lib/commands/autocomplete/index.js | 50 +++++++++++ lib/commands/smapi/smapi-commander.js | 2 +- lib/commands/util/util-commander.js | 2 +- lib/utils/constants.js | 3 + package.json | 3 +- .../unit/commands/autocomplete/helper-test.js | 86 +++++++++++++++++++ test/unit/commands/autocomplete/index-test.js | 73 ++++++++++++++++ .../commands/smapi/smapi-commander-test.js | 2 +- test/unit/run-test.js | 3 + 15 files changed, 354 insertions(+), 6 deletions(-) create mode 100644 bin/ask-autocomplete.js create mode 100644 docs/concepts/Autocompletion.md create mode 100644 lib/commands/autocomplete/helper.js create mode 100644 lib/commands/autocomplete/index.js create mode 100644 test/unit/commands/autocomplete/helper-test.js create mode 100644 test/unit/commands/autocomplete/index-test.js diff --git a/.gitignore b/.gitignore index 2e7b227e..a6f604dd 100644 --- a/.gitignore +++ b/.gitignore @@ -21,3 +21,5 @@ coverage # test temp test/temp +autocomplete-hints.json + diff --git a/bin/ask-autocomplete.js b/bin/ask-autocomplete.js new file mode 100644 index 00000000..686b1248 --- /dev/null +++ b/bin/ask-autocomplete.js @@ -0,0 +1,29 @@ +#!/usr/bin/env node + +require('module-alias/register'); +const commander = require('commander'); +const { makeAutoCompleteCommander } = require('@src/commands/autocomplete'); +const { makeSmapiCommander } = require('@src/commands/smapi/smapi-commander'); +const ConfigureCommander = require('@src/commands/configure'); +const DeployCommander = require('@src/commands/deploy'); +const DialogCommander = require('@src/commands/dialog'); +const InitCommander = require('@src/commands/init'); +const NewCommander = require('@src/commands/new'); +const UtilCommander = require('@src/commands/util/util-commander'); + +const smapiCommander = makeSmapiCommander(); +const utilCommander = UtilCommander.commander; +const configureCommander = ConfigureCommander.createCommand(commander); +const deployCommander = DeployCommander.createCommand(commander); +const newCommander = NewCommander.createCommand(commander); +const initCommander = InitCommander.createCommand(commander); +const dialogCommander = DialogCommander.createCommand(commander); +const commanders = [smapiCommander, utilCommander, configureCommander, deployCommander, newCommander, initCommander, dialogCommander]; + +const autoCompleteCommander = makeAutoCompleteCommander(commanders); + +if (!process.argv.slice(2).length) { + autoCompleteCommander.outputHelp(); +} else { + autoCompleteCommander.parse(process.argv); +} diff --git a/bin/ask.js b/bin/ask.js index 5cfe360d..23df0440 100755 --- a/bin/ask.js +++ b/bin/ask.js @@ -9,6 +9,8 @@ if (!require('semver').gte(process.version, '8.3.0')) { require('module-alias/register'); const commander = require('commander'); +const CONSTANTS = require('@src/utils/constants'); +const { initAutoComplete } = require('@src/commands/autocomplete'); require('@src/commands/configure').createCommand(commander); require('@src/commands/deploy').createCommand(commander); @@ -16,16 +18,18 @@ require('@src/commands/new').createCommand(commander); require('@src/commands/init').createCommand(commander); require('@src/commands/dialog').createCommand(commander); +initAutoComplete(); + commander .description('Command Line Interface for Alexa Skill Kit') .command('smapi', 'list of Alexa Skill Management API commands') .command('skill', 'increase the productivity when managing skill metadata') + .command('autocomplete', 'sets up terminal auto completion') .command('util', 'tooling functions when using ask-cli to manage Alexa Skill') .version(require('../package.json').version) .parse(process.argv); -const ALLOWED_ASK_ARGV_2 = ['configure', 'deploy', 'new', 'init', 'dialog', 'smapi', 'skill', 'util', 'help', '-v', '--version', '-h', '--help']; -if (process.argv[2] && ALLOWED_ASK_ARGV_2.indexOf(process.argv[2]) === -1) { +if (process.argv[2] && CONSTANTS.TOP_LEVEL_COMMANDS.indexOf(process.argv[2]) === -1) { console.log('Command not recognized. Please run "ask" to check the user instructions.'); process.exit(1); } diff --git a/docs/concepts/Autocompletion.md b/docs/concepts/Autocompletion.md new file mode 100644 index 00000000..bb9d37f9 --- /dev/null +++ b/docs/concepts/Autocompletion.md @@ -0,0 +1,35 @@ +# Autocompletion + +## Prerequisites + +Autocompletion currently works for the following shells: bash, zsh and fish. + +For bash, please install bash-completion. + +``` +brew install bash-completion +``` + +And the then add the following line to ~/.bash_profile or ~/.bashrc: + +``` +[[ -r "/usr/local/etc/profile.d/bash_completion.sh" ]] && . "/usr/local/etc/profile.d/bash_completion.sh" +``` + +## Enable Autocompletion +To setup auto completion, please run the following command and then restart the terminal. + +``` +ask autocomplete setup +``` + + +## Disable Autocompletion +To disable auto completion, please run the following command and then restart the terminal. + +``` +ask autocomplete cleanup +``` + + + diff --git a/lib/commands/abstract-command.js b/lib/commands/abstract-command.js index a7df0a06..235cf500 100644 --- a/lib/commands/abstract-command.js +++ b/lib/commands/abstract-command.js @@ -59,6 +59,7 @@ class AbstractCommand { // register command action this._registerAction(commanderCopy); + return commanderCopy; } catch (err) { Messenger.getInstance().fatal(err); this.exit(1); diff --git a/lib/commands/autocomplete/helper.js b/lib/commands/autocomplete/helper.js new file mode 100644 index 00000000..8f0f072b --- /dev/null +++ b/lib/commands/autocomplete/helper.js @@ -0,0 +1,61 @@ +const fs = require('fs-extra'); +const path = require('path'); + +module.exports = class Helper { + constructor(omelette, commanders = []) { + this.commanders = commanders; + this.completion = omelette('ask'); + this.autoCompleteHintsFile = path.join(__dirname, 'autocomplete-hints.json'); + } + + _getAutoCompleteOptions() { + const options = {}; + this.commanders.forEach(com => { + options[com.name()] = com.commands.map(sumCom => sumCom.name()); + }); + + return options; + } + + /** + * Initializes auto complete inside of the program + */ + initAutoComplete() { + if (fs.existsSync(this.autoCompleteHintsFile)) { + const options = fs.readJsonSync(this.autoCompleteHintsFile); + + this.completion.tree(options); + this.completion.init(); + } + } + + _withProcessExitDisabled(fn) { + const origExit = process.exit; + process.exit = () => {}; + fn(); + process.exit = origExit; + } + + /** + * Regenerates auto complete hints file + */ + reloadAutoCompleteHints() { + const options = this._getAutoCompleteOptions(); + fs.writeJSONSync(this.autoCompleteHintsFile, options); + } + + /** + * Sets ups auto complete. For example, adds autocomplete entry to .bash_profile file + */ + setUpAutoComplete() { + this.reloadAutoCompleteHints(); + this._withProcessExitDisabled(() => this.completion.setupShellInitFile()); + } + + /** + * Removes auto complete. For example, removes autocomplete entry from .bash_profile file + */ + cleanUpAutoComplete() { + this._withProcessExitDisabled(() => this.completion.cleanupShellInitFile()); + } +}; diff --git a/lib/commands/autocomplete/index.js b/lib/commands/autocomplete/index.js new file mode 100644 index 00000000..7676bbf3 --- /dev/null +++ b/lib/commands/autocomplete/index.js @@ -0,0 +1,50 @@ +const commander = require('commander'); +const omelette = require('omelette'); +const Messenger = require('@src/view/messenger'); +const Helper = require('./helper'); + +/** + * Initializes auto complete inside of the program + */ +const initAutoComplete = () => { + const helper = new Helper(omelette); + helper.initAutoComplete(); +}; +/** + * Creates auto complete commander + * @param {*} commanders list of commanders used for creating an autocomplete hints file + */ +const makeAutoCompleteCommander = commanders => { + const program = new commander.Command(); + commanders.push(program); + + const helper = new Helper(omelette, commanders); + + program._name = 'autocomplete'; + program.description('sets up ask cli terminal auto completion'); + + program.command('setup') + .description('set up auto completion') + .action(() => { + helper.setUpAutoComplete(); + Messenger.getInstance().info('Successfully set up auto completion. Please, reload the terminal.'); + }); + + program.command('cleanup') + .description('clean up auto completion') + .action(() => { + helper.cleanUpAutoComplete(); + Messenger.getInstance().info('Successfully removed auto completion. Please, reload the terminal.'); + }); + + program.command('reload') + .description('regenerates hints file') + .action(() => { + helper.reloadAutoCompleteHints(); + Messenger.getInstance().info('Successfully regenerated the hints file.'); + }); + + return program; +}; + +module.exports = { initAutoComplete, makeAutoCompleteCommander }; diff --git a/lib/commands/smapi/smapi-commander.js b/lib/commands/smapi/smapi-commander.js index e901dda4..1620e383 100644 --- a/lib/commands/smapi/smapi-commander.js +++ b/lib/commands/smapi/smapi-commander.js @@ -119,7 +119,7 @@ const makeSmapiCommander = () => { getTask.createCommand(program); searchTask.createCommand(program); - program._name = 'ask smapi'; + program._name = 'smapi'; program .description('The smapi command provides a number of sub-commands that ' + 'enable you to manage Alexa skills associated with your developer account.'); diff --git a/lib/commands/util/util-commander.js b/lib/commands/util/util-commander.js index 9a2190c2..1e0a0a9d 100644 --- a/lib/commands/util/util-commander.js +++ b/lib/commands/util/util-commander.js @@ -11,7 +11,7 @@ Object.keys(UTIL_COMMAND_MAP).forEach((cmd) => { require(UTIL_COMMAND_MAP[cmd]).createCommand(commander); }); -commander._name = 'ask util'; +commander._name = 'util'; commander .description('tooling functions when using ask-cli to manage Alexa Skill'); diff --git a/lib/utils/constants.js b/lib/utils/constants.js index 86a2f5f2..a0c0f775 100644 --- a/lib/utils/constants.js +++ b/lib/utils/constants.js @@ -5,6 +5,9 @@ module.exports.METRICS = { ENDPOINT: 'https://client-telemetry.amazonalexa.com' }; +module.exports.TOP_LEVEL_COMMANDS = ['configure', 'deploy', 'new', 'init', 'dialog', 'smapi', 'skill', 'util', 'help', + 'autocomplete', '-v', '--version', '-h', '--help']; + module.exports.DEPLOYER_TYPE = { HOSTED: { OPTION_NAME: 'Alexa-hosted skills', diff --git a/package.json b/package.json index c31f867a..202059c4 100644 --- a/package.json +++ b/package.json @@ -35,7 +35,7 @@ "prepublishOnly": "babel lib -d lib; babel bin -d bin", "pre-release": "standard-version", "prism": "prism", - "postinstall": "node postinstall.js" + "postinstall": "node bin/ask-autocomplete.js reload; node postinstall.js" }, "dependencies": { "adm-zip": "^0.4.13", @@ -58,6 +58,7 @@ "listr": "^0.14.3", "module-alias": "^2.1.0", "mustache": "^4.0.1", + "omelette": "^0.4.15-1", "open": "^7.0.3", "ora": "^3.4.0", "portscanner": "^2.1.1", diff --git a/test/unit/commands/autocomplete/helper-test.js b/test/unit/commands/autocomplete/helper-test.js new file mode 100644 index 00000000..04c99a96 --- /dev/null +++ b/test/unit/commands/autocomplete/helper-test.js @@ -0,0 +1,86 @@ +const { expect } = require('chai'); +const commander = require('commander'); +const EventEmitter = require('events'); +const fs = require('fs-extra'); +const sinon = require('sinon'); + +const Helper = require('@src/commands/autocomplete/helper'); + +describe('Commands autocomplete - helper test', () => { + let helper; + let setupShellInitFileStub; + let cleanupShellInitFileStub; + let initStub; + let treeStub; + let omeletteStub; + + const testCommander = new commander.Command(); + testCommander._name = 'test'; + testCommander.command('command-one'); + testCommander.command('command-two'); + + const commanders = [testCommander]; + + beforeEach(() => { + setupShellInitFileStub = sinon.stub(); + cleanupShellInitFileStub = sinon.stub(); + initStub = sinon.stub(); + treeStub = sinon.stub(); + + omeletteStub = () => { + class OmeletteStubClass extends EventEmitter { + constructor() { + super(); + this.setupShellInitFile = setupShellInitFileStub; + this.cleanupShellInitFile = cleanupShellInitFileStub; + this.init = initStub; + this.tree = treeStub; + } + } + return new OmeletteStubClass(); + }; + + helper = new Helper(omeletteStub, commanders); + }); + + it('should set up autocomplete', () => { + const writeJSONStub = sinon.stub(fs, 'writeJSONSync'); + helper.setUpAutoComplete(); + + expect(writeJSONStub.callCount).eq(1); + expect(setupShellInitFileStub.callCount).eq(1); + }); + + it('should regenerate autocomplete hints file', () => { + const writeJSONStub = sinon.stub(fs, 'writeJSONSync'); + helper.reloadAutoCompleteHints(); + + expect(writeJSONStub.callCount).eq(1); + }); + + it('should clean up autocomplete', () => { + helper.cleanUpAutoComplete(); + + expect(cleanupShellInitFileStub.callCount).eq(1); + }); + + it('should not initialize autocomplete if hint file is not present', () => { + sinon.stub(fs, 'existsSync').withArgs(helper.autoCompleteHintsFile).returns(false); + helper.initAutoComplete(); + + expect(initStub.callCount).eq(0); + }); + + it('initialize autocomplete if hint file is present', () => { + sinon.stub(fs, 'existsSync').withArgs(helper.autoCompleteHintsFile).returns(true); + sinon.stub(fs, 'readJsonSync').withArgs(helper.autoCompleteHintsFile).returns({}); + helper.initAutoComplete(); + + expect(treeStub.callCount).eq(1); + expect(initStub.callCount).eq(1); + }); + + afterEach(() => { + sinon.restore(); + }); +}); diff --git a/test/unit/commands/autocomplete/index-test.js b/test/unit/commands/autocomplete/index-test.js new file mode 100644 index 00000000..01ad12c4 --- /dev/null +++ b/test/unit/commands/autocomplete/index-test.js @@ -0,0 +1,73 @@ +const { expect } = require('chai'); +const commander = require('commander'); +const sinon = require('sinon'); + +const AutocompleteCommand = require('@src/commands/autocomplete'); +const Helper = require('@src/commands/autocomplete/helper'); +const Messenger = require('@src/view/messenger'); + +describe('Commands autocomplete - index test', () => { + const testCommander = new commander.Command(); + testCommander._name = 'test'; + testCommander.command('command-one'); + testCommander.command('command-two'); + testCommander.option('--large'); + testCommander.option('-s, --small'); + + const commanders = [testCommander]; + + let infoStub; + + beforeEach(() => { + infoStub = sinon.stub(Messenger.getInstance(), 'info'); + }); + + it('should set up autocomplete', () => { + const autoCompleteCommander = AutocompleteCommand.makeAutoCompleteCommander(commanders); + + expect(autoCompleteCommander.name()).eq('autocomplete'); + expect(autoCompleteCommander.description()).eq('sets up ask cli terminal auto completion'); + expect(autoCompleteCommander.commands.map(c => c.name())).eql(['setup', 'cleanup', 'reload']); + }); + + it('should trigger autocomplete set up', () => { + const setUpAutoCompleteStub = sinon.stub(Helper.prototype, 'setUpAutoComplete'); + const autoCompleteCommander = AutocompleteCommand.makeAutoCompleteCommander(commanders); + + autoCompleteCommander.parse(['', '', 'setup']); + + expect(setUpAutoCompleteStub.callCount).eq(1); + expect(infoStub.args[0][0]).eq('Successfully set up auto completion. Please, reload the terminal.'); + }); + + it('should trigger autocomplete reload', () => { + const reloadAutoCompleteHintsStub = sinon.stub(Helper.prototype, 'reloadAutoCompleteHints'); + const autoCompleteCommander = AutocompleteCommand.makeAutoCompleteCommander(commanders); + + autoCompleteCommander.parse(['', '', 'reload']); + + expect(reloadAutoCompleteHintsStub.callCount).eq(1); + expect(infoStub.args[0][0]).eq('Successfully regenerated the hints file.'); + }); + + it('should trigger autocomplete clean up', () => { + const cleanUpAutoCompleteStub = sinon.stub(Helper.prototype, 'cleanUpAutoComplete'); + const autoCompleteCommander = AutocompleteCommand.makeAutoCompleteCommander(commanders); + + autoCompleteCommander.parse(['', '', 'cleanup']); + + expect(cleanUpAutoCompleteStub.callCount).eq(1); + expect(infoStub.args[0][0]).eq('Successfully removed auto completion. Please, reload the terminal.'); + }); + + it('should initialize auto complete', () => { + const initAutoCompleteStub = sinon.stub(Helper.prototype, 'initAutoComplete'); + AutocompleteCommand.initAutoComplete(); + + expect(initAutoCompleteStub.callCount).eq(1); + }); + + afterEach(() => { + sinon.restore(); + }); +}); diff --git a/test/unit/commands/smapi/smapi-commander-test.js b/test/unit/commands/smapi/smapi-commander-test.js index 8d7faa91..b2e587cf 100644 --- a/test/unit/commands/smapi/smapi-commander-test.js +++ b/test/unit/commands/smapi/smapi-commander-test.js @@ -12,7 +12,7 @@ describe('Smapi test - makeSmapiCommander function', () => { it('| should create instance of commander', () => { const commander = makeSmapiCommander(); - expect(commander._name).eql('ask smapi'); + expect(commander._name).eql('smapi'); expect(commander.commands.length).gt(0); }); diff --git a/test/unit/run-test.js b/test/unit/run-test.js index 7ee82853..fab676df 100644 --- a/test/unit/run-test.js +++ b/test/unit/run-test.js @@ -23,6 +23,9 @@ process.env.ASK_SHARE_USAGE = false; // commands '@test/unit/commands/option-validator-test', '@test/unit/commands/abstract-command-test', + // command - autocomplete + '@test/unit/commands/autocomplete/index-test', + '@test/unit/commands/autocomplete/helper-test', // command - util '@test/unit/commands/util/git-credentials-helper/index-test', '@test/unit/commands/util/generate-lwa-tokens/index-test',