diff --git a/bin/kibana-plugin b/bin/kibana-plugin new file mode 100755 index 000000000000..7e8481fa71a2 --- /dev/null +++ b/bin/kibana-plugin @@ -0,0 +1,24 @@ +#!/bin/sh +SCRIPT=$0 + +# SCRIPT may be an arbitrarily deep series of symlinks. Loop until we have the concrete path. +while [ -h "$SCRIPT" ] ; do + ls=$(ls -ld "$SCRIPT") + # Drop everything prior to -> + link=$(expr "$ls" : '.*-> \(.*\)$') + if expr "$link" : '/.*' > /dev/null; then + SCRIPT="$link" + else + SCRIPT=$(dirname "$SCRIPT")/"$link" + fi +done + +DIR="$(dirname "${SCRIPT}")/.." +NODE="${DIR}/node/bin/node" +test -x "$NODE" || NODE=$(which node) +if [ ! -x "$NODE" ]; then + echo "unable to find usable node.js executable." + exit 1 +fi + +exec "${NODE}" $NODE_OPTIONS "${DIR}/src/cli_plugin" ${@} diff --git a/bin/kibana-plugin.bat b/bin/kibana-plugin.bat new file mode 100644 index 000000000000..9d8bdc477812 --- /dev/null +++ b/bin/kibana-plugin.bat @@ -0,0 +1,29 @@ +@echo off + +SETLOCAL + +set SCRIPT_DIR=%~dp0 +for %%I in ("%SCRIPT_DIR%..") do set DIR=%%~dpfI + +set NODE=%DIR%\node\node.exe + +WHERE /Q node +IF %ERRORLEVEL% EQU 0 ( + for /f "delims=" %%i in ('WHERE node') do set SYS_NODE=%%i +) + +If Not Exist "%NODE%" ( + IF Exist "%SYS_NODE%" ( + set "NODE=%SYS_NODE%" + ) else ( + Echo unable to find usable node.js executable. + Exit /B 1 + ) +) + +TITLE Kibana Server +"%NODE%" %NODE_OPTIONS% "%DIR%\src\cli_plugin" %* + +:finally + +ENDLOCAL diff --git a/package.json b/package.json index 6c9e003b207c..edf5a1930b1f 100644 --- a/package.json +++ b/package.json @@ -135,6 +135,7 @@ "style-loader": "0.12.3", "tar": "2.2.0", "url-loader": "0.5.6", + "validate-npm-package-name": "2.2.2", "webpack": "1.12.1", "webpack-directory-name-as-main": "1.0.0", "whatwg-fetch": "0.9.0", diff --git a/src/cli/cli.js b/src/cli/cli.js index 0de588a87ab0..f25608dec1cf 100644 --- a/src/cli/cli.js +++ b/src/cli/cli.js @@ -1,10 +1,10 @@ import _ from 'lodash'; - import pkg from '../utils/package_json'; import Command from './command'; +import serveCommand from './serve/serve'; -let argv = process.env.kbnWorkerArgv ? JSON.parse(process.env.kbnWorkerArgv) : process.argv.slice(); -let program = new Command('bin/kibana'); +const argv = process.env.kbnWorkerArgv ? JSON.parse(process.env.kbnWorkerArgv) : process.argv.slice(); +const program = new Command('bin/kibana'); program .version(pkg.version) @@ -14,15 +14,14 @@ program ); // attach commands -require('./serve/serve')(program); -require('./plugin/plugin')(program); +serveCommand(program); program .command('help ') .description('Get the help for a specific command') .action(function (cmdName) { - var cmd = _.find(program.commands, { _name: cmdName }); - if (!cmd) return this.error(`unknown command ${cmdName}`); + const cmd = _.find(program.commands, { _name: cmdName }); + if (!cmd) return program.error(`unknown command ${cmdName}`); cmd.help(); }); @@ -33,7 +32,7 @@ program }); // check for no command name -var subCommand = argv[2] && !String(argv[2][0]).match(/^-|^\.|\//); +const subCommand = argv[2] && !String(argv[2][0]).match(/^-|^\.|\//); if (!subCommand) { if (_.intersection(argv.slice(2), ['-h', '--help']).length) { diff --git a/src/cli/plugin/__tests__/file_type.js b/src/cli/plugin/__tests__/file_type.js deleted file mode 100644 index e21a9bbe25ed..000000000000 --- a/src/cli/plugin/__tests__/file_type.js +++ /dev/null @@ -1,31 +0,0 @@ -import expect from 'expect.js'; -import fileType, { ZIP, TAR } from '../file_type'; - -describe('kibana cli', function () { - describe('file_type', function () { - it('returns ZIP for .zip filename', function () { - const type = fileType('wat.zip'); - expect(type).to.equal(ZIP); - }); - it('returns TAR for .tar.gz filename', function () { - const type = fileType('wat.tar.gz'); - expect(type).to.equal(TAR); - }); - it('returns TAR for .tgz filename', function () { - const type = fileType('wat.tgz'); - expect(type).to.equal(TAR); - }); - it('returns undefined for unknown file type', function () { - const type = fileType('wat.unknown'); - expect(type).to.equal(undefined); - }); - it('accepts paths', function () { - const type = fileType('/some/path/to/wat.zip'); - expect(type).to.equal(ZIP); - }); - it('accepts urls', function () { - const type = fileType('http://example.com/wat.zip'); - expect(type).to.equal(ZIP); - }); - }); -}); diff --git a/src/cli/plugin/__tests__/plugin_downloader.js b/src/cli/plugin/__tests__/plugin_downloader.js deleted file mode 100644 index 5fd05dd21d3f..000000000000 --- a/src/cli/plugin/__tests__/plugin_downloader.js +++ /dev/null @@ -1,335 +0,0 @@ -import expect from 'expect.js'; -import sinon from 'sinon'; -import nock from 'nock'; -import glob from 'glob-all'; -import rimraf from 'rimraf'; -import mkdirp from 'mkdirp'; -import pluginLogger from '../plugin_logger'; -import pluginDownloader from '../plugin_downloader'; -import { join } from 'path'; - -describe('kibana cli', function () { - - describe('plugin downloader', function () { - const testWorkingPath = join(__dirname, '.test.data'); - const tempArchiveFilePath = join(testWorkingPath, 'archive.part'); - let logger; - let downloader; - - function expectWorkingPathEmpty() { - const files = glob.sync('**/*', { cwd: testWorkingPath }); - expect(files).to.eql([]); - } - - function expectWorkingPathNotEmpty() { - const files = glob.sync('**/*', { cwd: testWorkingPath }); - const expected = [ - 'archive.part' - ]; - - expect(files.sort()).to.eql(expected.sort()); - } - - function shouldReject() { - throw new Error('expected the promise to reject'); - } - - beforeEach(function () { - logger = pluginLogger(false); - sinon.stub(logger, 'log'); - sinon.stub(logger, 'error'); - rimraf.sync(testWorkingPath); - mkdirp.sync(testWorkingPath); - }); - - afterEach(function () { - logger.log.restore(); - logger.error.restore(); - rimraf.sync(testWorkingPath); - }); - - describe('_downloadSingle', function () { - - beforeEach(function () { - const settings = { - urls: [], - workingPath: testWorkingPath, - tempArchiveFile: tempArchiveFilePath, - timeout: 0 - }; - downloader = pluginDownloader(settings, logger); - }); - - describe('http downloader', function () { - - it('should download an unsupported file type, but return undefined for archiveType', function () { - const filePath = join(__dirname, 'replies/banana.jpg'); - const couchdb = nock('http://www.files.com') - .defaultReplyHeaders({ - 'content-length': '10', - 'content-type': 'image/jpeg' - }) - .get('/banana.jpg') - .replyWithFile(200, filePath); - - const sourceUrl = 'http://www.files.com/banana.jpg'; - return downloader._downloadSingle(sourceUrl) - .then(function (data) { - expect(data.archiveType).to.be(undefined); - expectWorkingPathNotEmpty(); - }); - }); - - it('should throw an ENOTFOUND error for a http ulr that returns 404', function () { - const couchdb = nock('http://www.files.com') - .get('/plugin.tar.gz') - .reply(404); - - const sourceUrl = 'http://www.files.com/plugin.tar.gz'; - - return downloader._downloadSingle(sourceUrl) - .then(shouldReject, function (err) { - expect(err.message).to.match(/ENOTFOUND/); - expectWorkingPathEmpty(); - }); - }); - - it('should throw an ENOTFOUND error for an invalid url', function () { - const sourceUrl = 'i am an invalid url'; - - return downloader._downloadSingle(sourceUrl) - .then(shouldReject, function (err) { - expect(err.message).to.match(/ENOTFOUND/); - expectWorkingPathEmpty(); - }); - }); - - it('should download a tarball from a valid http url', function () { - const filePath = join(__dirname, 'replies/test_plugin_master.tar.gz'); - - const couchdb = nock('http://www.files.com') - .defaultReplyHeaders({ - 'content-length': '10', - 'content-type': 'application/x-gzip' - }) - .get('/plugin.tar.gz') - .replyWithFile(200, filePath); - - const sourceUrl = 'http://www.files.com/plugin.tar.gz'; - - return downloader._downloadSingle(sourceUrl) - .then(function (data) { - expect(data.archiveType).to.be('.tar.gz'); - expectWorkingPathNotEmpty(); - }); - }); - - it('should consider .tgz files as archive type .tar.gz', function () { - const filePath = join(__dirname, 'replies/test_plugin_master.tar.gz'); - - const couchdb = nock('http://www.files.com') - .defaultReplyHeaders({ - 'content-length': '10' - }) - .get('/plugin.tgz') - .replyWithFile(200, filePath); - - const sourceUrl = 'http://www.files.com/plugin.tgz'; - - return downloader._downloadSingle(sourceUrl) - .then(function (data) { - expect(data.archiveType).to.be('.tar.gz'); - expectWorkingPathNotEmpty(); - }); - }); - - it('should download a zip from a valid http url', function () { - const filePath = join(__dirname, 'replies/test_plugin_master.zip'); - - const couchdb = nock('http://www.files.com') - .defaultReplyHeaders({ - 'content-length': '341965', - 'content-type': 'application/zip' - }) - .get('/plugin.zip') - .replyWithFile(200, filePath); - - const sourceUrl = 'http://www.files.com/plugin.zip'; - - return downloader._downloadSingle(sourceUrl) - .then(function (data) { - expect(data.archiveType).to.be('.zip'); - expectWorkingPathNotEmpty(); - }); - }); - - }); - - describe('local file downloader', function () { - - it('should copy an unsupported file type, but return undefined for archiveType', function () { - const filePath = join(__dirname, 'replies/banana.jpg'); - const sourceUrl = 'file://' + filePath.replace(/\\/g, '/'); - - const couchdb = nock('http://www.files.com') - .defaultReplyHeaders({ - 'content-length': '10', - 'content-type': 'image/jpeg' - }) - .get('/banana.jpg') - .replyWithFile(200, filePath); - - return downloader._downloadSingle(sourceUrl) - .then(function (data) { - expect(data.archiveType).to.be(undefined); - expectWorkingPathNotEmpty(); - }); - }); - - it('should throw an ENOTFOUND error for an invalid local file', function () { - const filePath = join(__dirname, 'replies/i-am-not-there.tar.gz'); - const sourceUrl = 'file://' + filePath.replace(/\\/g, '/'); - - return downloader._downloadSingle(sourceUrl) - .then(shouldReject, function (err) { - expect(err.message).to.match(/ENOTFOUND/); - expectWorkingPathEmpty(); - }); - }); - - it('should copy a tarball from a valid local file', function () { - const filePath = join(__dirname, 'replies/test_plugin_master.tar.gz'); - const sourceUrl = 'file://' + filePath.replace(/\\/g, '/'); - - return downloader._downloadSingle(sourceUrl) - .then(function (data) { - expect(data.archiveType).to.be('.tar.gz'); - expectWorkingPathNotEmpty(); - }); - }); - - it('should copy a zip from a valid local file', function () { - const filePath = join(__dirname, 'replies/test_plugin_master.zip'); - const sourceUrl = 'file://' + filePath.replace(/\\/g, '/'); - - return downloader._downloadSingle(sourceUrl) - .then(function (data) { - expect(data.archiveType).to.be('.zip'); - expectWorkingPathNotEmpty(); - }); - }); - - }); - - }); - - describe('download', function () { - it('should loop through bad urls until it finds a good one.', function () { - const filePath = join(__dirname, 'replies/test_plugin_master.tar.gz'); - const settings = { - urls: [ - 'http://www.files.com/badfile1.tar.gz', - 'http://www.files.com/badfile2.tar.gz', - 'I am a bad uri', - 'http://www.files.com/goodfile.tar.gz' - ], - workingPath: testWorkingPath, - tempArchiveFile: tempArchiveFilePath, - timeout: 0 - }; - downloader = pluginDownloader(settings, logger); - - const couchdb = nock('http://www.files.com') - .defaultReplyHeaders({ - 'content-length': '10' - }) - .get('/badfile1.tar.gz') - .reply(404) - .get('/badfile2.tar.gz') - .reply(404) - .get('/goodfile.tar.gz') - .replyWithFile(200, filePath); - - return downloader.download(settings, logger) - .then(function (data) { - expect(logger.log.getCall(0).args[0]).to.match(/badfile1.tar.gz/); - expect(logger.log.getCall(1).args[0]).to.match(/badfile2.tar.gz/); - expect(logger.log.getCall(2).args[0]).to.match(/I am a bad uri/); - expect(logger.log.getCall(3).args[0]).to.match(/goodfile.tar.gz/); - expectWorkingPathNotEmpty(); - }); - }); - - it('should stop looping through urls when it finds a good one.', function () { - const filePath = join(__dirname, 'replies/test_plugin_master.tar.gz'); - const settings = { - urls: [ - 'http://www.files.com/badfile1.tar.gz', - 'http://www.files.com/badfile2.tar.gz', - 'http://www.files.com/goodfile.tar.gz', - 'http://www.files.com/badfile3.tar.gz' - ], - workingPath: testWorkingPath, - tempArchiveFile: tempArchiveFilePath, - timeout: 0 - }; - downloader = pluginDownloader(settings, logger); - - const couchdb = nock('http://www.files.com') - .defaultReplyHeaders({ - 'content-length': '10' - }) - .get('/badfile1.tar.gz') - .reply(404) - .get('/badfile2.tar.gz') - .reply(404) - .get('/goodfile.tar.gz') - .replyWithFile(200, filePath) - .get('/badfile3.tar.gz') - .reply(404); - - return downloader.download(settings, logger) - .then(function (data) { - for (let i = 0; i < logger.log.callCount; i++) { - expect(logger.log.getCall(i).args[0]).to.not.match(/badfile3.tar.gz/); - } - expectWorkingPathNotEmpty(); - }); - }); - - it('should throw an error when it doesn\'t find a good url.', function () { - const settings = { - urls: [ - 'http://www.files.com/badfile1.tar.gz', - 'http://www.files.com/badfile2.tar.gz', - 'http://www.files.com/badfile3.tar.gz' - ], - workingPath: testWorkingPath, - tempArchiveFile: tempArchiveFilePath, - timeout: 0 - }; - downloader = pluginDownloader(settings, logger); - - const couchdb = nock('http://www.files.com') - .defaultReplyHeaders({ - 'content-length': '10' - }) - .get('/badfile1.tar.gz') - .reply(404) - .get('/badfile2.tar.gz') - .reply(404) - .get('/badfile3.tar.gz') - .reply(404); - - return downloader.download(settings, logger) - .then(shouldReject, function (err) { - expect(err.message).to.match(/no valid url specified/i); - expectWorkingPathEmpty(); - }); - }); - - }); - - }); - -}); diff --git a/src/cli/plugin/__tests__/plugin_extractor.js b/src/cli/plugin/__tests__/plugin_extractor.js deleted file mode 100644 index 7bbb5b737a68..000000000000 --- a/src/cli/plugin/__tests__/plugin_extractor.js +++ /dev/null @@ -1,131 +0,0 @@ -import expect from 'expect.js'; -import sinon from 'sinon'; -import glob from 'glob-all'; -import rimraf from 'rimraf'; -import mkdirp from 'mkdirp'; - -import pluginLogger from '../plugin_logger'; -import extract from '../plugin_extractor'; -import pluginDownloader from '../plugin_downloader'; -import { join } from 'path'; - -describe('kibana cli', function () { - - describe('plugin extractor', function () { - - const testWorkingPath = join(__dirname, '.test.data'); - const tempArchiveFilePath = join(testWorkingPath, 'archive.part'); - let logger; - let downloader; - - const settings = { - workingPath: testWorkingPath, - tempArchiveFile: tempArchiveFilePath - }; - - function shouldReject() { - throw new Error('expected the promise to reject'); - } - - beforeEach(function () { - logger = pluginLogger(false); - sinon.stub(logger, 'log'); - sinon.stub(logger, 'error'); - rimraf.sync(testWorkingPath); - mkdirp.sync(testWorkingPath); - downloader = pluginDownloader(settings, logger); - }); - - afterEach(function () { - logger.log.restore(); - logger.error.restore(); - rimraf.sync(testWorkingPath); - }); - - function copyReplyFile(filename) { - const filePath = join(__dirname, 'replies', filename); - const sourceUrl = 'file://' + filePath.replace(/\\/g, '/'); - - return downloader._downloadSingle(sourceUrl); - } - - function shouldReject() { - throw new Error('expected the promise to reject'); - } - - describe('extractArchive', function () { - - it('successfully extract a valid tarball', function () { - return copyReplyFile('test_plugin_master.tar.gz') - .then((data) => { - return extract(settings, logger, data.archiveType); - }) - .then(() => { - const files = glob.sync('**/*', { cwd: testWorkingPath }); - const expected = [ - 'archive.part', - 'README.md', - 'index.js', - 'package.json', - 'public', - 'public/app.js' - ]; - expect(files.sort()).to.eql(expected.sort()); - }); - }); - - it('successfully extract a valid zip', function () { - return copyReplyFile('test_plugin_master.zip') - .then((data) => { - return extract(settings, logger, data.archiveType); - }) - .then(() => { - const files = glob.sync('**/*', { cwd: testWorkingPath }); - const expected = [ - 'archive.part', - 'README.md', - 'index.js', - 'package.json', - 'public', - 'public/app.js', - 'extra file only in zip.txt' - ]; - expect(files.sort()).to.eql(expected.sort()); - }); - }); - - it('throw an error when extracting a corrupt zip', function () { - return copyReplyFile('corrupt.zip') - .then((data) => { - return extract(settings, logger, data.archiveType); - }) - .then(shouldReject, (err) => { - expect(err.message).to.match(/error extracting/i); - }); - }); - - it('throw an error when extracting a corrupt tarball', function () { - return copyReplyFile('corrupt.tar.gz') - .then((data) => { - return extract(settings, logger, data.archiveType); - }) - .then(shouldReject, (err) => { - expect(err.message).to.match(/error extracting/i); - }); - }); - - it('throw an error when passed an unknown archive type', function () { - return copyReplyFile('banana.jpg') - .then((data) => { - return extract(settings, logger, data.archiveType); - }) - .then(shouldReject, (err) => { - expect(err.message).to.match(/unsupported archive format/i); - }); - }); - - }); - - }); - -}); diff --git a/src/cli/plugin/__tests__/plugin_installer.js b/src/cli/plugin/__tests__/plugin_installer.js deleted file mode 100644 index a8988052fdf4..000000000000 --- a/src/cli/plugin/__tests__/plugin_installer.js +++ /dev/null @@ -1,55 +0,0 @@ -import expect from 'expect.js'; -import sinon from 'sinon'; -import rimraf from 'rimraf'; -import pluginLogger from '../plugin_logger'; -import pluginInstaller from '../plugin_installer'; -import { mkdirSync } from 'fs'; -import { join } from 'path'; - -describe('kibana cli', function () { - - describe('plugin installer', function () { - - describe('pluginInstaller', function () { - let logger; - let testWorkingPath; - let processExitStub; - - beforeEach(function () { - processExitStub = undefined; - logger = pluginLogger(false); - testWorkingPath = join(__dirname, '.test.data'); - rimraf.sync(testWorkingPath); - sinon.stub(logger, 'log'); - sinon.stub(logger, 'error'); - }); - - afterEach(function () { - if (processExitStub) processExitStub.restore(); - logger.log.restore(); - logger.error.restore(); - rimraf.sync(testWorkingPath); - }); - - it('should throw an error if the workingPath already exists.', function () { - processExitStub = sinon.stub(process, 'exit'); - mkdirSync(testWorkingPath); - - let settings = { - pluginPath: testWorkingPath - }; - - var errorStub = sinon.stub(); - return pluginInstaller.install(settings, logger) - .catch(errorStub) - .then(function (data) { - expect(logger.error.firstCall.args[0]).to.match(/already exists/); - expect(process.exit.called).to.be(true); - }); - }); - - }); - - }); - -}); diff --git a/src/cli/plugin/__tests__/replies/corrupt.tar.gz b/src/cli/plugin/__tests__/replies/corrupt.tar.gz deleted file mode 100644 index d737f1ef2271..000000000000 Binary files a/src/cli/plugin/__tests__/replies/corrupt.tar.gz and /dev/null differ diff --git a/src/cli/plugin/__tests__/replies/plugin_no_package.gz b/src/cli/plugin/__tests__/replies/plugin_no_package.gz deleted file mode 100644 index 8695565d86ff..000000000000 Binary files a/src/cli/plugin/__tests__/replies/plugin_no_package.gz and /dev/null differ diff --git a/src/cli/plugin/__tests__/replies/test_plugin_master.tar.gz b/src/cli/plugin/__tests__/replies/test_plugin_master.tar.gz deleted file mode 100644 index 0fecb5e016c3..000000000000 Binary files a/src/cli/plugin/__tests__/replies/test_plugin_master.tar.gz and /dev/null differ diff --git a/src/cli/plugin/__tests__/replies/test_plugin_master.zip b/src/cli/plugin/__tests__/replies/test_plugin_master.zip deleted file mode 100644 index ead5be66e92f..000000000000 Binary files a/src/cli/plugin/__tests__/replies/test_plugin_master.zip and /dev/null differ diff --git a/src/cli/plugin/__tests__/setting_parser.js b/src/cli/plugin/__tests__/setting_parser.js deleted file mode 100644 index bf47bd7c82c1..000000000000 --- a/src/cli/plugin/__tests__/setting_parser.js +++ /dev/null @@ -1,382 +0,0 @@ -import path from 'path'; -import expect from 'expect.js'; - -import fromRoot from '../../../utils/from_root'; -import settingParser from '../setting_parser'; - -describe('kibana cli', function () { - - describe('plugin installer', function () { - - describe('command line option parsing', function () { - - describe('parseMilliseconds function', function () { - - var parser = settingParser(); - - it('should return 0 for an empty string', function () { - var value = ''; - - var result = parser.parseMilliseconds(value); - - expect(result).to.be(0); - }); - - it('should return 0 for a number with an invalid unit of measure', function () { - var result = parser.parseMilliseconds('1gigablasts'); - expect(result).to.be(0); - }); - - it('should assume a number with no unit of measure is specified as milliseconds', function () { - var result = parser.parseMilliseconds(1); - expect(result).to.be(1); - - result = parser.parseMilliseconds('1'); - expect(result).to.be(1); - }); - - it('should interpret a number with "s" as the unit of measure as seconds', function () { - var result = parser.parseMilliseconds('5s'); - expect(result).to.be(5 * 1000); - }); - - it('should interpret a number with "second" as the unit of measure as seconds', function () { - var result = parser.parseMilliseconds('5second'); - expect(result).to.be(5 * 1000); - }); - - it('should interpret a number with "seconds" as the unit of measure as seconds', function () { - var result = parser.parseMilliseconds('5seconds'); - expect(result).to.be(5 * 1000); - }); - - it('should interpret a number with "m" as the unit of measure as minutes', function () { - var result = parser.parseMilliseconds('9m'); - expect(result).to.be(9 * 1000 * 60); - }); - - it('should interpret a number with "minute" as the unit of measure as minutes', function () { - var result = parser.parseMilliseconds('9minute'); - expect(result).to.be(9 * 1000 * 60); - }); - - it('should interpret a number with "minutes" as the unit of measure as minutes', function () { - var result = parser.parseMilliseconds('9minutes'); - expect(result).to.be(9 * 1000 * 60); - }); - - }); - - describe('parse function', function () { - - var options; - var parser; - beforeEach(function () { - options = { install: 'dummy/dummy', pluginDir: fromRoot('installedPlugins') }; - }); - - it('should require the user to specify either install, remove, or list', function () { - options.install = null; - parser = settingParser(options); - - expect(parser.parse).withArgs().to.throwError(/Please specify either --install, --remove, or --list./); - }); - - it('should not allow the user to specify both install and remove', function () { - options.remove = 'package'; - options.install = 'org/package/version'; - parser = settingParser(options); - - expect(parser.parse).withArgs().to.throwError(/Please specify either --install, --remove, or --list./); - }); - - it('should not allow the user to specify both install and list', function () { - options.list = true; - options.install = 'org/package/version'; - parser = settingParser(options); - - expect(parser.parse).withArgs().to.throwError(/Please specify either --install, --remove, or --list./); - }); - - it('should not allow the user to specify both remove and list', function () { - options.list = true; - options.remove = 'package'; - parser = settingParser(options); - - expect(parser.parse).withArgs().to.throwError(/Please specify either --install, --remove, or --list./); - }); - - it('should not allow the user to specify install, remove, and list', function () { - options.list = true; - options.install = 'org/package/version'; - options.remove = 'package'; - parser = settingParser(options); - - expect(parser.parse).withArgs().to.throwError(/Please specify either --install, --remove, or --list./); - }); - - describe('quiet option', function () { - - it('should default to false', function () { - parser = settingParser(options); - var settings = parser.parse(options); - - expect(settings.quiet).to.be(false); - }); - - it('should set settings.quiet property to true', function () { - options.parent = { quiet: true }; - parser = settingParser(options); - var settings = parser.parse(options); - - expect(settings.quiet).to.be(true); - }); - - }); - - describe('silent option', function () { - - it('should default to false', function () { - parser = settingParser(options); - var settings = parser.parse(options); - - expect(settings).to.have.property('silent', false); - }); - - it('should set settings.silent property to true', function () { - options.silent = true; - parser = settingParser(options); - var settings = parser.parse(options); - - expect(settings).to.have.property('silent', true); - }); - - }); - - - describe('timeout option', function () { - - it('should default to 0 (milliseconds)', function () { - parser = settingParser(options); - var settings = parser.parse(options); - - expect(settings).to.have.property('timeout', 0); - }); - - it('should set settings.timeout property to specified value', function () { - options.timeout = 1234; - parser = settingParser(options); - var settings = parser.parse(options); - - expect(settings).to.have.property('timeout', 1234); - }); - - }); - - describe('install option', function () { - - it('should set settings.action property to "install"', function () { - options.install = 'org/package/version'; - parser = settingParser(options); - var settings = parser.parse(options); - - expect(settings).to.have.property('action', 'install'); - }); - - it('should allow two parts to the install parameter', function () { - options.install = 'kibana/test-plugin'; - parser = settingParser(options); - - expect(parser.parse).withArgs().to.not.throwError(); - - var settings = parser.parse(options); - - expect(settings).to.have.property('organization', 'kibana'); - expect(settings).to.have.property('package', 'test-plugin'); - expect(settings).to.have.property('version', undefined); - }); - - it('should allow three parts to the install parameter', function () { - options.install = 'kibana/test-plugin/v1.0.1'; - parser = settingParser(options); - - expect(parser.parse).withArgs().to.not.throwError(); - - var settings = parser.parse(options); - - expect(settings).to.have.property('organization', 'kibana'); - expect(settings).to.have.property('package', 'test-plugin'); - expect(settings).to.have.property('version', 'v1.0.1'); - }); - - it('should not allow one part to the install parameter', function () { - options.install = 'test-plugin'; - parser = settingParser(options); - - expect(parser.parse).withArgs().to.throwError(/Invalid install option. Please use the format \/\/./); - }); - - it('should not allow more than three parts to the install parameter', function () { - options.install = 'kibana/test-plugin/v1.0.1/dummy'; - parser = settingParser(options); - - expect(parser.parse).withArgs().to.throwError(/Invalid install option. Please use the format \/\/./); - }); - - it('should populate the urls collection properly when no version specified', function () { - options.install = 'kibana/test-plugin'; - parser = settingParser(options); - - var settings = parser.parse(); - - expect(settings.urls).to.have.property('length', 1); - expect(settings.urls).to.contain('https://download.elastic.co/kibana/test-plugin/test-plugin-latest.tar.gz'); - }); - - it('should populate the urls collection properly version specified', function () { - options.install = 'kibana/test-plugin/v1.1.1'; - parser = settingParser(options); - - var settings = parser.parse(); - - expect(settings.urls).to.have.property('length', 1); - expect(settings.urls).to.contain('https://download.elastic.co/kibana/test-plugin/test-plugin-v1.1.1.tar.gz'); - }); - - it('should populate the pluginPath', function () { - options.install = 'kibana/test-plugin'; - parser = settingParser(options); - - var settings = parser.parse(); - var expected = fromRoot('installedPlugins/test-plugin'); - - expect(settings).to.have.property('pluginPath', expected); - }); - - it('should populate the workingPath', function () { - options.install = 'kibana/test-plugin'; - parser = settingParser(options); - - var settings = parser.parse(); - var expected = fromRoot('installedPlugins/.plugin.installing'); - - expect(settings).to.have.property('workingPath', expected); - }); - - it('should populate the tempArchiveFile', function () { - options.install = 'kibana/test-plugin'; - parser = settingParser(options); - - var settings = parser.parse(); - var expected = fromRoot('installedPlugins/.plugin.installing/archive.part'); - - expect(settings).to.have.property('tempArchiveFile', expected); - }); - - describe('with url option', function () { - - it('should allow one part to the install parameter', function () { - options.install = 'test-plugin'; - options.url = 'http://www.google.com/plugin.tar.gz'; - parser = settingParser(options); - - expect(parser.parse).withArgs().to.not.throwError(); - - var settings = parser.parse(); - - expect(settings).to.have.property('package', 'test-plugin'); - }); - - it('should not allow more than one part to the install parameter', function () { - options.url = 'http://www.google.com/plugin.tar.gz'; - options.install = 'kibana/test-plugin'; - parser = settingParser(options); - - expect(parser.parse).withArgs() - .to.throwError(/Invalid install option. When providing a url, please use the format ./); - }); - - it('should result in only the specified url in urls collection', function () { - var url = 'http://www.google.com/plugin.tar.gz'; - options.install = 'test-plugin'; - options.url = url; - parser = settingParser(options); - - var settings = parser.parse(); - - expect(settings).to.have.property('urls'); - expect(settings.urls).to.be.an('array'); - expect(settings.urls).to.have.property('length', 1); - expect(settings.urls).to.contain(url); - }); - - }); - - }); - - describe('remove option', function () { - - it('should set settings.action property to "remove"', function () { - delete options.install; - options.remove = 'package'; - parser = settingParser(options); - - var settings = parser.parse(); - - expect(settings).to.have.property('action', 'remove'); - }); - - it('should allow one part to the remove parameter', function () { - delete options.install; - options.remove = 'test-plugin'; - parser = settingParser(options); - - var settings = parser.parse(); - - expect(settings).to.have.property('package', 'test-plugin'); - }); - - it('should not allow more than one part to the remove parameter', function () { - delete options.install; - options.remove = 'kibana/test-plugin'; - parser = settingParser(options); - - expect(parser.parse).withArgs() - .to.throwError(/Invalid remove option. Please use the format ./); - }); - - it('should populate the pluginPath', function () { - delete options.install; - options.remove = 'test-plugin'; - parser = settingParser(options); - - var settings = parser.parse(); - var expected = fromRoot('installedPlugins/test-plugin'); - - expect(settings).to.have.property('pluginPath', expected); - }); - - }); - - describe('list option', function () { - - it('should set settings.action property to "list"', function () { - delete options.install; - delete options.remove; - options.list = true; - parser = settingParser(options); - - var settings = parser.parse(); - - expect(settings).to.have.property('action', 'list'); - }); - - }); - - }); - - }); - - }); - -}); diff --git a/src/cli/plugin/extractors/tar_gz.js b/src/cli/plugin/extractors/tar_gz.js deleted file mode 100644 index f9b8b538fdb4..000000000000 --- a/src/cli/plugin/extractors/tar_gz.js +++ /dev/null @@ -1,34 +0,0 @@ -import zlib from 'zlib'; -import fs from 'fs'; -import tar from 'tar'; - -async function extractArchive(settings) { - await new Promise((resolve, reject) => { - const gunzip = zlib.createGunzip(); - const tarExtract = new tar.Extract({ path: settings.workingPath, strip: 1 }); - const readStream = fs.createReadStream(settings.tempArchiveFile); - - readStream.on('error', reject); - gunzip.on('error', reject); - tarExtract.on('error', reject); - - readStream - .pipe(gunzip) - .pipe(tarExtract); - - tarExtract.on('finish', resolve); - }); -} - -export default async function extractTarball(settings, logger) { - try { - logger.log('Extracting plugin archive'); - - await extractArchive(settings); - - logger.log('Extraction complete'); - } catch (err) { - logger.error(err); - throw new Error('Error extracting plugin archive'); - } -}; diff --git a/src/cli/plugin/extractors/zip.js b/src/cli/plugin/extractors/zip.js deleted file mode 100644 index da5d20005b35..000000000000 --- a/src/cli/plugin/extractors/zip.js +++ /dev/null @@ -1,32 +0,0 @@ -import DecompressZip from '@bigfunger/decompress-zip'; - -async function extractArchive(settings) { - await new Promise((resolve, reject) => { - const unzipper = new DecompressZip(settings.tempArchiveFile); - - unzipper.on('error', reject); - - unzipper.extract({ - path: settings.workingPath, - strip: 1, - filter(file) { - return file.type !== 'SymbolicLink'; - } - }); - - unzipper.on('extract', resolve); - }); -} - -export default async function extractZip(settings, logger) { - try { - logger.log('Extracting plugin archive'); - - await extractArchive(settings); - - logger.log('Extraction complete'); - } catch (err) { - logger.error(err); - throw new Error('Error extracting plugin archive'); - } -}; diff --git a/src/cli/plugin/file_type.js b/src/cli/plugin/file_type.js deleted file mode 100644 index 78c76d8eee91..000000000000 --- a/src/cli/plugin/file_type.js +++ /dev/null @@ -1,14 +0,0 @@ -export const TAR = '.tar.gz'; -export const ZIP = '.zip'; - -export default function fileType(filename) { - if (/\.zip$/i.test(filename)) { - return ZIP; - } - if (/\.tar\.gz$/i.test(filename)) { - return TAR; - } - if (/\.tgz$/i.test(filename)) { - return TAR; - } -} diff --git a/src/cli/plugin/plugin.js b/src/cli/plugin/plugin.js deleted file mode 100644 index 390d2e48baee..000000000000 --- a/src/cli/plugin/plugin.js +++ /dev/null @@ -1,75 +0,0 @@ -import fromRoot from '../../utils/from_root'; -import settingParser from './setting_parser'; -import installer from './plugin_installer'; -import remover from './plugin_remover'; -import lister from './plugin_lister'; -import pluginLogger from './plugin_logger'; - -export default function pluginCli(program) { - function processCommand(command, options) { - let settings; - try { - settings = settingParser(command).parse(); - } catch (ex) { - //The logger has not yet been initialized. - console.error(ex.message); - process.exit(64); // eslint-disable-line no-process-exit - } - - const logger = pluginLogger(settings); - - switch (settings.action) { - case 'install': - installer.install(settings, logger); - break; - case 'remove': - remover.remove(settings, logger); - break; - case 'list': - lister.list(settings, logger); - break; - } - } - - program - .command('plugin') - .option('-i, --install //', 'The plugin to install') - .option('-r, --remove ', 'The plugin to remove') - .option('-l, --list', 'List installed plugins') - .option('-q, --quiet', 'Disable all process messaging except errors') - .option('-s, --silent', 'Disable all process messaging') - .option('-u, --url ', 'Specify download url') - .option( - '-c, --config ', - 'Path to the config file', - fromRoot('config/kibana.yml') - ) - .option( - '-t, --timeout ', - 'Length of time before failing; 0 for never fail', - settingParser.parseMilliseconds - ) - .option( - '-d, --plugin-dir ', - 'The path to the directory where plugins are stored', - fromRoot('installedPlugins') - ) - .description( - 'Maintain Plugins', -` - Common examples: - -i username/sample - attempts to download the latest version from the following url: - https://download.elastic.co/username/sample/sample-latest.tar.gz - - -i username/sample/v1.1.1 - attempts to download version v1.1.1 from the following url: - https://download.elastic.co/username/sample/sample-v1.1.1.tar.gz - - -i sample -u http://www.example.com/other_name.tar.gz - attempts to download from the specified url, - and installs the plugin found at that url as "sample" -` - ) - .action(processCommand); -}; diff --git a/src/cli/plugin/plugin_cleaner.js b/src/cli/plugin/plugin_cleaner.js deleted file mode 100644 index d3bffdd46e82..000000000000 --- a/src/cli/plugin/plugin_cleaner.js +++ /dev/null @@ -1,39 +0,0 @@ -import rimraf from 'rimraf'; -import fs from 'fs'; - -export default function createPluginCleaner(settings, logger) { - function cleanPrevious() { - return new Promise(function (resolve, reject) { - try { - fs.statSync(settings.workingPath); - - logger.log('Found previous install attempt. Deleting...'); - try { - rimraf.sync(settings.workingPath); - } catch (e) { - return reject(e); - } - return resolve(); - } catch (e) { - if (e.code !== 'ENOENT') return reject(e); - - return resolve(); - } - }); - } - - function cleanError() { - // delete the working directory. - // At this point we're bailing, so swallow any errors on delete. - try { - rimraf.sync(settings.workingPath); - rimraf.sync(settings.pluginPath); - } - catch (e) {} // eslint-disable-line no-empty - } - - return { - cleanPrevious: cleanPrevious, - cleanError: cleanError - }; -}; diff --git a/src/cli/plugin/plugin_downloader.js b/src/cli/plugin/plugin_downloader.js deleted file mode 100644 index 7b1affe39d37..000000000000 --- a/src/cli/plugin/plugin_downloader.js +++ /dev/null @@ -1,51 +0,0 @@ -import _ from 'lodash'; -import downloadHttpFile from './downloaders/http'; -import downloadLocalFile from './downloaders/file'; -import { parse as urlParse } from 'url'; - -export default function createPluginDownloader(settings, logger) { - let archiveType; - let sourceType; - - //Attempts to download each url in turn until one is successful - function download() { - const urls = settings.urls.slice(0); - - function tryNext() { - const sourceUrl = urls.shift(); - if (!sourceUrl) { - throw new Error('No valid url specified.'); - } - - logger.log(`Attempting to transfer from ${sourceUrl}`); - - return downloadSingle(sourceUrl) - .catch((err) => { - if (err.message === 'ENOTFOUND') { - return tryNext(); - } - throw (err); - }); - } - - return tryNext(); - } - - function downloadSingle(sourceUrl) { - const urlInfo = urlParse(sourceUrl); - let downloadPromise; - - if (/^file/.test(urlInfo.protocol)) { - downloadPromise = downloadLocalFile(logger, urlInfo.path, settings.tempArchiveFile); - } else { - downloadPromise = downloadHttpFile(logger, sourceUrl, settings.tempArchiveFile, settings.timeout); - } - - return downloadPromise; - } - - return { - download: download, - _downloadSingle: downloadSingle - }; -}; diff --git a/src/cli/plugin/plugin_extractor.js b/src/cli/plugin/plugin_extractor.js deleted file mode 100644 index 0dc67f3e9948..000000000000 --- a/src/cli/plugin/plugin_extractor.js +++ /dev/null @@ -1,16 +0,0 @@ -import zipExtract from './extractors/zip'; -import tarGzExtract from './extractors/tar_gz'; -import { ZIP, TAR } from './file_type'; - -export default function extractArchive(settings, logger, archiveType) { - switch (archiveType) { - case ZIP: - return zipExtract(settings, logger); - break; - case TAR: - return tarGzExtract(settings, logger); - break; - default: - throw new Error('Unsupported archive format.'); - } -}; diff --git a/src/cli/plugin/plugin_installer.js b/src/cli/plugin/plugin_installer.js deleted file mode 100644 index f95dd6c37f73..000000000000 --- a/src/cli/plugin/plugin_installer.js +++ /dev/null @@ -1,86 +0,0 @@ -import _ from 'lodash'; -import fromRoot from '../../utils/from_root'; -import pluginDownloader from './plugin_downloader'; -import pluginCleaner from './plugin_cleaner'; -import pluginExtractor from './plugin_extractor'; -import KbnServer from '../../server/kbn_server'; -import readYamlConfig from '../serve/read_yaml_config'; -import Promise from 'bluebird'; -import { sync as rimrafSync } from 'rimraf'; -import { statSync, renameSync } from 'fs'; -const mkdirp = Promise.promisify(require('mkdirp')); - -export default { - install: install -}; - -function checkForExistingInstall(settings, logger) { - try { - statSync(settings.pluginPath); - - logger.error(`Plugin ${settings.package} already exists, please remove before installing a new version`); - process.exit(70); // eslint-disable-line no-process-exit - } catch (e) { - if (e.code !== 'ENOENT') throw e; - } -} - -async function rebuildKibanaCache(settings, logger) { - logger.log('Optimizing and caching browser bundles...'); - const serverConfig = _.merge( - readYamlConfig(settings.config), - { - env: 'production', - logging: { - silent: settings.silent, - quiet: !settings.silent, - verbose: false - }, - optimize: { - useBundleCache: false - }, - server: { - autoListen: false - }, - plugins: { - initialize: false, - scanDirs: [settings.pluginDir, fromRoot('src/plugins')] - } - } - ); - - const kbnServer = new KbnServer(serverConfig); - await kbnServer.ready(); - await kbnServer.close(); -} - -async function install(settings, logger) { - logger.log(`Installing ${settings.package}`); - - const cleaner = pluginCleaner(settings, logger); - - try { - checkForExistingInstall(settings, logger); - - await cleaner.cleanPrevious(); - - await mkdirp(settings.workingPath); - - const downloader = pluginDownloader(settings, logger); - const { archiveType } = await downloader.download(); - - await pluginExtractor (settings, logger, archiveType); - - rimrafSync(settings.tempArchiveFile); - - renameSync(settings.workingPath, settings.pluginPath); - - await rebuildKibanaCache(settings, logger); - - logger.log('Plugin installation complete'); - } catch (err) { - logger.error(`Plugin installation was unsuccessful due to error "${err.message}"`); - cleaner.cleanError(); - process.exit(70); // eslint-disable-line no-process-exit - } -} diff --git a/src/cli/plugin/plugin_lister.js b/src/cli/plugin/plugin_lister.js deleted file mode 100644 index c12154b60a73..000000000000 --- a/src/cli/plugin/plugin_lister.js +++ /dev/null @@ -1,8 +0,0 @@ -import fs from 'fs'; - -export function list(settings, logger) { - fs.readdirSync(settings.pluginDir) - .forEach(function (pluginFile) { - logger.log(pluginFile); - }); -} diff --git a/src/cli/plugin/plugin_logger.js b/src/cli/plugin/plugin_logger.js deleted file mode 100644 index 489bec040df1..000000000000 --- a/src/cli/plugin/plugin_logger.js +++ /dev/null @@ -1,44 +0,0 @@ -export default function createPluginLogger(settings) { - let previousLineEnded = true; - const silent = !!settings.silent; - const quiet = !!settings.quiet; - - function log(data, sameLine) { - if (silent || quiet) return; - - if (!sameLine && !previousLineEnded) { - process.stdout.write('\n'); - } - - //if data is a stream, pipe it. - if (data.readable) { - data.pipe(process.stdout); - return; - } - - process.stdout.write(data); - if (!sameLine) process.stdout.write('\n'); - previousLineEnded = !sameLine; - } - - function error(data) { - if (silent) return; - - if (!previousLineEnded) { - process.stderr.write('\n'); - } - - //if data is a stream, pipe it. - if (data.readable) { - data.pipe(process.stderr); - return; - } - process.stderr.write(`${data}\n`); - previousLineEnded = true; - } - - return { - log: log, - error: error - }; -}; diff --git a/src/cli/plugin/plugin_remover.js b/src/cli/plugin/plugin_remover.js deleted file mode 100644 index ff4c22e0feeb..000000000000 --- a/src/cli/plugin/plugin_remover.js +++ /dev/null @@ -1,23 +0,0 @@ -import fs from 'fs'; -import rimraf from 'rimraf'; - -module.exports = { - remove: remove -}; - -function remove(settings, logger) { - try { - try { - fs.statSync(settings.pluginPath); - } catch (e) { - logger.log(`Plugin ${settings.package} does not exist`); - return; - } - - logger.log(`Removing ${settings.package}...`); - rimraf.sync(settings.pluginPath); - } catch (err) { - logger.error(`Unable to remove plugin "${settings.package}" because of error: "${err.message}"`); - process.exit(74); // eslint-disable-line no-process-exit - } -} diff --git a/src/cli/plugin/progress_reporter.js b/src/cli/plugin/progress_reporter.js deleted file mode 100644 index 6de7d987adf5..000000000000 --- a/src/cli/plugin/progress_reporter.js +++ /dev/null @@ -1,38 +0,0 @@ -/* -Generates file transfer progress messages -*/ -export default function createProgressReporter(logger) { - let dotCount = 0; - let runningTotal = 0; - let totalSize = 0; - - function init(size) { - totalSize = size; - let totalDesc = totalSize || 'unknown number of'; - - logger.log(`Transferring ${totalDesc} bytes`, true); - } - - //Should log a dot for every 5% of progress - function progress(size) { - if (!totalSize) return; - - runningTotal += size; - let newDotCount = Math.round(runningTotal / totalSize * 100 / 5); - if (newDotCount > 20) newDotCount = 20; - for (let i = 0; i < (newDotCount - dotCount); i++) { - logger.log('.', true); - } - dotCount = newDotCount; - } - - function complete() { - logger.log(`Transfer complete`, false); - } - - return { - init: init, - progress: progress, - complete: complete - }; -}; diff --git a/src/cli/plugin/setting_parser.js b/src/cli/plugin/setting_parser.js deleted file mode 100644 index 0e581b22b11a..000000000000 --- a/src/cli/plugin/setting_parser.js +++ /dev/null @@ -1,114 +0,0 @@ -import expiry from 'expiry-js'; -import { intersection } from 'lodash'; -import { resolve } from 'path'; - -export default function createSettingParser(options) { - function parseMilliseconds(val) { - let result; - - try { - let timeVal = expiry(val); - result = timeVal.asMilliseconds(); - } catch (ex) { - result = 0; - } - - return result; - } - - function generateDownloadUrl(settings) { - const version = (settings.version) || 'latest'; - const filename = settings.package + '-' + version + '.tar.gz'; - - return 'https://download.elastic.co/' + settings.organization + '/' + settings.package + '/' + filename; - } - - function areMultipleOptionsChosen(options, choices) { - return intersection(Object.keys(options), choices).length > 1; - } - - function parse() { - let parts; - let settings = { - timeout: 0, - silent: false, - quiet: false, - urls: [] - }; - - if (options.timeout) { - settings.timeout = options.timeout; - } - - if (options.parent && options.parent.quiet) { - settings.quiet = options.parent.quiet; - } - - if (options.silent) { - settings.silent = options.silent; - } - - if (options.url) { - settings.urls.push(options.url); - } - - if (options.config) { - settings.config = options.config; - } - - if (options.install) { - settings.action = 'install'; - parts = options.install.split('/'); - - if (options.url) { - if (parts.length !== 1) { - throw new Error('Invalid install option. When providing a url, please use the format .'); - } - - settings.package = parts.shift(); - } else { - if (parts.length < 2 || parts.length > 3) { - throw new Error('Invalid install option. Please use the format //.'); - } - - settings.organization = parts.shift(); - settings.package = parts.shift(); - settings.version = parts.shift(); - - settings.urls.push(generateDownloadUrl(settings)); - } - } - - if (options.remove) { - settings.action = 'remove'; - parts = options.remove.split('/'); - - if (parts.length !== 1) { - throw new Error('Invalid remove option. Please use the format .'); - } - settings.package = parts.shift(); - } - - if (options.list) { - settings.action = 'list'; - } - - if (!settings.action || areMultipleOptionsChosen(options, [ 'install', 'remove', 'list' ])) { - throw new Error('Please specify either --install, --remove, or --list.'); - } - - settings.pluginDir = options.pluginDir; - if (settings.package) { - settings.pluginPath = resolve(settings.pluginDir, settings.package); - settings.workingPath = resolve(settings.pluginDir, '.plugin.installing'); - settings.tempArchiveFile = resolve(settings.workingPath, 'archive.part'); - } - - return settings; - } - - return { - parse: parse, - parseMilliseconds: parseMilliseconds - }; -}; diff --git a/src/cli_plugin/cli.js b/src/cli_plugin/cli.js new file mode 100644 index 000000000000..5f322a48bb28 --- /dev/null +++ b/src/cli_plugin/cli.js @@ -0,0 +1,43 @@ +import _ from 'lodash'; +import pkg from '../utils/package_json'; +import Command from '../cli/command'; +import listCommand from './list'; +import installCommand from './install'; +import removeCommand from './remove'; + +let argv = process.env.kbnWorkerArgv ? JSON.parse(process.env.kbnWorkerArgv) : process.argv.slice(); +let program = new Command('bin/kibana-plugin'); + +program +.version(pkg.version) +.description( + 'The Kibana plugin manager enables you to install and remove plugins that ' + + 'provide additional functionality to Kibana' +); + +listCommand(program); +installCommand(program); +removeCommand(program); + +program +.command('help ') +.description('get the help for a specific command') +.action(function (cmdName) { + var cmd = _.find(program.commands, { _name: cmdName }); + if (!cmd) return program.error(`unknown command ${cmdName}`); + cmd.help(); +}); + +program +.command('*', null, { noHelp: true }) +.action(function (cmd, options) { + program.error(`unknown command ${cmd}`); +}); + +// check for no command name +var subCommand = argv[2] && !String(argv[2][0]).match(/^-|^\.|\//); +if (!subCommand) { + program.defaultHelp(); +} + +program.parse(argv); diff --git a/src/cli_plugin/index.js b/src/cli_plugin/index.js new file mode 100644 index 000000000000..a0cd17d62f15 --- /dev/null +++ b/src/cli_plugin/index.js @@ -0,0 +1,5 @@ +// load the babel options seperately so that they can modify the process.env +// before calling babel/register +const babelOptions = require('../optimize/babel_options').node; +require('babel/register')(babelOptions); +require('./cli'); diff --git a/src/cli/plugin/__tests__/plugin_cleaner.js b/src/cli_plugin/install/__tests__/cleanup.js similarity index 83% rename from src/cli/plugin/__tests__/plugin_cleaner.js rename to src/cli_plugin/install/__tests__/cleanup.js index e97ae39f1709..c8812c8dedf8 100644 --- a/src/cli/plugin/__tests__/plugin_cleaner.js +++ b/src/cli_plugin/install/__tests__/cleanup.js @@ -3,8 +3,8 @@ import sinon from 'sinon'; import fs from 'fs'; import rimraf from 'rimraf'; -import pluginCleaner from '../plugin_cleaner'; -import pluginLogger from '../plugin_logger'; +import { cleanPrevious, cleanArtifacts } from '../cleanup'; +import Logger from '../../lib/logger'; describe('kibana cli', function () { @@ -24,8 +24,7 @@ describe('kibana cli', function () { beforeEach(function () { errorStub = sinon.stub(); - logger = pluginLogger(false); - cleaner = pluginCleaner(settings, logger); + logger = new Logger(settings); sinon.stub(logger, 'log'); sinon.stub(logger, 'error'); request = { @@ -49,7 +48,7 @@ describe('kibana cli', function () { throw error; }); - return cleaner.cleanPrevious(logger) + return cleanPrevious(settings, logger) .catch(errorStub) .then(function (data) { expect(errorStub.called).to.be(false); @@ -64,7 +63,7 @@ describe('kibana cli', function () { }); errorStub = sinon.stub(); - return cleaner.cleanPrevious(logger) + return cleanPrevious(settings, logger) .catch(errorStub) .then(function () { expect(errorStub.called).to.be(true); @@ -75,7 +74,7 @@ describe('kibana cli', function () { sinon.stub(rimraf, 'sync'); sinon.stub(fs, 'statSync'); - return cleaner.cleanPrevious(logger) + return cleanPrevious(settings, logger) .catch(errorStub) .then(function (data) { expect(logger.log.calledWith('Found previous install attempt. Deleting...')).to.be(true); @@ -89,7 +88,7 @@ describe('kibana cli', function () { }); errorStub = sinon.stub(); - return cleaner.cleanPrevious(logger) + return cleanPrevious(settings, logger) .catch(errorStub) .then(function () { expect(errorStub.called).to.be(true); @@ -100,7 +99,7 @@ describe('kibana cli', function () { sinon.stub(rimraf, 'sync'); sinon.stub(fs, 'statSync'); - return cleaner.cleanPrevious(logger) + return cleanPrevious(settings, logger) .catch(errorStub) .then(function (data) { expect(errorStub.called).to.be(false); @@ -109,13 +108,11 @@ describe('kibana cli', function () { }); - describe('cleanError', function () { - let cleaner; + describe('cleanArtifacts', function () { let logger; beforeEach(function () { - logger = pluginLogger(false); - cleaner = pluginCleaner(settings, logger); + logger = new Logger(settings); }); afterEach(function () { @@ -125,7 +122,7 @@ describe('kibana cli', function () { it('should attempt to delete the working directory', function () { sinon.stub(rimraf, 'sync'); - cleaner.cleanError(); + cleanArtifacts(settings); expect(rimraf.sync.calledWith(settings.workingPath)).to.be(true); }); @@ -134,7 +131,7 @@ describe('kibana cli', function () { throw new Error('Something bad happened.'); }); - expect(cleaner.cleanError).withArgs(settings).to.not.throwError(); + expect(cleanArtifacts).withArgs(settings).to.not.throwError(); }); }); diff --git a/src/cli_plugin/install/__tests__/download.js b/src/cli_plugin/install/__tests__/download.js new file mode 100644 index 000000000000..5885d9c057f1 --- /dev/null +++ b/src/cli_plugin/install/__tests__/download.js @@ -0,0 +1,226 @@ +import expect from 'expect.js'; +import sinon from 'sinon'; +import nock from 'nock'; +import glob from 'glob-all'; +import rimraf from 'rimraf'; +import mkdirp from 'mkdirp'; +import Logger from '../../lib/logger'; +import { download, _downloadSingle } from '../download'; +import { join } from 'path'; + +describe('kibana cli', function () { + + describe('plugin downloader', function () { + const testWorkingPath = join(__dirname, '.test.data'); + const tempArchiveFilePath = join(testWorkingPath, 'archive.part'); + + const settings = { + urls: [], + workingPath: testWorkingPath, + tempArchiveFile: tempArchiveFilePath, + timeout: 0 + }; + const logger = new Logger(settings); + + function expectWorkingPathEmpty() { + const files = glob.sync('**/*', { cwd: testWorkingPath }); + expect(files).to.eql([]); + } + + function expectWorkingPathNotEmpty() { + const files = glob.sync('**/*', { cwd: testWorkingPath }); + const expected = [ + 'archive.part' + ]; + + expect(files.sort()).to.eql(expected.sort()); + } + + function shouldReject() { + throw new Error('expected the promise to reject'); + } + + beforeEach(function () { + sinon.stub(logger, 'log'); + sinon.stub(logger, 'error'); + rimraf.sync(testWorkingPath); + mkdirp.sync(testWorkingPath); + }); + + afterEach(function () { + logger.log.restore(); + logger.error.restore(); + rimraf.sync(testWorkingPath); + }); + + describe('_downloadSingle', function () { + + beforeEach(function () { + }); + + describe('http downloader', function () { + + it('should throw an ENOTFOUND error for a http ulr that returns 404', function () { + const couchdb = nock('http://example.com') + .get('/plugin.tar.gz') + .reply(404); + + const sourceUrl = 'http://example.com/plugin.tar.gz'; + + return _downloadSingle(settings, logger, sourceUrl) + .then(shouldReject, function (err) { + expect(err.message).to.match(/ENOTFOUND/); + expectWorkingPathEmpty(); + }); + }); + + it('should throw an ENOTFOUND error for an invalid url', function () { + const sourceUrl = 'i am an invalid url'; + + return _downloadSingle(settings, logger, sourceUrl) + .then(shouldReject, function (err) { + expect(err.message).to.match(/ENOTFOUND/); + expectWorkingPathEmpty(); + }); + }); + + it('should download a file from a valid http url', function () { + const filePath = join(__dirname, 'replies/banana.jpg'); + + const couchdb = nock('http://example.com') + .defaultReplyHeaders({ + 'content-length': '341965', + 'content-type': 'application/zip' + }) + .get('/plugin.zip') + .replyWithFile(200, filePath); + + const sourceUrl = 'http://example.com/plugin.zip'; + + return _downloadSingle(settings, logger, sourceUrl) + .then(function () { + expectWorkingPathNotEmpty(); + }); + }); + + }); + + describe('local file downloader', function () { + + it('should throw an ENOTFOUND error for an invalid local file', function () { + const filePath = join(__dirname, 'replies/i-am-not-there.zip'); + const sourceUrl = 'file://' + filePath.replace(/\\/g, '/'); + + return _downloadSingle(settings, logger, sourceUrl) + .then(shouldReject, function (err) { + expect(err.message).to.match(/ENOTFOUND/); + expectWorkingPathEmpty(); + }); + }); + + it('should copy a valid local file', function () { + const filePath = join(__dirname, 'replies/banana.jpg'); + const sourceUrl = 'file://' + filePath.replace(/\\/g, '/'); + + return _downloadSingle(settings, logger, sourceUrl) + .then(function () { + expectWorkingPathNotEmpty(); + }); + }); + + }); + + }); + + describe('download', function () { + it('should loop through bad urls until it finds a good one.', function () { + const filePath = join(__dirname, 'replies/test_plugin.zip'); + settings.urls = [ + 'http://example.com/badfile1.tar.gz', + 'http://example.com/badfile2.tar.gz', + 'I am a bad uri', + 'http://example.com/goodfile.tar.gz' + ]; + + const couchdb = nock('http://example.com') + .defaultReplyHeaders({ + 'content-length': '10' + }) + .get('/badfile1.tar.gz') + .reply(404) + .get('/badfile2.tar.gz') + .reply(404) + .get('/goodfile.tar.gz') + .replyWithFile(200, filePath); + + return download(settings, logger) + .then(function () { + expect(logger.log.getCall(0).args[0]).to.match(/badfile1.tar.gz/); + expect(logger.log.getCall(1).args[0]).to.match(/badfile2.tar.gz/); + expect(logger.log.getCall(2).args[0]).to.match(/I am a bad uri/); + expect(logger.log.getCall(3).args[0]).to.match(/goodfile.tar.gz/); + expectWorkingPathNotEmpty(); + }); + }); + + it('should stop looping through urls when it finds a good one.', function () { + const filePath = join(__dirname, 'replies/test_plugin.zip'); + settings.urls = [ + 'http://example.com/badfile1.tar.gz', + 'http://example.com/badfile2.tar.gz', + 'http://example.com/goodfile.tar.gz', + 'http://example.com/badfile3.tar.gz' + ]; + + const couchdb = nock('http://example.com') + .defaultReplyHeaders({ + 'content-length': '10' + }) + .get('/badfile1.tar.gz') + .reply(404) + .get('/badfile2.tar.gz') + .reply(404) + .get('/goodfile.tar.gz') + .replyWithFile(200, filePath) + .get('/badfile3.tar.gz') + .reply(404); + + return download(settings, logger) + .then(function () { + for (let i = 0; i < logger.log.callCount; i++) { + expect(logger.log.getCall(i).args[0]).to.not.match(/badfile3.tar.gz/); + } + expectWorkingPathNotEmpty(); + }); + }); + + it('should throw an error when it doesn\'t find a good url.', function () { + settings.urls = [ + 'http://example.com/badfile1.tar.gz', + 'http://example.com/badfile2.tar.gz', + 'http://example.com/badfile3.tar.gz' + ]; + + const couchdb = nock('http://example.com') + .defaultReplyHeaders({ + 'content-length': '10' + }) + .get('/badfile1.tar.gz') + .reply(404) + .get('/badfile2.tar.gz') + .reply(404) + .get('/badfile3.tar.gz') + .reply(404); + + return download(settings, logger) + .then(shouldReject, function (err) { + expect(err.message).to.match(/no valid url specified/i); + expectWorkingPathEmpty(); + }); + }); + + }); + + }); + +}); diff --git a/src/cli/plugin/__tests__/plugin.js b/src/cli_plugin/install/__tests__/index.js similarity index 81% rename from src/cli/plugin/__tests__/plugin.js rename to src/cli_plugin/install/__tests__/index.js index 0da19de19d0c..470d9452a1c1 100644 --- a/src/cli/plugin/__tests__/plugin.js +++ b/src/cli_plugin/install/__tests__/index.js @@ -1,6 +1,6 @@ import expect from 'expect.js'; import sinon from 'sinon'; -import plugin from '../plugin'; +import index from '../index'; describe('kibana cli', function () { @@ -18,8 +18,8 @@ describe('kibana cli', function () { it('should define the command', function () { sinon.spy(program, 'command'); - plugin(program); - expect(program.command.calledWith('plugin')).to.be(true); + index(program); + expect(program.command.calledWith('install ')).to.be(true); program.command.restore(); }); @@ -27,8 +27,8 @@ describe('kibana cli', function () { it('should define the description', function () { sinon.spy(program, 'description'); - plugin(program); - expect(program.description.calledWith('Maintain Plugins')).to.be(true); + index(program); + expect(program.description.calledWith('install a plugin')).to.be(true); program.description.restore(); }); @@ -37,14 +37,14 @@ describe('kibana cli', function () { const spy = sinon.spy(program, 'option'); const options = [ - /-i/, - /-r/, + /-q/, /-s/, - /-u/, - /-t/ + /-c/, + /-t/, + /-d/ ]; - plugin(program); + index(program); for (let i = 0; i < spy.callCount; i++) { const call = spy.getCall(i); @@ -63,7 +63,7 @@ describe('kibana cli', function () { it('should call the action function', function () { sinon.spy(program, 'action'); - plugin(program); + index(program); expect(program.action.calledOnce).to.be(true); program.action.restore(); diff --git a/src/cli_plugin/install/__tests__/kibana.js b/src/cli_plugin/install/__tests__/kibana.js new file mode 100644 index 000000000000..2ae0a1e2589a --- /dev/null +++ b/src/cli_plugin/install/__tests__/kibana.js @@ -0,0 +1,170 @@ +import expect from 'expect.js'; +import sinon from 'sinon'; +import Logger from '../../lib/logger'; +import { join } from 'path'; +import rimraf from 'rimraf'; +import mkdirp from 'mkdirp'; +import { existingInstall, assertVersion } from '../kibana'; + +describe('kibana cli', function () { + + describe('plugin installer', function () { + + describe('kibana', function () { + const testWorkingPath = join(__dirname, '.test.data'); + const tempArchiveFilePath = join(testWorkingPath, 'archive.part'); + + const settings = { + workingPath: testWorkingPath, + tempArchiveFile: tempArchiveFilePath, + plugin: 'test-plugin', + version: '1.0.0', + plugins: [ { name: 'foo', path: join(testWorkingPath, 'foo') } ] + }; + + const logger = new Logger(settings); + + describe('assertVersion', function () { + + beforeEach(function () { + rimraf.sync(testWorkingPath); + mkdirp.sync(testWorkingPath); + sinon.stub(logger, 'log'); + sinon.stub(logger, 'error'); + }); + + afterEach(function () { + logger.log.restore(); + logger.error.restore(); + rimraf.sync(testWorkingPath); + }); + + it('should succeed with exact match', function () { + const settings = { + workingPath: testWorkingPath, + tempArchiveFile: tempArchiveFilePath, + plugin: 'test-plugin', + version: '5.0.0-snapshot', + plugins: [ { name: 'foo', path: join(testWorkingPath, 'foo'), version: '5.0.0-snapshot' } ] + }; + const errorStub = sinon.stub(); + + try { + assertVersion(settings); + } + catch (err) { + errorStub(err); + } + + expect(errorStub.called).to.be(false); + }); + + it('should throw an error if plugin does contain a version.', function () { + const errorStub = sinon.stub(); + + try { + assertVersion(settings); + } + catch (err) { + errorStub(err); + } + + expect(errorStub.firstCall.args[0]).to.match(/plugin version not found/i); + }); + + it('should throw an error if plugin version does does not match kibana version', function () { + const errorStub = sinon.stub(); + settings.plugins[0].version = '1.2.3.4'; + + try { + assertVersion(settings); + } + catch (err) { + errorStub(err); + } + + expect(errorStub.firstCall.args[0]).to.match(/incorrect version/i); + }); + + it('should not throw an error if plugin version matches kibana version', function () { + const errorStub = sinon.stub(); + settings.plugins[0].version = '1.0.0'; + + try { + assertVersion(settings); + } + catch (err) { + errorStub(err); + } + + expect(errorStub.called).to.be(false); + }); + + it('should ignore version info after the dash in checks on valid version', function () { + const errorStub = sinon.stub(); + settings.plugins[0].version = '1.0.0-foo-bar-version-1.2.3'; + + try { + assertVersion(settings); + } + catch (err) { + errorStub(err); + } + + expect(errorStub.called).to.be(false); + }); + + it('should ignore version info after the dash in checks on invalid version', function () { + const errorStub = sinon.stub(); + settings.plugins[0].version = '2.0.0-foo-bar-version-1.2.3'; + + try { + assertVersion(settings); + } + catch (err) { + errorStub(err); + } + + expect(errorStub.firstCall.args[0]).to.match(/incorrect version/i); + }); + }); + + describe('existingInstall', function () { + let testWorkingPath; + let processExitStub; + + beforeEach(function () { + processExitStub = sinon.stub(process, 'exit'); + testWorkingPath = join(__dirname, '.test.data'); + rimraf.sync(testWorkingPath); + sinon.stub(logger, 'log'); + sinon.stub(logger, 'error'); + }); + + afterEach(function () { + processExitStub.restore(); + logger.log.restore(); + logger.error.restore(); + rimraf.sync(testWorkingPath); + }); + + it('should throw an error if the workingPath already exists.', function () { + mkdirp.sync(settings.plugins[0].path); + existingInstall(settings, logger); + + expect(logger.error.firstCall.args[0]).to.match(/already exists/); + expect(process.exit.called).to.be(true); + }); + + it('should not throw an error if the workingPath does not exist.', function () { + existingInstall(settings, logger); + expect(logger.error.called).to.be(false); + }); + + }); + + }); + + }); + +}); diff --git a/src/cli_plugin/install/__tests__/pack.js b/src/cli_plugin/install/__tests__/pack.js new file mode 100644 index 000000000000..433df5bc0a0d --- /dev/null +++ b/src/cli_plugin/install/__tests__/pack.js @@ -0,0 +1,174 @@ +import expect from 'expect.js'; +import sinon from 'sinon'; +import glob from 'glob-all'; +import rimraf from 'rimraf'; +import mkdirp from 'mkdirp'; +import Logger from '../../lib/logger'; +import { extract, getPackData } from '../pack'; +import { _downloadSingle } from '../download'; +import { join } from 'path'; + +describe('kibana cli', function () { + + describe('pack', function () { + + const testWorkingPath = join(__dirname, '.test.data'); + const tempArchiveFilePath = join(testWorkingPath, 'archive.part'); + const testPluginPath = join(testWorkingPath, '.installedPlugins'); + let logger; + + const settings = { + workingPath: testWorkingPath, + tempArchiveFile: tempArchiveFilePath, + pluginDir: testPluginPath, + plugin: 'test-plugin' + }; + + beforeEach(function () { + logger = new Logger(settings); + sinon.stub(logger, 'log'); + sinon.stub(logger, 'error'); + rimraf.sync(testWorkingPath); + mkdirp.sync(testWorkingPath); + }); + + afterEach(function () { + logger.log.restore(); + logger.error.restore(); + rimraf.sync(testWorkingPath); + }); + + function copyReplyFile(filename) { + const filePath = join(__dirname, 'replies', filename); + const sourceUrl = 'file://' + filePath.replace(/\\/g, '/'); + + return _downloadSingle(settings, logger, sourceUrl); + } + + function shouldReject() { + throw new Error('expected the promise to reject'); + } + + describe('extract', function () { + + //Also only extracts the content from the kibana folder. + //Ignores the others. + it('successfully extract a valid zip', function () { + return copyReplyFile('test_plugin.zip') + .then(() => { + return getPackData(settings, logger); + }) + .then(() => { + return extract(settings, logger); + }) + .then(() => { + const files = glob.sync('**/*', { cwd: testWorkingPath }); + const expected = [ + 'archive.part', + 'README.md', + 'index.js', + 'package.json', + 'public', + 'public/app.js', + 'extra file only in zip.txt' + ]; + expect(files.sort()).to.eql(expected.sort()); + }); + }); + + }); + + describe('getPackData', function () { + + it('populate settings.plugins', function () { + return copyReplyFile('test_plugin.zip') + .then(() => { + return getPackData(settings, logger); + }) + .then(() => { + expect(settings.plugins[0].name).to.be('test-plugin'); + expect(settings.plugins[0].folder).to.be('test-plugin'); + expect(settings.plugins[0].version).to.be('1.0.0'); + expect(settings.plugins[0].platform).to.be(undefined); + }); + }); + + it('populate settings.plugins with multiple plugins', function () { + return copyReplyFile('test_plugin_many.zip') + .then(() => { + return getPackData(settings, logger); + }) + .then(() => { + expect(settings.plugins[0].name).to.be('funger-plugin'); + expect(settings.plugins[0].file).to.be('kibana/funger-plugin/package.json'); + expect(settings.plugins[0].folder).to.be('funger-plugin'); + expect(settings.plugins[0].version).to.be('1.0.0'); + expect(settings.plugins[0].platform).to.be(undefined); + + expect(settings.plugins[1].name).to.be('pdf'); + expect(settings.plugins[1].file).to.be('kibana/pdf-linux/package.json'); + expect(settings.plugins[1].folder).to.be('pdf-linux'); + expect(settings.plugins[1].version).to.be('1.0.0'); + expect(settings.plugins[1].platform).to.be('linux'); + + expect(settings.plugins[2].name).to.be('pdf'); + expect(settings.plugins[2].file).to.be('kibana/pdf-win32/package.json'); + expect(settings.plugins[2].folder).to.be('pdf-win32'); + expect(settings.plugins[2].version).to.be('1.0.0'); + expect(settings.plugins[2].platform).to.be('win32'); + + expect(settings.plugins[3].name).to.be('pdf'); + expect(settings.plugins[3].file).to.be('kibana/pdf-win64/package.json'); + expect(settings.plugins[3].folder).to.be('pdf-win64'); + expect(settings.plugins[3].version).to.be('1.0.0'); + expect(settings.plugins[3].platform).to.be('win64'); + + expect(settings.plugins[4].name).to.be('pdf'); + expect(settings.plugins[4].file).to.be('kibana/pdf/package.json'); + expect(settings.plugins[4].folder).to.be('pdf'); + expect(settings.plugins[4].version).to.be('1.0.0'); + expect(settings.plugins[4].platform).to.be(undefined); + + expect(settings.plugins[5].name).to.be('test-plugin'); + expect(settings.plugins[5].file).to.be('kibana/test-plugin/package.json'); + expect(settings.plugins[5].folder).to.be('test-plugin'); + expect(settings.plugins[5].version).to.be('1.0.0'); + expect(settings.plugins[5].platform).to.be(undefined); + }); + }); + + it('throw an error if there is no kibana plugin', function () { + return copyReplyFile('test_plugin_no_kibana.zip') + .then((data) => { + return getPackData(settings, logger); + }) + .then(shouldReject, (err) => { + expect(err.message).to.match(/No kibana plugins found in archive/i); + }); + }); + + it('throw an error with a corrupt zip', function () { + return copyReplyFile('corrupt.zip') + .then((data) => { + return getPackData(settings, logger); + }) + .then(shouldReject, (err) => { + expect(err.message).to.match(/error retrieving/i); + }); + }); + + it('throw an error if there an invalid plugin name', function () { + return copyReplyFile('invalid_name.zip') + .then((data) => { + return getPackData(settings, logger); + }) + .then(shouldReject, (err) => { + expect(err.message).to.match(/invalid plugin name/i); + }); + }); + + }); + + }); + +}); diff --git a/src/cli/plugin/__tests__/progress_reporter.js b/src/cli_plugin/install/__tests__/progress.js similarity index 93% rename from src/cli/plugin/__tests__/progress_reporter.js rename to src/cli_plugin/install/__tests__/progress.js index 5f81f509c183..15f4fd9a1bc1 100644 --- a/src/cli/plugin/__tests__/progress_reporter.js +++ b/src/cli_plugin/install/__tests__/progress.js @@ -1,23 +1,22 @@ import expect from 'expect.js'; import sinon from 'sinon'; -import progressReporter from '../progress_reporter'; -import pluginLogger from '../plugin_logger'; +import Progress from '../progress'; +import Logger from '../../lib/logger'; describe('kibana cli', function () { describe('plugin installer', function () { describe('progressReporter', function () { - let logger; let progress; let request; beforeEach(function () { - logger = pluginLogger({ silent: false, quiet: false }); + logger = new Logger({ silent: false, quiet: false }); sinon.stub(logger, 'log'); sinon.stub(logger, 'error'); - progress = progressReporter(logger); + progress = new Progress(logger); }); afterEach(function () { diff --git a/src/cli/plugin/__tests__/replies/banana.jpg b/src/cli_plugin/install/__tests__/replies/banana.jpg similarity index 100% rename from src/cli/plugin/__tests__/replies/banana.jpg rename to src/cli_plugin/install/__tests__/replies/banana.jpg diff --git a/src/cli/plugin/__tests__/replies/corrupt.zip b/src/cli_plugin/install/__tests__/replies/corrupt.zip similarity index 100% rename from src/cli/plugin/__tests__/replies/corrupt.zip rename to src/cli_plugin/install/__tests__/replies/corrupt.zip diff --git a/src/cli_plugin/install/__tests__/replies/invalid_name.zip b/src/cli_plugin/install/__tests__/replies/invalid_name.zip new file mode 100644 index 000000000000..5de9a0677b6c Binary files /dev/null and b/src/cli_plugin/install/__tests__/replies/invalid_name.zip differ diff --git a/src/cli_plugin/install/__tests__/replies/package.no_version.json b/src/cli_plugin/install/__tests__/replies/package.no_version.json new file mode 100644 index 000000000000..874c3e5a0460 --- /dev/null +++ b/src/cli_plugin/install/__tests__/replies/package.no_version.json @@ -0,0 +1,3 @@ +{ + "name": "test-plugin", +} diff --git a/src/cli_plugin/install/__tests__/replies/test_plugin.zip b/src/cli_plugin/install/__tests__/replies/test_plugin.zip new file mode 100644 index 000000000000..1ec957f80128 Binary files /dev/null and b/src/cli_plugin/install/__tests__/replies/test_plugin.zip differ diff --git a/src/cli_plugin/install/__tests__/replies/test_plugin_many.zip b/src/cli_plugin/install/__tests__/replies/test_plugin_many.zip new file mode 100644 index 000000000000..e4fc8d73feef Binary files /dev/null and b/src/cli_plugin/install/__tests__/replies/test_plugin_many.zip differ diff --git a/src/cli_plugin/install/__tests__/replies/test_plugin_no_kibana.zip b/src/cli_plugin/install/__tests__/replies/test_plugin_no_kibana.zip new file mode 100644 index 000000000000..d460c65978c6 Binary files /dev/null and b/src/cli_plugin/install/__tests__/replies/test_plugin_no_kibana.zip differ diff --git a/src/cli_plugin/install/__tests__/settings.js b/src/cli_plugin/install/__tests__/settings.js new file mode 100644 index 000000000000..8f2baa9f295a --- /dev/null +++ b/src/cli_plugin/install/__tests__/settings.js @@ -0,0 +1,228 @@ +import path from 'path'; +import expect from 'expect.js'; +import fromRoot from '../../../utils/from_root'; +import { resolve } from 'path'; +import { parseMilliseconds, parse, getPlatform } from '../settings'; + +describe('kibana cli', function () { + + describe('plugin installer', function () { + + describe('command line option parsing', function () { + + describe('parseMilliseconds function', function () { + + it('should return 0 for an empty string', function () { + const value = ''; + const result = parseMilliseconds(value); + + expect(result).to.be(0); + }); + + it('should return 0 for a number with an invalid unit of measure', function () { + const result = parseMilliseconds('1gigablasts'); + expect(result).to.be(0); + }); + + it('should assume a number with no unit of measure is specified as milliseconds', function () { + const result = parseMilliseconds(1); + expect(result).to.be(1); + + const result2 = parseMilliseconds('1'); + expect(result2).to.be(1); + }); + + it('should interpret a number with "s" as the unit of measure as seconds', function () { + const result = parseMilliseconds('5s'); + expect(result).to.be(5 * 1000); + }); + + it('should interpret a number with "second" as the unit of measure as seconds', function () { + const result = parseMilliseconds('5second'); + expect(result).to.be(5 * 1000); + }); + + it('should interpret a number with "seconds" as the unit of measure as seconds', function () { + const result = parseMilliseconds('5seconds'); + expect(result).to.be(5 * 1000); + }); + + it('should interpret a number with "m" as the unit of measure as minutes', function () { + const result = parseMilliseconds('9m'); + expect(result).to.be(9 * 1000 * 60); + }); + + it('should interpret a number with "minute" as the unit of measure as minutes', function () { + const result = parseMilliseconds('9minute'); + expect(result).to.be(9 * 1000 * 60); + }); + + it('should interpret a number with "minutes" as the unit of measure as minutes', function () { + const result = parseMilliseconds('9minutes'); + expect(result).to.be(9 * 1000 * 60); + }); + + }); + + describe('parse function', function () { + + const command = 'plugin name'; + let options = {}; + const kbnPackage = { version: 1234 }; + beforeEach(function () { + options = { pluginDir: fromRoot('installedPlugins') }; + }); + + describe('timeout option', function () { + + it('should default to 0 (milliseconds)', function () { + const settings = parse(command, options, kbnPackage); + + expect(settings.timeout).to.be(0); + }); + + it('should set settings.timeout property', function () { + options.timeout = 1234; + const settings = parse(command, options, kbnPackage); + + expect(settings.timeout).to.be(1234); + }); + + }); + + describe('quiet option', function () { + + it('should default to false', function () { + const settings = parse(command, options, kbnPackage); + + expect(settings.quiet).to.be(false); + }); + + it('should set settings.quiet property to true', function () { + options.quiet = true; + const settings = parse(command, options, kbnPackage); + + expect(settings.quiet).to.be(true); + }); + + }); + + describe('silent option', function () { + + it('should default to false', function () { + const settings = parse(command, options, kbnPackage); + + expect(settings.silent).to.be(false); + }); + + it('should set settings.silent property to true', function () { + options.silent = true; + const settings = parse(command, options, kbnPackage); + + expect(settings.silent).to.be(true); + }); + + }); + + describe('config option', function () { + + it('should default to ZLS', function () { + const settings = parse(command, options, kbnPackage); + + expect(settings.config).to.be(''); + }); + + it('should set settings.config property', function () { + options.config = 'foo bar baz'; + const settings = parse(command, options, kbnPackage); + + expect(settings.config).to.be('foo bar baz'); + }); + + }); + + describe('pluginDir option', function () { + + it('should default to installedPlugins', function () { + const settings = parse(command, options, kbnPackage); + + expect(settings.pluginDir).to.be(fromRoot('installedPlugins')); + }); + + it('should set settings.config property', function () { + options.pluginDir = 'foo bar baz'; + const settings = parse(command, options, kbnPackage); + + expect(settings.pluginDir).to.be('foo bar baz'); + }); + + }); + + describe('command value', function () { + + it('should set settings.plugin property', function () { + const settings = parse(command, options, kbnPackage); + + expect(settings.plugin).to.be(command); + }); + + }); + + describe('urls collection', function () { + + it('should populate the settings.urls property', function () { + const settings = parse(command, options, kbnPackage); + + const expected = [ + command, + `https://download.elastic.co/packs/${command}/${command}-1234.zip` + ]; + + expect(settings.urls).to.eql(expected); + }); + + }); + + describe('workingPath value', function () { + + it('should set settings.workingPath property', function () { + options.pluginDir = 'foo/bar/baz'; + const settings = parse(command, options, kbnPackage); + const expected = resolve('foo/bar/baz', '.plugin.installing'); + + expect(settings.workingPath).to.be(expected); + }); + + }); + + describe('tempArchiveFile value', function () { + + it('should set settings.tempArchiveFile property', function () { + options.pluginDir = 'foo/bar/baz'; + const settings = parse(command, options, kbnPackage); + const expected = resolve('foo/bar/baz', '.plugin.installing', 'archive.part'); + + expect(settings.tempArchiveFile).to.be(expected); + }); + + }); + + describe('tempPackageFile value', function () { + + it('should set settings.tempPackageFile property', function () { + options.pluginDir = 'foo/bar/baz'; + const settings = parse(command, options, kbnPackage); + const expected = resolve('foo/bar/baz', '.plugin.installing', 'package.json'); + + expect(settings.tempPackageFile).to.be(expected); + }); + + }); + + }); + + }); + + }); + +}); diff --git a/src/cli_plugin/install/__tests__/zip.js b/src/cli_plugin/install/__tests__/zip.js new file mode 100644 index 000000000000..7cc96fd318c9 --- /dev/null +++ b/src/cli_plugin/install/__tests__/zip.js @@ -0,0 +1,145 @@ +import expect from 'expect.js'; +import sinon from 'sinon'; +import glob from 'glob-all'; +import rimraf from 'rimraf'; +import mkdirp from 'mkdirp'; +import Logger from '../../lib/logger'; +import { _downloadSingle } from '../download'; +import { join } from 'path'; +import { listFiles, extractFiles } from '../zip'; + +describe('kibana cli', function () { + + describe('zip', function () { + + const testWorkingPath = join(__dirname, '.test.data'); + const tempArchiveFilePath = join(testWorkingPath, 'archive.part'); + let logger; + + const settings = { + workingPath: testWorkingPath, + tempArchiveFile: tempArchiveFilePath, + plugin: 'test-plugin', + setPlugin: function (plugin) {} + }; + + function shouldReject() { + throw new Error('expected the promise to reject'); + } + + beforeEach(function () { + logger = new Logger(settings); + sinon.stub(logger, 'log'); + sinon.stub(logger, 'error'); + sinon.stub(settings, 'setPlugin'); + rimraf.sync(testWorkingPath); + mkdirp.sync(testWorkingPath); + }); + + afterEach(function () { + logger.log.restore(); + logger.error.restore(); + settings.setPlugin.restore(); + rimraf.sync(testWorkingPath); + }); + + function copyReplyFile(filename) { + const filePath = join(__dirname, 'replies', filename); + const sourceUrl = 'file://' + filePath.replace(/\\/g, '/'); + + return _downloadSingle(settings, logger, sourceUrl); + } + + describe('listFiles', function () { + + it('lists the files in the zip', function () { + return copyReplyFile('test_plugin.zip') + .then(() => { + return listFiles(settings.tempArchiveFile); + }) + .then((actual) => { + const expected = [ + 'elasticsearch/', + 'kibana/', + 'kibana/test-plugin/', + 'kibana/test-plugin/.gitignore', + 'kibana/test-plugin/extra file only in zip.txt', + 'kibana/test-plugin/index.js', + 'kibana/test-plugin/package.json', + 'kibana/test-plugin/public/', + 'kibana/test-plugin/public/app.js', + 'kibana/test-plugin/README.md', + 'logstash/' + ]; + + expect(actual).to.eql(expected); + }); + }); + + }); + + describe('extractFiles', function () { + + it('extracts files using the files filter', function () { + return copyReplyFile('test_plugin_many.zip') + .then(() => { + const filter = { + files: [ + 'kibana/funger-plugin/extra file only in zip.txt', + 'kibana/funger-plugin/index.js', + 'kibana\\funger-plugin\\package.json' + ] + }; + + return extractFiles(settings.tempArchiveFile, settings.workingPath, 0, filter); + }) + .then(() => { + const files = glob.sync('**/*', { cwd: testWorkingPath }); + const expected = [ + 'kibana', + 'kibana/funger-plugin', + 'kibana/funger-plugin/extra file only in zip.txt', + 'kibana/funger-plugin/index.js', + 'kibana/funger-plugin/package.json', + 'archive.part' + ]; + expect(files.sort()).to.eql(expected.sort()); + }); + }); + + it('extracts files using the paths filter', function () { + return copyReplyFile('test_plugin_many.zip') + .then(() => { + const filter = { + paths: [ + 'kibana/funger-plugin', + 'kibana/test-plugin/public' + ] + }; + + return extractFiles(settings.tempArchiveFile, settings.workingPath, 0, filter); + }) + .then(() => { + const files = glob.sync('**/*', { cwd: testWorkingPath }); + const expected = [ + 'archive.part', + 'kibana', + 'kibana/funger-plugin', + 'kibana/funger-plugin/README.md', + 'kibana/funger-plugin/extra file only in zip.txt', + 'kibana/funger-plugin/index.js', + 'kibana/funger-plugin/package.json', + 'kibana/funger-plugin/public', + 'kibana/funger-plugin/public/app.js', + 'kibana/test-plugin', + 'kibana/test-plugin/public', + 'kibana/test-plugin/public/app.js' + ]; + expect(files.sort()).to.eql(expected.sort()); + }); + }); + }); + + }); + +}); diff --git a/src/cli_plugin/install/cleanup.js b/src/cli_plugin/install/cleanup.js new file mode 100644 index 000000000000..5e8f6fec58b3 --- /dev/null +++ b/src/cli_plugin/install/cleanup.js @@ -0,0 +1,32 @@ +import rimraf from 'rimraf'; +import fs from 'fs'; + +export function cleanPrevious(settings, logger) { + return new Promise(function (resolve, reject) { + try { + fs.statSync(settings.workingPath); + + logger.log('Found previous install attempt. Deleting...'); + try { + rimraf.sync(settings.workingPath); + } catch (e) { + reject(e); + } + resolve(); + } catch (e) { + if (e.code !== 'ENOENT') reject(e); + + resolve(); + } + }); +}; + +export function cleanArtifacts(settings) { + // delete the working directory. + // At this point we're bailing, so swallow any errors on delete. + try { + rimraf.sync(settings.workingPath); + rimraf.sync(settings.plugins[0].path); + } + catch (e) {} // eslint-disable-line no-empty +}; diff --git a/src/cli_plugin/install/download.js b/src/cli_plugin/install/download.js new file mode 100644 index 000000000000..0c87ec3f7d4c --- /dev/null +++ b/src/cli_plugin/install/download.js @@ -0,0 +1,40 @@ +import downloadHttpFile from './downloaders/http'; +import downloadLocalFile from './downloaders/file'; +import { parse } from 'url'; + +export function _downloadSingle(settings, logger, sourceUrl) { + const urlInfo = parse(sourceUrl); + let downloadPromise; + + if (/^file/.test(urlInfo.protocol)) { + downloadPromise = downloadLocalFile(logger, decodeURI(urlInfo.path), settings.tempArchiveFile); + } else { + downloadPromise = downloadHttpFile(logger, sourceUrl, settings.tempArchiveFile, settings.timeout); + } + + return downloadPromise; +} + +//Attempts to download each url in turn until one is successful +export function download(settings, logger) { + const urls = settings.urls.slice(0); + + function tryNext() { + const sourceUrl = urls.shift(); + if (!sourceUrl) { + throw new Error('No valid url specified.'); + } + + logger.log(`Attempting to transfer from ${sourceUrl}`); + + return _downloadSingle(settings, logger, sourceUrl) + .catch((err) => { + if (err.message === 'ENOTFOUND') { + return tryNext(); + } + throw (err); + }); + } + + return tryNext(); +}; diff --git a/src/cli/plugin/downloaders/file.js b/src/cli_plugin/install/downloaders/file.js similarity index 69% rename from src/cli/plugin/downloaders/file.js rename to src/cli_plugin/install/downloaders/file.js index 7ccaf290df56..505a103755e6 100644 --- a/src/cli/plugin/downloaders/file.js +++ b/src/cli_plugin/install/downloaders/file.js @@ -1,6 +1,5 @@ -import getProgressReporter from '../progress_reporter'; +import Progress from '../progress'; import { createWriteStream, createReadStream, unlinkSync, statSync } from 'fs'; -import fileType from '../file_type'; function openSourceFile({ sourcePath }) { try { @@ -18,7 +17,7 @@ function openSourceFile({ sourcePath }) { } } -async function copyFile({ readStream, writeStream, progressReporter }) { +async function copyFile({ readStream, writeStream, progress }) { await new Promise((resolve, reject) => { // if either stream errors, fail quickly readStream.on('error', reject); @@ -26,7 +25,7 @@ async function copyFile({ readStream, writeStream, progressReporter }) { // report progress as we transfer readStream.on('data', (chunk) => { - progressReporter.progress(chunk.length); + progress.progress(chunk.length); }); // write the download to the file system @@ -46,21 +45,17 @@ export default async function copyLocalFile(logger, sourcePath, targetPath) { const writeStream = createWriteStream(targetPath); try { - const progressReporter = getProgressReporter(logger); - progressReporter.init(fileInfo.size); + const progress = new Progress(logger); + progress.init(fileInfo.size); - await copyFile({ readStream, writeStream, progressReporter }); + await copyFile({ readStream, writeStream, progress }); - progressReporter.complete(); + progress.complete(); } catch (err) { readStream.close(); writeStream.close(); throw err; } - - // all is well, return our archive type - const archiveType = fileType(sourcePath); - return { archiveType }; } catch (err) { logger.error(err); throw err; diff --git a/src/cli/plugin/downloaders/http.js b/src/cli_plugin/install/downloaders/http.js similarity index 61% rename from src/cli/plugin/downloaders/http.js rename to src/cli_plugin/install/downloaders/http.js index 436f07b2e117..40069c4cd063 100644 --- a/src/cli/plugin/downloaders/http.js +++ b/src/cli_plugin/install/downloaders/http.js @@ -1,8 +1,7 @@ import Wreck from 'wreck'; -import getProgressReporter from '../progress_reporter'; +import Progress from '../progress'; import { fromNode as fn } from 'bluebird'; import { createWriteStream, unlinkSync } from 'fs'; -import fileType, { ZIP, TAR } from '../file_type'; function sendRequest({ sourceUrl, timeout }) { const maxRedirects = 11; //Because this one goes to 11. @@ -25,7 +24,7 @@ function sendRequest({ sourceUrl, timeout }) { }); } -function downloadResponse({ resp, targetPath, progressReporter }) { +function downloadResponse({ resp, targetPath, progress }) { return new Promise((resolve, reject) => { const writeStream = createWriteStream(targetPath); @@ -35,7 +34,7 @@ function downloadResponse({ resp, targetPath, progressReporter }) { // report progress as we download resp.on('data', (chunk) => { - progressReporter.progress(chunk.length); + progress.progress(chunk.length); }); // write the download to the file system @@ -46,19 +45,6 @@ function downloadResponse({ resp, targetPath, progressReporter }) { }); } -function getArchiveTypeFromResponse(resp, sourceUrl) { - const contentType = (resp.headers['content-type'] || ''); - - switch (contentType.toLowerCase()) { - case 'application/zip': return ZIP; - case 'application/x-gzip': return TAR; - default: - //If we can't infer the archive type from the content-type header, - //fall back to checking the extension in the url - return fileType(sourceUrl); - } -} - /* Responsible for managing http transfers */ @@ -68,20 +54,16 @@ export default async function downloadUrl(logger, sourceUrl, targetPath, timeout try { let totalSize = parseFloat(resp.headers['content-length']) || 0; - const progressReporter = getProgressReporter(logger); - progressReporter.init(totalSize); + const progress = new Progress(logger); + progress.init(totalSize); - await downloadResponse({ resp, targetPath, progressReporter }); + await downloadResponse({ resp, targetPath, progress }); - progressReporter.complete(); + progress.complete(); } catch (err) { req.abort(); throw err; } - - // all is well, return our archive type - const archiveType = getArchiveTypeFromResponse(resp, sourceUrl); - return { archiveType }; } catch (err) { if (err.message !== 'ENOTFOUND') { logger.error(err); diff --git a/src/cli_plugin/install/index.js b/src/cli_plugin/install/index.js new file mode 100644 index 000000000000..ceb587fc3502 --- /dev/null +++ b/src/cli_plugin/install/index.js @@ -0,0 +1,47 @@ +import fromRoot from '../../utils/from_root'; +import install from './install'; +import Logger from '../lib/logger'; +import pkg from '../../utils/package_json'; +import { parse, parseMilliseconds } from './settings'; + +function processCommand(command, options) { + let settings; + try { + settings = parse(command, options, pkg); + } catch (ex) { + //The logger has not yet been initialized. + console.error(ex.message); + process.exit(64); // eslint-disable-line no-process-exit + } + + const logger = new Logger(settings); + install(settings, logger); +} + +export default function pluginInstall(program) { + program + .command('install ') + .option('-q, --quiet', 'disable all process messaging except errors') + .option('-s, --silent', 'disable all process messaging') + .option( + '-c, --config ', + 'path to the config file', + fromRoot('config/kibana.yml') + ) + .option( + '-t, --timeout ', + 'length of time before failing; 0 for never fail', + parseMilliseconds + ) + .option( + '-d, --plugin-dir ', + 'path to the directory where plugins are stored', + fromRoot('installedPlugins') + ) + .description('install a plugin', +`Common examples: + install xpack + install file:///Path/to/my/xpack.zip + install https://path.to/my/xpack.zip`) + .action(processCommand); +}; diff --git a/src/cli_plugin/install/install.js b/src/cli_plugin/install/install.js new file mode 100644 index 000000000000..1c72827cad71 --- /dev/null +++ b/src/cli_plugin/install/install.js @@ -0,0 +1,40 @@ +import { download } from './download'; +import Promise from 'bluebird'; +import { cleanPrevious, cleanArtifacts } from './cleanup'; +import { extract, getPackData } from './pack'; +import { sync as rimrafSync } from 'rimraf'; +import { renameSync } from 'fs'; +import { existingInstall, rebuildCache, assertVersion } from './kibana'; +import mkdirp from 'mkdirp'; + +const mkdir = Promise.promisify(mkdirp); + +export default async function install(settings, logger) { + try { + await cleanPrevious(settings, logger); + + await mkdir(settings.workingPath); + + await download(settings, logger); + + await getPackData(settings, logger); + + await extract(settings, logger); + + rimrafSync(settings.tempArchiveFile); + + existingInstall(settings, logger); + + assertVersion(settings); + + renameSync(settings.workingPath, settings.plugins[0].path); + + await rebuildCache(settings, logger); + + logger.log('Plugin installation complete'); + } catch (err) { + logger.error(`Plugin installation was unsuccessful due to error "${err.message}"`); + cleanArtifacts(settings); + process.exit(70); // eslint-disable-line no-process-exit + } +} diff --git a/src/cli_plugin/install/kibana.js b/src/cli_plugin/install/kibana.js new file mode 100644 index 000000000000..03097b11b1d4 --- /dev/null +++ b/src/cli_plugin/install/kibana.js @@ -0,0 +1,59 @@ +import _ from 'lodash'; +import fromRoot from '../../utils/from_root'; +import KbnServer from '../../server/kbn_server'; +import readYamlConfig from '../../cli/serve/read_yaml_config'; +import { versionSatisfies, cleanVersion } from './version'; +import { statSync } from 'fs'; + +export function existingInstall(settings, logger) { + try { + statSync(settings.plugins[0].path); + + logger.error(`Plugin ${settings.plugins[0].name} already exists, please remove before installing a new version`); + process.exit(70); // eslint-disable-line no-process-exit + } catch (e) { + if (e.code !== 'ENOENT') throw e; + } +} + +export async function rebuildCache(settings, logger) { + logger.log('Optimizing and caching browser bundles...'); + const serverConfig = _.merge( + readYamlConfig(settings.config), + { + env: 'production', + logging: { + silent: settings.silent, + quiet: !settings.silent, + verbose: false + }, + optimize: { + useBundleCache: false + }, + server: { + autoListen: false + }, + plugins: { + initialize: false, + scanDirs: [settings.pluginDir, fromRoot('src/plugins')] + } + } + ); + + const kbnServer = new KbnServer(serverConfig); + await kbnServer.ready(); + await kbnServer.close(); +} + +export function assertVersion(settings) { + if (!settings.plugins[0].version) { + throw new Error (`Plugin version not found. Check package.json in archive`); + } + + const actual = cleanVersion(settings.plugins[0].version); + const expected = cleanVersion(settings.version); + if (!versionSatisfies(actual, expected)) { + throw new Error (`Incorrect version in plugin [${settings.plugins[0].name}]. ` + + `Expected [${expected}]; found [${actual}]`); + } +} diff --git a/src/cli_plugin/install/pack.js b/src/cli_plugin/install/pack.js new file mode 100644 index 000000000000..62da90044a3a --- /dev/null +++ b/src/cli_plugin/install/pack.js @@ -0,0 +1,136 @@ +import _ from 'lodash'; +import { listFiles, extractFiles } from './zip'; +import { resolve } from 'path'; +import { sync as rimrafSync } from 'rimraf'; +import validate from 'validate-npm-package-name'; + +/** + * Returns an array of package objects. There will be one for each of + * package.json files in the archive + * @param {object} settings - a plugin installer settings object + */ +async function listPackages(settings) { + const regExp = new RegExp('(kibana/([^/]+))/package.json', 'i'); + const archiveFiles = await listFiles(settings.tempArchiveFile); + + return _(archiveFiles) + .map(file => file.replace(/\\/g, '/')) + .map(file => file.match(regExp)) + .compact() + .map(([ file, _, folder ]) => ({ file, folder })) + .uniq() + .value(); +} + +/** + * Extracts the package.json files into the workingPath + * @param {object} settings - a plugin installer settings object + * @param {array} packages - array of package objects from listPackages() + */ +async function extractPackageFiles(settings, packages) { + const filter = { + files: packages.map((pkg) => pkg.file) + }; + await extractFiles(settings.tempArchiveFile, settings.workingPath, 0, filter); +} + +/** + * Deletes the package.json files created by extractPackageFiles() + * @param {object} settings - a plugin installer settings object + */ +function deletePackageFiles(settings) { + const fullPath = resolve(settings.workingPath, 'kibana'); + rimrafSync(fullPath); +} + +/** + * Checks the plugin name. Will throw an exception if it does not meet + * npm package naming conventions + * @param {object} plugin - a package object from listPackages() + */ +function assertValidPackageName(plugin) { + const validation = validate(plugin.name); + if (!validation.validForNewPackages) { + throw new Error(`Invalid plugin name [${plugin.name}] in package.json`); + } +} + + +/** + * Examine each package.json file to determine the plugin name, + * version, and platform. Mutates the package objects in the packages array + * @param {object} settings - a plugin installer settings object + * @param {array} packages - array of package objects from listPackages() + */ +async function mergePackageData(settings, packages) { + return packages.map((pkg) => { + const fullPath = resolve(settings.workingPath, pkg.file); + const packageInfo = require(fullPath); + + pkg.version = _.get(packageInfo, 'version'); + pkg.name = _.get(packageInfo, 'name'); + pkg.path = resolve(settings.pluginDir, pkg.name); + + const regExp = new RegExp(`${pkg.name}-(.+)`, 'i'); + const matches = pkg.folder.match(regExp); + pkg.platform = (matches) ? matches[1] : undefined; + + return pkg; + }); +} + +/** + * Extracts the first plugin in the archive. + * NOTE: This will need to be changed in later versions of the pack installer + * that allow for the installation of more than one plugin at once. + * @param {object} settings - a plugin installer settings object + */ +async function extractArchive(settings) { + const filter = { + paths: [ settings.plugins[0].folder ] + }; + await extractFiles(settings.tempArchiveFile, settings.workingPath, 2, filter); +} + + +/** + * Returns the detailed information about each kibana plugin in the pack. + * TODO: If there are platform specific folders, determine which one to use. + * @param {object} settings - a plugin installer settings object + * @param {object} logger - a plugin installer logger object + */ +export async function getPackData(settings, logger) { + let packages; + try { + logger.log('Retrieving metadata from plugin archive'); + + packages = await listPackages(settings); + + await extractPackageFiles(settings, packages); + await mergePackageData(settings, packages); + await deletePackageFiles(settings); + } catch (err) { + logger.error(err); + throw new Error('Error retrieving metadata from plugin archive'); + } + + if (packages.length === 0) { + throw new Error('No kibana plugins found in archive'); + } + packages.forEach(assertValidPackageName); + + settings.plugins = packages; +} + +export async function extract(settings, logger) { + try { + logger.log('Extracting plugin archive'); + + await extractArchive(settings); + + logger.log('Extraction complete'); + } catch (err) { + logger.error(err); + throw new Error('Error extracting plugin archive'); + } +}; diff --git a/src/cli_plugin/install/progress.js b/src/cli_plugin/install/progress.js new file mode 100644 index 000000000000..dca7f9a2b88f --- /dev/null +++ b/src/cli_plugin/install/progress.js @@ -0,0 +1,38 @@ +/** + * Generates file transfer progress messages + */ +export default class Progress { + + constructor(logger) { + const self = this; + + self.dotCount = 0; + self.runningTotal = 0; + self.totalSize = 0; + self.logger = logger; + } + + init(size) { + this.totalSize = size; + const totalDesc = this.totalSize || 'unknown number of'; + + this.logger.log(`Transferring ${totalDesc} bytes`, true); + } + + progress(size) { + if (!this.totalSize) return; + + this.runningTotal += size; + let newDotCount = Math.round(this.runningTotal / this.totalSize * 100 / 5); + if (newDotCount > 20) newDotCount = 20; + for (let i = 0; i < (newDotCount - this.dotCount); i++) { + this.logger.log('.', true); + } + this.dotCount = newDotCount; + } + + complete() { + this.logger.log(`Transfer complete`, false); + } + +} diff --git a/src/cli_plugin/install/settings.js b/src/cli_plugin/install/settings.js new file mode 100644 index 000000000000..f9f87c1b6772 --- /dev/null +++ b/src/cli_plugin/install/settings.js @@ -0,0 +1,47 @@ +import expiry from 'expiry-js'; +import { intersection } from 'lodash'; +import { resolve } from 'path'; +import { arch, platform } from 'os'; + +function generateUrls({ version, plugin }) { + return [ + plugin, + `https://download.elastic.co/packs/${plugin}/${plugin}-${version}.zip` + ]; +} + +export function parseMilliseconds(val) { + let result; + + try { + const timeVal = expiry(val); + result = timeVal.asMilliseconds(); + } catch (ex) { + result = 0; + } + + return result; +}; + +export function parse(command, options, kbnPackage) { + const settings = { + timeout: options.timeout || 0, + quiet: options.quiet || false, + silent: options.silent || false, + config: options.config || '', + plugin: command, + version: kbnPackage.version, + pluginDir: options.pluginDir || '' + }; + + settings.urls = generateUrls(settings); + settings.workingPath = resolve(settings.pluginDir, '.plugin.installing'); + settings.tempArchiveFile = resolve(settings.workingPath, 'archive.part'); + settings.tempPackageFile = resolve(settings.workingPath, 'package.json'); + settings.setPlugin = function (plugin) { + settings.plugin = plugin; + settings.pluginPath = resolve(settings.pluginDir, settings.plugin.name); + }; + + return settings; +}; diff --git a/src/cli_plugin/install/version.js b/src/cli_plugin/install/version.js new file mode 100644 index 000000000000..a2a37d0dd23b --- /dev/null +++ b/src/cli_plugin/install/version.js @@ -0,0 +1,15 @@ +import semver from 'semver'; + +export function versionSatisfies(cleanActual, cleanExpected) { + try { + return (cleanActual === cleanExpected); + } catch (err) { + return false; + } +} + +export function cleanVersion(version) { + const match = version.match(/\d+\.\d+\.\d+/); + if (!match) return version; + return match[0]; +} diff --git a/src/cli_plugin/install/zip.js b/src/cli_plugin/install/zip.js new file mode 100644 index 000000000000..38dd82f013c4 --- /dev/null +++ b/src/cli_plugin/install/zip.js @@ -0,0 +1,94 @@ +import _ from 'lodash'; +import DecompressZip from '@bigfunger/decompress-zip'; + +const SYMBOLIC_LINK = 'SymbolicLink'; + +/** + * Creates a filter function to be consumed by extractFiles that filters by + * an array of files + * @param {array} files - an array of full file paths to extract. Should match + * exactly a value from listFiles + */ +function extractFilterFromFiles(files) { + const filterFiles = files.map((file) => file.replace(/\\/g, '/')); + return function filterByFiles(file) { + if (file.type === SYMBOLIC_LINK) return false; + + const path = file.path.replace(/\\/g, '/'); + return _.includes(filterFiles, path); + }; +} + +/** + * Creates a filter function to be consumed by extractFiles that filters by + * an array of root paths + * @param {array} paths - an array of root paths from the archive. All files and + * folders will be extracted recursively using these paths as roots. + */ +function extractFilterFromPaths(paths) { + return function filterByRootPath(file) { + if (file.type === SYMBOLIC_LINK) return false; + + return paths.some(path => { + const regex = new RegExp(`${path}($|/)`, 'i'); + return file.parent.match(regex); + }); + }; +} + +/** + * Creates a filter function to be consumed by extractFiles + * @param {object} filter - an object with either a files or paths property. + */ +function extractFilter(filter) { + if (filter.files) return extractFilterFromFiles(filter.files); + if (filter.paths) return extractFilterFromPaths(filter.paths); + return _.noop; +} + +/** + * Extracts files from a zip archive to a file path using a filter function + * @param {string} zipPath - file path to a zip archive + * @param {string} targetPath - directory path to where the files should + * extracted + * @param {integer} strip - Number of nested directories within the archive + * that should be ignored when determining the target path of an archived + * file. + * @param {function} filter - A function that accepts a single parameter 'file' + * and returns true if the file should be extracted from the archive + */ +export async function extractFiles(zipPath, targetPath, strip, filter) { + await new Promise((resolve, reject) => { + const unzipper = new DecompressZip(zipPath); + + unzipper.on('error', reject); + + unzipper.extract({ + path: targetPath, + strip: strip, + filter: extractFilter(filter) + }); + + unzipper.on('extract', resolve); + }); +} + +/** + * Returns all files within an archive + * @param {string} zipPath - file path to a zip archive + * @returns {array} all files within an archive with their relative paths + */ +export async function listFiles(zipPath) { + return await new Promise((resolve, reject) => { + const unzipper = new DecompressZip(zipPath); + + unzipper.on('error', reject); + + unzipper.on('list', (files) => { + files = files.map((file) => file.replace(/\\/g, '/')); + resolve(files); + }); + + unzipper.list(); + }); +} diff --git a/src/cli/plugin/__tests__/plugin_logger.js b/src/cli_plugin/lib/__tests__/logger.js similarity index 88% rename from src/cli/plugin/__tests__/plugin_logger.js rename to src/cli_plugin/lib/__tests__/logger.js index 13ce90c9e76f..26bcca9ea488 100644 --- a/src/cli/plugin/__tests__/plugin_logger.js +++ b/src/cli_plugin/lib/__tests__/logger.js @@ -1,6 +1,6 @@ import expect from 'expect.js'; import sinon from 'sinon'; -import pluginLogger from '../plugin_logger'; +import Logger from '../logger'; describe('kibana cli', function () { @@ -20,7 +20,7 @@ describe('kibana cli', function () { }); it('should log messages to the console and append a new line', function () { - logger = pluginLogger({ silent: false, quiet: false }); + logger = new Logger({ silent: false, quiet: false }); const message = 'this is my message'; logger.log(message); @@ -31,7 +31,7 @@ describe('kibana cli', function () { }); it('should log messages to the console and append not append a new line', function () { - logger = pluginLogger({ silent: false, quiet: false }); + logger = new Logger({ silent: false, quiet: false }); for (let i = 0; i < 10; i++) { logger.log('.', true); } @@ -54,7 +54,7 @@ describe('kibana cli', function () { }); it('should not log any messages when quiet is set', function () { - logger = pluginLogger({ silent: false, quiet: true }); + logger = new Logger({ silent: false, quiet: true }); const message = 'this is my message'; logger.log(message); @@ -68,7 +68,7 @@ describe('kibana cli', function () { }); it('should not log any messages when silent is set', function () { - logger = pluginLogger({ silent: true, quiet: false }); + logger = new Logger({ silent: true, quiet: false }); const message = 'this is my message'; logger.log(message); @@ -94,7 +94,7 @@ describe('kibana cli', function () { }); it('should log error messages to the console and append a new line', function () { - logger = pluginLogger({ silent: false, quiet: false }); + logger = new Logger({ silent: false, quiet: false }); const message = 'this is my error'; logger.error(message); @@ -102,7 +102,7 @@ describe('kibana cli', function () { }); it('should log error messages to the console when quiet is set', function () { - logger = pluginLogger({ silent: false, quiet: true }); + logger = new Logger({ silent: false, quiet: true }); const message = 'this is my error'; logger.error(message); @@ -110,7 +110,7 @@ describe('kibana cli', function () { }); it('should not log any error messages when silent is set', function () { - logger = pluginLogger({ silent: true, quiet: false }); + logger = new Logger({ silent: true, quiet: false }); const message = 'this is my error'; logger.error(message); diff --git a/src/cli_plugin/lib/logger.js b/src/cli_plugin/lib/logger.js new file mode 100644 index 000000000000..16bc15f33e02 --- /dev/null +++ b/src/cli_plugin/lib/logger.js @@ -0,0 +1,46 @@ +/** + * Logs messages and errors + */ +export default class Logger { + + constructor(settings) { + this.previousLineEnded = true; + this.silent = !!settings.silent; + this.quiet = !!settings.quiet; + } + + log(data, sameLine) { + if (this.silent || this.quiet) return; + + if (!sameLine && !this.previousLineEnded) { + process.stdout.write('\n'); + } + + //if data is a stream, pipe it. + if (data.readable) { + data.pipe(process.stdout); + return; + } + + process.stdout.write(data); + if (!sameLine) process.stdout.write('\n'); + this.previousLineEnded = !sameLine; + } + + error(data) { + if (this.silent) return; + + if (!this.previousLineEnded) { + process.stderr.write('\n'); + } + + //if data is a stream, pipe it. + if (data.readable) { + data.pipe(process.stderr); + return; + } + process.stderr.write(`${data}\n`); + this.previousLineEnded = true; + }; + +} diff --git a/src/cli_plugin/list/__tests__/list.js b/src/cli_plugin/list/__tests__/list.js new file mode 100644 index 000000000000..ba5036f404a9 --- /dev/null +++ b/src/cli_plugin/list/__tests__/list.js @@ -0,0 +1,75 @@ +import expect from 'expect.js'; +import sinon from 'sinon'; +import rimraf from 'rimraf'; +import mkdirp from 'mkdirp'; +import Logger from '../../lib/logger'; +import list from '../list'; +import { join } from 'path'; +import { writeFileSync } from 'fs'; + +describe('kibana cli', function () { + + describe('plugin lister', function () { + + const pluginDir = join(__dirname, '.test.data'); + let logger; + + const settings = { + pluginDir: pluginDir + }; + + beforeEach(function () { + logger = new Logger(settings); + sinon.stub(logger, 'log'); + sinon.stub(logger, 'error'); + rimraf.sync(pluginDir); + mkdirp.sync(pluginDir); + }); + + afterEach(function () { + logger.log.restore(); + logger.error.restore(); + rimraf.sync(pluginDir); + }); + + it('list all of the folders in the plugin folder', function () { + mkdirp.sync(join(pluginDir, 'plugin1')); + mkdirp.sync(join(pluginDir, 'plugin2')); + mkdirp.sync(join(pluginDir, 'plugin3')); + + list(settings, logger); + + expect(logger.log.calledWith('plugin1')).to.be(true); + expect(logger.log.calledWith('plugin2')).to.be(true); + expect(logger.log.calledWith('plugin3')).to.be(true); + }); + + it('ignore folders that start with a period', function () { + mkdirp.sync(join(pluginDir, '.foo')); + mkdirp.sync(join(pluginDir, 'plugin1')); + mkdirp.sync(join(pluginDir, 'plugin2')); + mkdirp.sync(join(pluginDir, 'plugin3')); + mkdirp.sync(join(pluginDir, '.bar')); + + list(settings, logger); + + expect(logger.log.calledWith('.foo')).to.be(false); + expect(logger.log.calledWith('.bar')).to.be(false); + }); + + it('list should only list folders', function () { + mkdirp.sync(join(pluginDir, 'plugin1')); + mkdirp.sync(join(pluginDir, 'plugin2')); + mkdirp.sync(join(pluginDir, 'plugin3')); + writeFileSync(join(pluginDir, 'plugin4'), 'This is a file, and not a folder.'); + + list(settings, logger); + + expect(logger.log.calledWith('plugin1')).to.be(true); + expect(logger.log.calledWith('plugin2')).to.be(true); + expect(logger.log.calledWith('plugin3')).to.be(true); + }); + + }); + +}); diff --git a/src/cli_plugin/list/__tests__/settings.js b/src/cli_plugin/list/__tests__/settings.js new file mode 100644 index 000000000000..8e08e5b17ad5 --- /dev/null +++ b/src/cli_plugin/list/__tests__/settings.js @@ -0,0 +1,44 @@ +import path from 'path'; +import expect from 'expect.js'; +import fromRoot from '../../../utils/from_root'; +import { resolve } from 'path'; +import { parseMilliseconds, parse } from '../settings'; + +describe('kibana cli', function () { + + describe('plugin installer', function () { + + describe('command line option parsing', function () { + + describe('parse function', function () { + + let command; + const options = {}; + beforeEach(function () { + command = { pluginDir: fromRoot('installedPlugins') }; + }); + + describe('pluginDir option', function () { + + it('should default to installedPlugins', function () { + const settings = parse(command, options); + + expect(settings.pluginDir).to.be(fromRoot('installedPlugins')); + }); + + it('should set settings.config property', function () { + command.pluginDir = 'foo bar baz'; + const settings = parse(command, options); + + expect(settings.pluginDir).to.be('foo bar baz'); + }); + + }); + + }); + + }); + + }); + +}); diff --git a/src/cli_plugin/list/index.js b/src/cli_plugin/list/index.js new file mode 100644 index 000000000000..348fa99261d5 --- /dev/null +++ b/src/cli_plugin/list/index.js @@ -0,0 +1,30 @@ +import fromRoot from '../../utils/from_root'; +import list from './list'; +import Logger from '../lib/logger'; +import { parse } from './settings'; + +function processCommand(command, options) { + let settings; + try { + settings = parse(command, options); + } catch (ex) { + //The logger has not yet been initialized. + console.error(ex.message); + process.exit(64); // eslint-disable-line no-process-exit + } + + const logger = new Logger(settings); + list(settings, logger); +} + +export default function pluginList(program) { + program + .command('list') + .option( + '-d, --plugin-dir ', + 'path to the directory where plugins are stored', + fromRoot('installedPlugins') + ) + .description('list installed plugins') + .action(processCommand); +}; diff --git a/src/cli_plugin/list/list.js b/src/cli_plugin/list/list.js new file mode 100644 index 000000000000..9d1427a25a45 --- /dev/null +++ b/src/cli_plugin/list/list.js @@ -0,0 +1,14 @@ +import { statSync, readdirSync } from 'fs'; +import { join } from 'path'; + +export default function list(settings, logger) { + readdirSync(settings.pluginDir) + .forEach((filename) => { + const stat = statSync(join(settings.pluginDir, filename)); + + if (stat.isDirectory() && filename[0] !== '.') { + logger.log(filename); + } + }); + logger.log(''); //intentional blank line for aesthetics +} diff --git a/src/cli_plugin/list/settings.js b/src/cli_plugin/list/settings.js new file mode 100644 index 000000000000..f372bfc0d35e --- /dev/null +++ b/src/cli_plugin/list/settings.js @@ -0,0 +1,9 @@ +import { resolve } from 'path'; + +export function parse(command, options) { + const settings = { + pluginDir: command.pluginDir || '' + }; + + return settings; +}; diff --git a/src/cli_plugin/remove/__tests__/remove.js b/src/cli_plugin/remove/__tests__/remove.js new file mode 100644 index 000000000000..567f67c33c9a --- /dev/null +++ b/src/cli_plugin/remove/__tests__/remove.js @@ -0,0 +1,68 @@ +import expect from 'expect.js'; +import sinon from 'sinon'; +import glob from 'glob-all'; +import rimraf from 'rimraf'; +import mkdirp from 'mkdirp'; +import Logger from '../../lib/logger'; +import remove from '../remove'; +import { join } from 'path'; +import { writeFileSync } from 'fs'; + +describe('kibana cli', function () { + + describe('plugin remover', function () { + + const pluginDir = join(__dirname, '.test.data'); + let processExitStub; + let logger; + + const settings = { pluginDir }; + + beforeEach(function () { + processExitStub = sinon.stub(process, 'exit'); + logger = new Logger(settings); + sinon.stub(logger, 'log'); + sinon.stub(logger, 'error'); + rimraf.sync(pluginDir); + mkdirp.sync(pluginDir); + }); + + afterEach(function () { + processExitStub.restore(); + logger.log.restore(); + logger.error.restore(); + rimraf.sync(pluginDir); + }); + + it('throw an error if the plugin is not installed.', function () { + settings.pluginPath = join(pluginDir, 'foo'); + settings.plugin = 'foo'; + + remove(settings, logger); + expect(logger.error.firstCall.args[0]).to.match(/not installed/); + expect(process.exit.called).to.be(true); + }); + + it('throw an error if the specified plugin is not a folder.', function () { + writeFileSync(join(pluginDir, 'foo'), 'This is a file, and not a folder.'); + + remove(settings, logger); + expect(logger.error.firstCall.args[0]).to.match(/not a plugin/); + expect(process.exit.called).to.be(true); + }); + + it('delete the specified folder.', function () { + settings.pluginPath = join(pluginDir, 'foo'); + mkdirp.sync(join(pluginDir, 'foo')); + mkdirp.sync(join(pluginDir, 'bar')); + + remove(settings, logger); + + const files = glob.sync('**/*', { cwd: pluginDir }); + const expected = ['bar']; + expect(files.sort()).to.eql(expected.sort()); + }); + + }); + +}); diff --git a/src/cli_plugin/remove/__tests__/settings.js b/src/cli_plugin/remove/__tests__/settings.js new file mode 100644 index 000000000000..b87600d95968 --- /dev/null +++ b/src/cli_plugin/remove/__tests__/settings.js @@ -0,0 +1,106 @@ +import path from 'path'; +import expect from 'expect.js'; +import fromRoot from '../../../utils/from_root'; +import { resolve } from 'path'; +import { parseMilliseconds, parse } from '../settings'; + +describe('kibana cli', function () { + + describe('plugin installer', function () { + + describe('command line option parsing', function () { + + describe('parse function', function () { + + const command = 'plugin name'; + let options = {}; + const kbnPackage = { version: 1234 }; + beforeEach(function () { + options = { pluginDir: fromRoot('installedPlugins') }; + }); + + describe('quiet option', function () { + + it('should default to false', function () { + const settings = parse(command, options, kbnPackage); + + expect(settings.quiet).to.be(false); + }); + + it('should set settings.quiet property to true', function () { + options.quiet = true; + const settings = parse(command, options, kbnPackage); + + expect(settings.quiet).to.be(true); + }); + + }); + + describe('silent option', function () { + + it('should default to false', function () { + const settings = parse(command, options, kbnPackage); + + expect(settings.silent).to.be(false); + }); + + it('should set settings.silent property to true', function () { + options.silent = true; + const settings = parse(command, options, kbnPackage); + + expect(settings.silent).to.be(true); + }); + + }); + + describe('config option', function () { + + it('should default to ZLS', function () { + const settings = parse(command, options, kbnPackage); + + expect(settings.config).to.be(''); + }); + + it('should set settings.config property', function () { + options.config = 'foo bar baz'; + const settings = parse(command, options, kbnPackage); + + expect(settings.config).to.be('foo bar baz'); + }); + + }); + + describe('pluginDir option', function () { + + it('should default to installedPlugins', function () { + const settings = parse(command, options, kbnPackage); + + expect(settings.pluginDir).to.be(fromRoot('installedPlugins')); + }); + + it('should set settings.config property', function () { + options.pluginDir = 'foo bar baz'; + const settings = parse(command, options, kbnPackage); + + expect(settings.pluginDir).to.be('foo bar baz'); + }); + + }); + + describe('command value', function () { + + it('should set settings.plugin property', function () { + const settings = parse(command, options, kbnPackage); + + expect(settings.plugin).to.be(command); + }); + + }); + + }); + + }); + + }); + +}); diff --git a/src/cli_plugin/remove/index.js b/src/cli_plugin/remove/index.js new file mode 100644 index 000000000000..0f6fbfb31736 --- /dev/null +++ b/src/cli_plugin/remove/index.js @@ -0,0 +1,39 @@ +import fromRoot from '../../utils/from_root'; +import remove from './remove'; +import Logger from '../lib/logger'; +import { parse } from './settings'; + +function processCommand(command, options) { + let settings; + try { + settings = parse(command, options); + } catch (ex) { + //The logger has not yet been initialized. + console.error(ex.message); + process.exit(64); // eslint-disable-line no-process-exit + } + + const logger = new Logger(settings); + remove(settings, logger); +} + +export default function pluginRemove(program) { + program + .command('remove ') + .option('-q, --quiet', 'disable all process messaging except errors') + .option('-s, --silent', 'disable all process messaging') + .option( + '-c, --config ', + 'path to the config file', + fromRoot('config/kibana.yml') + ) + .option( + '-d, --plugin-dir ', + 'path to the directory where plugins are stored', + fromRoot('installedPlugins') + ) + .description('remove a plugin', +`common examples: + remove xpack`) + .action(processCommand); +}; diff --git a/src/cli_plugin/remove/remove.js b/src/cli_plugin/remove/remove.js new file mode 100644 index 000000000000..4fa40076129e --- /dev/null +++ b/src/cli_plugin/remove/remove.js @@ -0,0 +1,23 @@ +import { statSync } from 'fs'; +import rimraf from 'rimraf'; + +export default function remove(settings, logger) { + try { + let stat; + try { + stat = statSync(settings.pluginPath); + } catch (e) { + throw new Error(`Plugin [${settings.plugin}] is not installed`); + } + + if (!stat.isDirectory()) { + throw new Error(`[${settings.plugin}] is not a plugin`); + } + + logger.log(`Removing ${settings.plugin}...`); + rimraf.sync(settings.pluginPath); + } catch (err) { + logger.error(`Unable to remove plugin because of error: "${err.message}"`); + process.exit(74); // eslint-disable-line no-process-exit + } +} diff --git a/src/cli_plugin/remove/settings.js b/src/cli_plugin/remove/settings.js new file mode 100644 index 000000000000..7e7ed37d2e5a --- /dev/null +++ b/src/cli_plugin/remove/settings.js @@ -0,0 +1,15 @@ +import { resolve } from 'path'; + +export function parse(command, options) { + const settings = { + quiet: options.quiet || false, + silent: options.silent || false, + config: options.config || '', + pluginDir: options.pluginDir || '', + plugin: command + }; + + settings.pluginPath = resolve(settings.pluginDir, settings.plugin); + + return settings; +};