diff --git a/cli.js b/cli.js index 658bd495e1..b1f1c715a3 100755 --- a/cli.js +++ b/cli.js @@ -1,64 +1,64 @@ -const program = require('commander'); const {pickBy, isUndefined} = require('lodash'); -function list(values) { - return values - .split(',') - .map(value => value.trim()) - .filter(value => value && value !== 'false'); -} +const stringList = { + type: 'string', + array: true, + coerce: values => + values.length === 1 && values[0].trim() === 'false' + ? [] + : values.reduce((values, value) => values.concat(value.split(',').map(value => value.trim())), []), +}; module.exports = async () => { - program - .name('semantic-release') - .description('Run automated package publishing') - .option('-b, --branch ', 'Branch to release from') - .option('-r, --repository-url ', 'Git repository URL') - .option('-t, --tag-format ', `Git tag format`) - .option('-e, --extends ', 'Comma separated list of shareable config paths or packages name', list) - .option( - '--verify-conditions ', - 'Comma separated list of paths or packages name for the verifyConditions plugin(s)', - list - ) - .option('--analyze-commits ', 'Path or package name for the analyzeCommits plugin') - .option( - '--verify-release ', - 'Comma separated list of paths or packages name for the verifyRelease plugin(s)', - list - ) - .option('--generate-notes ', 'Path or package name for the generateNotes plugin') - .option('--publish ', 'Comma separated list of paths or packages name for the publish plugin(s)', list) - .option('--success ', 'Comma separated list of paths or packages name for the success plugin(s)', list) - .option('--fail ', 'Comma separated list of paths or packages name for the fail plugin(s)', list) - .option( - '--no-ci', - 'Skip Continuous Integration environment verifications, allowing to make releases from a local machine' - ) - .option('--debug', 'Output debugging information') - .option( - '-d, --dry-run', - 'Dry-run mode, skipping verifyConditions, publishing and release, printing next version and release notes' - ) - .parse(process.argv); - - if (program.debug) { - // Debug must be enabled before other requires in order to work - require('debug').enable('semantic-release:*'); - } + const cli = require('yargs') + .command('$0', 'Run automated package publishing', yargs => { + yargs.demandCommand(0, 0).usage(`Run automated package publishing +Usage: + semantic-release [options] [plugins]`); + }) + .option('b', {alias: 'branch', describe: 'Git branch to release from', type: 'string', group: 'Options'}) + .option('r', {alias: 'repository-url', describe: 'Git repository URL', type: 'string', group: 'Options'}) + .option('t', {alias: 'tag-format', describe: 'Git tag format', type: 'string', group: 'Options'}) + .option('e', {alias: 'extends', describe: 'Shareable configurations', ...stringList, group: 'Options'}) + .option('ci', {describe: 'Toggle CI verifications', default: true, type: 'boolean', group: 'Options'}) + .option('verify-conditions', {...stringList, group: 'Plugins'}) + .option('analyze-commits', {type: 'string', group: 'Plugins'}) + .option('verify-release', {...stringList, group: 'Plugins'}) + .option('generate-notes', {type: 'string', group: 'Plugins'}) + .option('publish', {...stringList, group: 'Plugins'}) + .option('success', {...stringList, group: 'Plugins'}) + .option('fail', {...stringList, group: 'Plugins'}) + .option('debug', {describe: 'Output debugging information', default: false, type: 'boolean', group: 'Options'}) + .option('d', {alias: 'dry-run', describe: 'Skip publishing', default: false, type: 'boolean', group: 'Options'}) + .option('h', {alias: 'help', group: 'Options'}) + .option('v', {alias: 'version', group: 'Options'}) + .strict(false) + .exitProcess(false); try { - if (program.args.length > 0) { - program.outputHelp(); - process.exitCode = 1; - } else { - const opts = program.opts(); - // Set the `noCi` options as commander.js sets the `ci` options instead (because args starts with `--no`) - opts.noCi = opts.ci === false ? true : undefined; - // Remove option with undefined values, as commander.js sets non defined options as `undefined` - await require('.')(pickBy(opts, value => !isUndefined(value))); + const {help, version, ...opts} = cli.argv; + if (Boolean(help) || Boolean(version)) { + process.exitCode = 0; + return; + } + + // Set the `noCi` options as yargs sets the `ci` options instead (because arg starts with `--no`) + if (opts.ci === false) { + opts.noCi = true; } + + if (opts.debug) { + // Debug must be enabled before other requires in order to work + require('debug').enable('semantic-release:*'); + } + + // Remove option with undefined values, as yargs sets non defined options as `undefined` + await require('.')(pickBy(opts, value => !isUndefined(value))); + process.exitCode = 0; } catch (err) { + if (err.name !== 'YError') { + console.error(err); + } process.exitCode = 1; } }; diff --git a/package.json b/package.json index 26578ee5cf..bd02d1b87b 100644 --- a/package.json +++ b/package.json @@ -42,7 +42,8 @@ "p-reduce": "^1.0.0", "read-pkg-up": "^3.0.0", "resolve-from": "^4.0.0", - "semver": "^5.4.1" + "semver": "^5.4.1", + "yargs": "^11.0.0" }, "devDependencies": { "ava": "^0.25.0", diff --git a/test/cli.test.js b/test/cli.test.js new file mode 100644 index 0000000000..fcb1d166cb --- /dev/null +++ b/test/cli.test.js @@ -0,0 +1,217 @@ +import test from 'ava'; +import proxyquire from 'proxyquire'; +import clearModule from 'clear-module'; +import {stub} from 'sinon'; + +// Save the current process.env and process.argv +const envBackup = Object.assign({}, process.env); +const argvBackup = Object.assign({}, process.argv); + +test.beforeEach(t => { + clearModule('yargs'); + t.context.logs = ''; + t.context.errors = ''; + t.context.stdout = stub(process.stdout, 'write').callsFake(val => { + t.context.logs += val.toString(); + }); + t.context.stderr = stub(process.stderr, 'write').callsFake(val => { + t.context.errors += val.toString(); + }); +}); + +test.afterEach.always(t => { + process.env = envBackup; + process.argv = argvBackup; + t.context.stdout.restore(); + t.context.stderr.restore(); + delete process.exitCode; +}); + +test.serial('Pass options to semantic-release API', async t => { + const run = stub().resolves(true); + const cli = proxyquire('../cli', {'.': run}); + + process.argv = [ + '', + '', + '-b', + 'master', + '-r', + 'https://github/com/owner/repo.git', + '-t', + `v\${version}`, + '-e', + 'config1', + 'config2', + '--verify-conditions', + 'condition1', + 'condition2', + '--analyze-commits', + 'analyze', + '--verify-release', + 'verify1', + 'verify2', + '--generate-notes', + 'notes', + '--publish', + 'publish1', + 'publish2', + '--success', + 'success1', + 'success2', + '--fail', + 'fail1', + 'fail2', + '--debug', + '-d', + ]; + + await cli(); + + t.is(run.args[0][0].branch, 'master'); + t.is(run.args[0][0].repositoryUrl, 'https://github/com/owner/repo.git'); + t.is(run.args[0][0].tagFormat, `v\${version}`); + t.deepEqual(run.args[0][0].extends, ['config1', 'config2']); + t.deepEqual(run.args[0][0].verifyConditions, ['condition1', 'condition2']); + t.is(run.args[0][0].analyzeCommits, 'analyze'); + t.deepEqual(run.args[0][0].verifyRelease, ['verify1', 'verify2']); + t.is(run.args[0][0].generateNotes, 'notes'); + t.deepEqual(run.args[0][0].publish, ['publish1', 'publish2']); + t.deepEqual(run.args[0][0].success, ['success1', 'success2']); + t.deepEqual(run.args[0][0].fail, ['fail1', 'fail2']); + t.is(run.args[0][0].debug, true); + t.is(run.args[0][0].dryRun, true); + + t.is(process.exitCode, 0); +}); + +test.serial('Pass options to semantic-release API with alias arguments', async t => { + const run = stub().resolves(true); + const cli = proxyquire('../cli', {'.': run}); + + process.argv = [ + '', + '', + '--branch', + 'master', + '--repository-url', + 'https://github/com/owner/repo.git', + '--tag-format', + `v\${version}`, + '--extends', + 'config1', + 'config2', + '--dry-run', + ]; + + await cli(); + + t.is(run.args[0][0].branch, 'master'); + t.is(run.args[0][0].repositoryUrl, 'https://github/com/owner/repo.git'); + t.is(run.args[0][0].tagFormat, `v\${version}`); + t.deepEqual(run.args[0][0].extends, ['config1', 'config2']); + t.is(run.args[0][0].dryRun, true); + + t.is(process.exitCode, 0); +}); + +test.serial('Pass unknown options to semantic-release API', async t => { + const run = stub().resolves(true); + const cli = proxyquire('../cli', {'.': run}); + + process.argv = [ + '', + '', + '--bool', + '--first-option', + 'value1', + '--second-option', + 'value2', + '--second-option', + 'value3', + ]; + + await cli(); + + t.is(run.args[0][0].bool, true); + t.is(run.args[0][0].firstOption, 'value1'); + t.deepEqual(run.args[0][0].secondOption, ['value2', 'value3']); + + t.is(process.exitCode, 0); +}); + +test.serial('Pass empty Array to semantic-release API for list option set to "false"', async t => { + const run = stub().resolves(true); + const cli = proxyquire('../cli', {'.': run}); + + process.argv = ['', '', '--publish', 'false']; + + await cli(); + + t.deepEqual(run.args[0][0].publish, []); + + t.is(process.exitCode, 0); +}); + +test.serial('Set "noCi" options to "true" with "--no-ci"', async t => { + const run = stub().resolves(true); + const cli = proxyquire('../cli', {'.': run}); + + process.argv = ['', '', '--no-ci']; + + await cli(); + + t.is(run.args[0][0].noCi, true); + + t.is(process.exitCode, 0); +}); + +test.serial('Display help', async t => { + const run = stub().resolves(true); + const cli = proxyquire('../cli', {'.': run}); + + process.argv = ['', '', '--help']; + + await cli(); + + t.regex(t.context.logs, /Run automated package publishing/); + t.is(process.exitCode, 0); +}); + +test.serial('Returns error code and prints help if called with a command', async t => { + const run = stub().resolves(true); + const cli = proxyquire('../cli', {'.': run}); + + process.argv = ['', '', 'pre']; + + await cli(); + + t.regex(t.context.errors, /Run automated package publishing/); + t.regex(t.context.errors, /Too many non-option arguments/); + t.is(process.exitCode, 1); +}); + +test.serial('Return error code if multiple plugin are set for single plugin', async t => { + const run = stub().resolves(true); + const cli = proxyquire('../cli', {'.': run}); + + process.argv = ['', '', '--analyze-commits', 'analyze1', 'analyze2']; + + await cli(); + + t.regex(t.context.errors, /Run automated package publishing/); + t.regex(t.context.errors, /Too many non-option arguments/); + t.is(process.exitCode, 1); +}); + +test.serial('Return error code if semantic-release throw error', async t => { + const run = stub().rejects(new Error('semantic-release error')); + const cli = proxyquire('../cli', {'.': run}); + + process.argv = ['', '']; + + await cli(); + + t.regex(t.context.errors, /semantic-release error/); + t.is(process.exitCode, 1); +}); diff --git a/test/integration.test.js b/test/integration.test.js index 3238dd0126..1c0b24e604 100644 --- a/test/integration.test.js +++ b/test/integration.test.js @@ -619,24 +619,3 @@ test.serial('Exit with 1 if missing permission to push to the remote repository' t.regex(stdout, /EGITNOPERMISSION/); t.is(code, 1); }); - -test.serial('CLI returns error code and prints help if called with a command', async t => { - t.log('$ semantic-release pre'); - const {stdout, code} = await execa(cli, ['pre'], {env, reject: false}); - t.regex(stdout, /Usage: semantic-release/); - t.is(code, 1); -}); - -test.serial('CLI prints help if called with --help', async t => { - t.log('$ semantic-release --help'); - const {stdout, code} = await execa(cli, ['--help'], {env}); - t.regex(stdout, /Usage: semantic-release/); - t.is(code, 0); -}); - -test.serial('CLI returns error code with invalid option', async t => { - t.log('$ semantic-release --unknown-option'); - const {stderr, code} = await execa(cli, ['--unknown-option'], {env, reject: false}); - t.regex(stderr, /unknown option/); - t.is(code, 1); -});