diff --git a/commands/plugin-install.js b/commands/plugin-install.js new file mode 100644 index 00000000000..8ac92515790 --- /dev/null +++ b/commands/plugin-install.js @@ -0,0 +1,127 @@ +'use strict'; + +const spawn = require('child-process-ext/spawn'); +const fsp = require('fs').promises; +const fse = require('fs-extra'); +const path = require('path'); +const _ = require('lodash'); +const isPlainObject = require('type/plain-object/is'); +const yaml = require('js-yaml'); +const cloudformationSchema = require('@serverless/utils/cloudformation-schema'); +const log = require('@serverless/utils/log'); +const ServerlessError = require('../lib/serverless-error'); +const yamlAstParser = require('../lib/utils/yamlAstParser'); +const npmCommandDeferred = require('../lib/utils/npm-command-deferred'); +const { + getPluginInfo, + getServerlessFilePath, + validate, +} = require('../lib/commands/plugin-management'); + +module.exports = async ({ configuration, serviceDir, configurationFilename, options }) => { + validate({ serviceDir }); + + const pluginInfo = getPluginInfo(options.name); + const pluginName = pluginInfo.name; + const pluginVersion = pluginInfo.version || 'latest'; + const configurationFilePath = getServerlessFilePath({ serviceDir, configurationFilename }); + + const context = { configuration, serviceDir, configurationFilePath, pluginName, pluginVersion }; + await installPlugin(context); + await addPluginToServerlessFile(context); + + const message = ['Successfully installed', ` "${pluginName}@${pluginVersion}"`].join(''); + log(message); +}; + +const installPlugin = async ({ serviceDir, pluginName, pluginVersion }) => { + const pluginFullName = `${pluginName}@${pluginVersion}`; + const message = [ + `Installing plugin "${pluginFullName}"`, + ' (this might take a few seconds...)', + ].join(''); + log(message); + await npmInstall(pluginFullName, { serviceDir }); +}; + +const addPluginToServerlessFile = async ({ configurationFilePath, pluginName }) => { + const fileExtension = path.extname(configurationFilePath); + if (fileExtension === '.js' || fileExtension === '.ts') { + requestManualUpdate(configurationFilePath); + return; + } + + const checkIsArrayPluginsObject = (pluginsObject) => + pluginsObject == null || Array.isArray(pluginsObject); + // pluginsObject type determined based on the value loaded during the serverless init. + if (_.last(configurationFilePath.split('.')) === 'json') { + const serverlessFileObj = await fse.readJson(configurationFilePath); + const newServerlessFileObj = serverlessFileObj; + const isArrayPluginsObject = checkIsArrayPluginsObject(newServerlessFileObj.plugins); + // null modules property is not supported + let plugins = isArrayPluginsObject + ? newServerlessFileObj.plugins || [] + : newServerlessFileObj.plugins.modules; + + if (plugins == null) { + throw new ServerlessError( + 'plugins modules property must be present', + 'PLUGINS_MODULES_MISSING' + ); + } + + plugins.push(pluginName); + plugins = _.sortedUniq(plugins); + + if (isArrayPluginsObject) { + newServerlessFileObj.plugins = plugins; + } else { + newServerlessFileObj.plugins.modules = plugins; + } + + await fse.writeJson(configurationFilePath, newServerlessFileObj); + return; + } + + const serverlessFileObj = yaml.load(await fsp.readFile(configurationFilePath, 'utf8'), { + filename: configurationFilePath, + schema: cloudformationSchema, + }); + if (serverlessFileObj.plugins != null) { + // Plugins section can be behind veriables, opt-out in such case + if (isPlainObject(serverlessFileObj.plugins)) { + if ( + serverlessFileObj.plugins.modules != null && + !Array.isArray(serverlessFileObj.plugins.modules) + ) { + requestManualUpdate(configurationFilePath); + return; + } + } else if (!Array.isArray(serverlessFileObj.plugins)) { + requestManualUpdate(configurationFilePath); + return; + } + } + await yamlAstParser.addNewArrayItem( + configurationFilePath, + checkIsArrayPluginsObject(serverlessFileObj.plugins) ? 'plugins' : 'plugins.modules', + pluginName + ); +}; + +const npmInstall = async (name, { serviceDir }) => { + const npmCommand = await npmCommandDeferred; + await spawn(npmCommand, ['install', '--save-dev', name], { + cwd: serviceDir, + stdio: 'ignore', + // To parse quotes used in module versions. E.g. 'serverless@"^1.60.0 || 2"' + // https://stackoverflow.com/a/48015470 + shell: true, + }); +}; + +const requestManualUpdate = (configurationFilePath) => + log(` + Can't automatically add plugin into "${path.basename(configurationFilePath)}" file. + Please make it manually. +`); diff --git a/lib/commands/plugin-management.js b/lib/commands/plugin-management.js new file mode 100644 index 00000000000..6efa1cc0f32 --- /dev/null +++ b/lib/commands/plugin-management.js @@ -0,0 +1,37 @@ +'use strict'; + +const path = require('path'); +const ServerlessError = require('../../lib/serverless-error'); + +module.exports = { + validate({ serviceDir }) { + if (!serviceDir) { + throw new ServerlessError( + 'This command can only be run inside a service directory', + 'MISSING_SERVICE_DIRECTORY' + ); + } + }, + + getServerlessFilePath({ serviceDir, configurationFilename }) { + if (configurationFilename) { + return path.resolve(serviceDir, configurationFilename); + } + throw new ServerlessError( + 'Could not find any serverless service definition file.', + 'MISSING_SERVICE_CONFIGURATION_FILE' + ); + }, + + getPluginInfo(name_) { + let name; + let version; + if (name_.startsWith('@')) { + [, name, version] = name_.split('@', 3); + name = `@${name}`; + } else { + [name, version] = name_.split('@', 2); + } + return { name, version }; + }, +}; diff --git a/lib/plugins/plugin/install.js b/lib/plugins/plugin/install.js index 3a5813c2333..65af0000944 100644 --- a/lib/plugins/plugin/install.js +++ b/lib/plugins/plugin/install.js @@ -1,3 +1,5 @@ +// TODO: Remove in v3 + 'use strict'; const BbPromise = require('bluebird'); diff --git a/scripts/serverless.js b/scripts/serverless.js index a09c6cf59e4..82e517b5a01 100755 --- a/scripts/serverless.js +++ b/scripts/serverless.js @@ -41,6 +41,8 @@ let hasTelemetryBeenReported = false; // to propery handle e.g. `SIGINT` interrupt const keepAliveTimer = setTimeout(() => {}, 60 * 60 * 1000); +const standaloneCommands = ['plugin install']; + process.once('uncaughtException', (error) => { clearTimeout(keepAliveTimer); progress.clear(); @@ -495,24 +497,38 @@ const processSpanPromise = (async () => { const configurationFilename = configuration && configurationPath.slice(serviceDir.length + 1); - if (isInteractiveSetup) { - require('../lib/cli/ensure-supported-command')(configuration); + const isStandaloneCommand = standaloneCommands.includes(command); - if (!process.stdin.isTTY && !process.env.SLS_INTERACTIVE_SETUP_ENABLE) { - throw new ServerlessError( - 'Attempted to run an interactive setup in non TTY environment.\n' + - "If that's intentended enforce with SLS_INTERACTIVE_SETUP_ENABLE=1 environment variable", - 'INTERACTIVE_SETUP_IN_NON_TTY' - ); - } - const { configuration: configurationFromInteractive } = - await require('../lib/cli/interactive-setup')({ + if (isInteractiveSetup || isStandaloneCommand) { + if (isInteractiveSetup) { + require('../lib/cli/ensure-supported-command')(configuration); + + if (!process.stdin.isTTY && !process.env.SLS_INTERACTIVE_SETUP_ENABLE) { + throw new ServerlessError( + 'Attempted to run an interactive setup in non TTY environment.\n' + + "If that's intentended enforce with SLS_INTERACTIVE_SETUP_ENABLE=1 environment variable", + 'INTERACTIVE_SETUP_IN_NON_TTY' + ); + } + const result = await require('../lib/cli/interactive-setup')({ configuration, serviceDir, configurationFilename, options, commandUsage, }); + if (result.configuration) { + configuration = result.configuration; + } + } else { + require('../lib/cli/ensure-supported-command')(configuration); + await require(`../commands/${commands.join('-')}`)({ + configuration, + serviceDir, + configurationFilename, + options, + }); + } progress.clear(); @@ -527,7 +543,7 @@ const processSpanPromise = (async () => { options, commandSchema, serviceDir, - configuration: configurationFromInteractive, + configuration, commandUsage, variableSources: variableSourcesInConfig, }), diff --git a/test/README.md b/test/README.md index 0d124be37ca..ef0ee90fb77 100644 --- a/test/README.md +++ b/test/README.md @@ -61,7 +61,7 @@ Check existing set of AWS integration tests at [test/integration](./integration) Pass test file to Mocha directly as follows ``` -AWS_ACCESS_KEY_ID=XXX AWS_SECRET_ACCESS_KEY=xxx npx mocha tests/integration/{chosen}.test.js +AWS_ACCESS_KEY_ID=XXX AWS_SECRET_ACCESS_KEY=xxx npx mocha test/integration/{chosen}.test.js ``` ### Tests that depend on shared infrastructure stack @@ -90,13 +90,13 @@ To run all integration tests run: To run only a specific integration test run: ``` -tests/templates/integration-test-template TEMPLATE_NAME BUILD_COMMAND +test/templates/integration-test-template TEMPLATE_NAME BUILD_COMMAND ``` so for example: ``` -tests/templates/integration-test-template aws-java-maven mvn package +test/templates/integration-test-template aws-java-maven mvn package ``` If you add a new template make sure to add it to the `test-all-templates` file and configure the `docker-compose.yml` file for your template. diff --git a/test/unit/commands/plugin-install.test.js b/test/unit/commands/plugin-install.test.js new file mode 100644 index 00000000000..d84adb871b2 --- /dev/null +++ b/test/unit/commands/plugin-install.test.js @@ -0,0 +1,62 @@ +'use strict'; + +const chai = require('chai'); +const sinon = require('sinon'); +const yaml = require('js-yaml'); +const fse = require('fs-extra'); +const proxyquire = require('proxyquire'); +const fixturesEngine = require('../../fixtures/programmatic'); +const resolveConfigurationPath = require('../../../lib/cli/resolve-configuration-path'); +const { expect } = require('chai'); + +chai.use(require('chai-as-promised')); + +const npmCommand = 'npm'; + +describe('test/unit/commands/plugin-install.test.js', async () => { + let spawnFake; + let serviceDir; + let configurationFilePath; + + const pluginName = 'serverless-plugin-1'; + + before(async () => { + spawnFake = sinon.fake(); + const installPlugin = proxyquire('../../../commands/plugin-install', { + 'child-process-ext/spawn': spawnFake, + }); + + const fixture = await fixturesEngine.setup('function'); + + const configuration = fixture.serviceConfig; + serviceDir = fixture.servicePath; + configurationFilePath = await resolveConfigurationPath({ + cwd: serviceDir, + }); + const configurationFilename = configurationFilePath.slice(serviceDir.length + 1); + const options = { + name: pluginName, + }; + + await installPlugin({ + configuration, + serviceDir, + configurationFilename, + options, + }); + }); + + it('should install plugin', () => { + const firstCall = spawnFake.firstCall; + const command = [firstCall.args[0], ...firstCall.args[1]].join(' '); + const expectedCommand = `${npmCommand} install --save-dev ${pluginName}`; + expect(command).to.have.string(expectedCommand); + }); + + it('should add plugin to serverless file', async () => { + const serverlessFileObj = yaml.load(await fse.readFile(configurationFilePath, 'utf8'), { + filename: configurationFilePath, + }); + expect(serverlessFileObj.plugins).to.include(pluginName); + }); +});