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'); | 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; | ||||||
|   } |   } | ||||||
| }; | }; | ||||||
|  | |||||||
| @ -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
									
								
							
							
						
						
									
										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.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); |  | ||||||
| }); |  | ||||||
|  | |||||||
		Loading…
	
	
			
			x
			
			
		
	
		Reference in New Issue
	
	Block a user