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
This commit is contained in:
Pierre Vanduynslager 2018-02-12 00:50:20 -05:00 committed by Gregor Martynus
parent f92677b092
commit 97cb354fea
4 changed files with 272 additions and 75 deletions

100
cli.js
View File

@ -1,64 +1,64 @@
const program = require('commander');
const {pickBy, isUndefined} = require('lodash'); const {pickBy, isUndefined} = require('lodash');
function list(values) { const stringList = {
return values type: 'string',
.split(',') array: true,
.map(value => value.trim()) coerce: values =>
.filter(value => value && value !== 'false'); values.length === 1 && values[0].trim() === 'false'
} ? []
: values.reduce((values, value) => values.concat(value.split(',').map(value => value.trim())), []),
};
module.exports = async () => { module.exports = async () => {
program const cli = require('yargs')
.name('semantic-release') .command('$0', 'Run automated package publishing', yargs => {
.description('Run automated package publishing') yargs.demandCommand(0, 0).usage(`Run automated package publishing
.option('-b, --branch <branch>', 'Branch to release from') Usage:
.option('-r, --repository-url <repositoryUrl>', 'Git repository URL') semantic-release [options] [plugins]`);
.option('-t, --tag-format <tagFormat>', `Git tag format`) })
.option('-e, --extends <paths>', 'Comma separated list of shareable config paths or packages name', list) .option('b', {alias: 'branch', describe: 'Git branch to release from', type: 'string', group: 'Options'})
.option( .option('r', {alias: 'repository-url', describe: 'Git repository URL', type: 'string', group: 'Options'})
'--verify-conditions <paths>', .option('t', {alias: 'tag-format', describe: 'Git tag format', type: 'string', group: 'Options'})
'Comma separated list of paths or packages name for the verifyConditions plugin(s)', .option('e', {alias: 'extends', describe: 'Shareable configurations', ...stringList, group: 'Options'})
list .option('ci', {describe: 'Toggle CI verifications', default: true, type: 'boolean', group: 'Options'})
) .option('verify-conditions', {...stringList, group: 'Plugins'})
.option('--analyze-commits <path>', 'Path or package name for the analyzeCommits plugin') .option('analyze-commits', {type: 'string', group: 'Plugins'})
.option( .option('verify-release', {...stringList, group: 'Plugins'})
'--verify-release <paths>', .option('generate-notes', {type: 'string', group: 'Plugins'})
'Comma separated list of paths or packages name for the verifyRelease plugin(s)', .option('publish', {...stringList, group: 'Plugins'})
list .option('success', {...stringList, group: 'Plugins'})
) .option('fail', {...stringList, group: 'Plugins'})
.option('--generate-notes <path>', 'Path or package name for the generateNotes plugin') .option('debug', {describe: 'Output debugging information', default: false, type: 'boolean', group: 'Options'})
.option('--publish <paths>', 'Comma separated list of paths or packages name for the publish plugin(s)', list) .option('d', {alias: 'dry-run', describe: 'Skip publishing', default: false, type: 'boolean', group: 'Options'})
.option('--success <paths>', 'Comma separated list of paths or packages name for the success plugin(s)', list) .option('h', {alias: 'help', group: 'Options'})
.option('--fail <paths>', 'Comma separated list of paths or packages name for the fail plugin(s)', list) .option('v', {alias: 'version', group: 'Options'})
.option( .strict(false)
'--no-ci', .exitProcess(false);
'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) { try {
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 // Debug must be enabled before other requires in order to work
require('debug').enable('semantic-release:*'); require('debug').enable('semantic-release:*');
} }
try { // Remove option with undefined values, as yargs sets non defined options as `undefined`
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))); await require('.')(pickBy(opts, value => !isUndefined(value)));
} process.exitCode = 0;
} catch (err) { } catch (err) {
if (err.name !== 'YError') {
console.error(err);
}
process.exitCode = 1; process.exitCode = 1;
} }
}; };

View File

@ -42,7 +42,8 @@
"p-reduce": "^1.0.0", "p-reduce": "^1.0.0",
"read-pkg-up": "^3.0.0", "read-pkg-up": "^3.0.0",
"resolve-from": "^4.0.0", "resolve-from": "^4.0.0",
"semver": "^5.4.1" "semver": "^5.4.1",
"yargs": "^11.0.0"
}, },
"devDependencies": { "devDependencies": {
"ava": "^0.25.0", "ava": "^0.25.0",

217
test/cli.test.js Normal file
View File

@ -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);
});

View File

@ -619,24 +619,3 @@ test.serial('Exit with 1 if missing permission to push to the remote repository'
t.regex(stdout, /EGITNOPERMISSION/); t.regex(stdout, /EGITNOPERMISSION/);
t.is(code, 1); 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);
});