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:
		
							parent
							
								
									f92677b092
								
							
						
					
					
						commit
						97cb354fea
					
				
							
								
								
									
										100
									
								
								cli.js
									
									
									
									
									
								
							
							
						
						
									
										100
									
								
								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); | ||||
|   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); | ||||
| 
 | ||||
|   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
 | ||||
|       require('debug').enable('semantic-release:*'); | ||||
|     } | ||||
| 
 | ||||
|   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`
 | ||||
|     // 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; | ||||
|   } | ||||
| }; | ||||
|  | ||||
| @ -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", | ||||
|  | ||||
							
								
								
									
										217
									
								
								test/cli.test.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										217
									
								
								test/cli.test.js
									
									
									
									
									
										Normal 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); | ||||
| }); | ||||
| @ -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); | ||||
| }); | ||||
|  | ||||
		Loading…
	
	
			
			x
			
			
		
	
		Reference in New Issue
	
	Block a user