Repository: semantic-release/semantic-release
Pull request #661 (2018-02-12 05:50:59)

 feat: improve CLI
- Replace `commander.js` with `yargs`
- Add CLI unit tests
- Add a `--version` option
- Improve `--help` output
- Remove `commander.js` related workaround
- Allow to set list option with arg repetition or space separated list
- Maintain the list options defined as comma separated list


Patch:
diff --git a/cli.js b/cli.js
index 658bd49..b1f1c71 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>', 'Branch to release from')
-    .option('-r, --repository-url <repositoryUrl>', 'Git repository URL')
-    .option('-t, --tag-format <tagFormat>', `Git tag format`)
-    .option('-e, --extends <paths>', 'Comma separated list of shareable config paths or packages name', list)
-    .option(
-      '--verify-conditions <paths>',
-      'Comma separated list of paths or packages name for the verifyConditions plugin(s)',
-      list
-    )
-    .option('--analyze-commits <path>', 'Path or package name for the analyzeCommits plugin')
-    .option(
-      '--verify-release <paths>',
-      'Comma separated list of paths or packages name for the verifyRelease plugin(s)',
-      list
-    )
-    .option('--generate-notes <path>', 'Path or package name for the generateNotes plugin')
-    .option('--publish <paths>', 'Comma separated list of paths or packages name for the publish plugin(s)', list)
-    .option('--success <paths>', 'Comma separated list of paths or packages name for the success plugin(s)', list)
-    .option('--fail <paths>', '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/index.js b/index.js
index 61ef962..bb1f73f 100644
--- a/index.js
+++ b/index.js
@@ -3,6 +3,7 @@ const marked = require('marked');
 const TerminalRenderer = require('marked-terminal');
 const envCi = require('env-ci');
 const hookStd = require('hook-std');
+const pkg = require('./package.json');
 const hideSensitive = require('./lib/hide-sensitive');
 const getConfig = require('./lib/get-config');
 const verify = require('./lib/verify');
@@ -140,6 +141,7 @@ async function callFail(plugins, options, error) {
 }
 
 module.exports = async opts => {
+  logger.log(`Running %s version %s`, pkg.name, pkg.version);
   const unhook = hookStd({silent: false}, hideSensitive);
   try {
     const config = await getConfig(opts, logger);
diff --git a/package.json b/package.json
index 26578ee..bd02d1b 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",


Test patch:
diff --git a/test/cli.test.js b/test/cli.test.js
new file mode 100644
index 0000000..fcb1d16
--- /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/index.test.js b/test/index.test.js
index a6db401..9160349 100644
--- a/test/index.test.js
+++ b/test/index.test.js
@@ -475,7 +475,7 @@ test.serial('Force a dry-run if not on a CI and "noCi" is not explicitly set', a
   });
   t.truthy(await semanticRelease(options));
 
-  t.is(t.context.log.args[0][0], 'This run was not triggered in a known CI environment, running in dry-run mode.');
+  t.is(t.context.log.args[1][0], 'This run was not triggered in a known CI environment, running in dry-run mode.');
   t.is(verifyConditions.callCount, 1);
   t.is(analyzeCommits.callCount, 1);
   t.is(verifyRelease.callCount, 1);
@@ -596,7 +596,7 @@ test.serial('Returns falsy value if triggered by a PR', async t => {
 
   t.falsy(await semanticRelease({repositoryUrl}));
   t.is(
-    t.context.log.args[8][0],
+    t.context.log.args[9][0],
     "This run was triggered by a pull request and therefore a new version won't be published."
   );
 });
@@ -623,7 +623,7 @@ test.serial('Returns falsy value if not running from the configured branch', asy
 
   t.falsy(await semanticRelease(options));
   t.is(
-    t.context.log.args[0][0],
+    t.context.log.args[1][0],
     'This test run was triggered on the branch other-branch, while semantic-release is configured to only publish from master, therefore a new version won’t be published.'
   );
 });
@@ -661,7 +661,7 @@ test.serial('Returns falsy value if there is no relevant changes', async t => {
   t.is(verifyRelease.callCount, 0);
   t.is(generateNotes.callCount, 0);
   t.is(publish.callCount, 0);
-  t.is(t.context.log.args[6][0], 'There are no relevant changes, so no new version is released.');
+  t.is(t.context.log.args[7][0], 'There are no relevant changes, so no new version is released.');
 });
 
 test.serial('Exclude commits with [skip release] or [release skip] from analysis', async t => {
@@ -723,8 +723,8 @@ test.serial('Hide sensitive environment variable values from the logs', async t
 
   await t.throws(semanticRelease(options));
 
-  t.regex(t.context.stdout.args[8][0], /Console: The token \[secure\] is invalid/);
-  t.regex(t.context.stdout.args[9][0], /Log: The token \[secure\] is invalid/);
+  t.regex(t.context.stdout.args[9][0], /Console: The token \[secure\] is invalid/);
+  t.regex(t.context.stdout.args[10][0], /Log: The token \[secure\] is invalid/);
   t.regex(t.context.stderr.args[0][0], /Error: The token \[secure\] is invalid/);
   t.regex(t.context.stderr.args[1][0], /Invalid token \[secure\]/);
 });
diff --git a/test/integration.test.js b/test/integration.test.js
index 3238dd0..1c0b24e 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);
-});