[dev/run] options can modify help, errors can show help (#33466)

* [dev/run] options can modify help, errors can show help

* include minor tooling log usage

* cleanup wording a little

* cleanup run options

# Conflicts:
#	src/dev/test_stats/get_test_stats_cli.ts

* explicitly document opts that are passed to getopts
This commit is contained in:
Spencer 2019-03-19 09:33:18 -07:00 committed by GitHub
parent 7c50ab8aff
commit ba4597e085
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
12 changed files with 233 additions and 21 deletions

View file

@ -49,5 +49,5 @@ export function lintFiles(log, files) {
}
log.error(cli.getFormatter()(report.results));
throw createFailError(`[eslint] ${failTypes.join(' & ')}`, 1);
throw createFailError(`[eslint] ${failTypes.join(' & ')}`);
}

125
src/dev/run/README.md Normal file
View file

@ -0,0 +1,125 @@
# dev/run
Helper functions for writing little scripts for random build/ci/dev tasks.
## Usage
Define the function that should validate the CLI arguments and call your task fn:
```ts
// dev/my_task/run_my_task.ts
import { createFlagError, run } from '../run';
run(
async ({ flags, log }) => {
if (typeof flags.path !== 'string') {
throw createFlagError('please provide a single --path flag');
}
await runTask(flags.path);
log.success('task complete');
},
{
description: `
Run my special task
`,
flags: {
string: ['path'],
help: `
--path Required, path to the file to operate on
`
},
}
);
```
Define the script which will setup node and load the script source:
```js
// scripts/my_task.js
require('../src/setup_node_env');
require('../src/dev/my_task/run_my_task');
```
Try out the script:
```sh
$ node scripts/my_task
# ERROR please provide a single --path flag
#
# node scripts/my_task.js
#
# Run my special task
#
# Options:
# --path Required, path to the file to operate on
# --verbose, -v Log verbosely
# --debug Log debug messages (less than verbose)
# --quiet Only log errors
# --silent Don't log anything
# --help Show this message
#
```
## API
- ***`run(fn: async ({ flags: Flags, log: ToolingLog }) => Promise<void>, options: Options)`***
Execte an async function, passing it the parsed flags and a tooling log that is configured to the requested logging level. If the returned promise is rejected with an error created by `createFailError(...)` or `createFlagError(...)` the process will exit as described by the error, otherwise the process will exit with code 1.
**`fn` Params:**
- *[`log: ToolingLog`](../../../packages/kbn-dev-utils/src/tooling_log/tooling_log.js)*:
An instance of the `ToolingLog` that is configured with the standard flags: `--verbose`, `--quiet`, `--silent`, and `--debug`
- *[`flags: Flags`](flags.ts)*:
The parsed CLI flags, created by [`getopts`](https://www.npmjs.com/package/getopts). Includes the default flags for controlling the log level of the ToolingLog, and `flags.unexpected`, which is an array of flag names which were passed but not expected.
**`Options`:**
- *`usage: string`*
A bit of text to replace the default usage in the `--help` text.
- *`description: string`*
A bit of text to replace the default description in the `--help` text.
- *`flags.help: string`*
A bit of text included at the top of the `Options:` section of the `--help` text.
- *`flags.string: string[]`*
An array of flag names that are expected to have a string value.
- *`flags.boolean: string[]`*
An array of flag names that are expected to have a boolean value.
- *`flags.alias: { [short: string], string }`*
A map of short flag names to long flag names, used to expand short flags like `-v` to `--verbose`.
- *`flags.default: { [name: string]: string | string[] | boolean | undefined }`*
A map of flag names to their default value. If the flag is not defined this value will be set in the flags object passed to the run `fn`.
- *`flags.allowUnexpected: boolean`*
By default, any flag that is passed but not mentioned in `flags.string`, `flags.boolean`, `flags.alias` or `flags.default` will trigger an error, preventing the run function from calling its first argument. If you have a reason to disable this behavior set this option to `true`.
- ***`createFailError(reason: string, options: { exitCode: number, showHelp: boolean }): FailError`***
Create and return an error object that, when thrown within `run(...)` can customize the failure behavior of the CLI. `reason` is printed instead of a stacktrace, `options.exitCode` customizes the exit code of the process, and `options.showHelp` will print the help text before exiting.
- ***`createFlagError(reason: string)`***
Shortcut for calling `createFailError()` with `options.showHelp`, as errors caused by invalid flags should print the help message to help users debug their usage.
- ***`isFailError(error: any)`***
Determine if a value is an error created by `createFailError(...)`.

View file

@ -23,16 +23,31 @@ const FAIL_TAG = Symbol('fail error');
interface FailError extends Error {
exitCode: number;
showHelp: boolean;
[FAIL_TAG]: true;
}
export function createFailError(reason: string, exitCode = 1): FailError {
interface FailErrorOptions {
exitCode?: number;
showHelp?: boolean;
}
export function createFailError(reason: string, options: FailErrorOptions = {}): FailError {
const { exitCode = 1, showHelp = false } = options;
return Object.assign(new Error(reason), {
exitCode,
showHelp,
[FAIL_TAG]: true as true,
});
}
export function createFlagError(reason: string) {
return createFailError(reason, {
showHelp: true,
});
}
export function isFailError(error: any): error is FailError {
return Boolean(error && error[FAIL_TAG]);
}
@ -46,6 +61,8 @@ export function combineErrors(errors: Array<Error | FailError>) {
.filter(isFailError)
.reduce((acc, error) => Math.max(acc, error.exitCode), 1);
const showHelp = errors.some(error => isFailError(error) && error.showHelp);
const message = errors.reduce((acc, error) => {
if (isFailError(error)) {
return acc + '\n' + error.message;
@ -54,5 +71,8 @@ export function combineErrors(errors: Array<Error | FailError>) {
return acc + `\nUNHANDLED ERROR\n${inspect(error)}`;
}, '');
return createFailError(`${errors.length} errors:\n${message}`, exitCode);
return createFailError(`${errors.length} errors:\n${message}`, {
exitCode,
showHelp,
});
}

View file

@ -19,6 +19,7 @@
import { relative } from 'path';
import dedent from 'dedent';
import getopts from 'getopts';
import { Options } from './run';
@ -30,23 +31,40 @@ export interface Flags {
debug: boolean;
help: boolean;
_: string[];
unexpected: string[];
[key: string]: undefined | boolean | string | string[];
}
export function getFlags(argv: string[]): Flags {
export function getFlags(argv: string[], options: Options): Flags {
const unexpected: string[] = [];
const flagOpts = options.flags || {};
const { verbose, quiet, silent, debug, help, _, ...others } = getopts(argv, {
string: flagOpts.string,
boolean: flagOpts.boolean,
alias: {
...(flagOpts.alias || {}),
v: 'verbose',
},
default: {
...(flagOpts.default || {}),
verbose: false,
quiet: false,
silent: false,
debug: false,
help: false,
},
});
unknown: (name: string) => {
unexpected.push(name);
if (options.flags && options.flags.allowUnexpected) {
return true;
}
return false;
},
} as any);
return {
verbose,
@ -55,22 +73,37 @@ export function getFlags(argv: string[]): Flags {
debug,
help,
_,
unexpected,
...others,
};
}
export function getHelp(options: Options) {
return `
node ${relative(process.cwd(), process.argv[1])}
const usage = options.usage || `node ${relative(process.cwd(), process.argv[1])}`;
${options.helpDescription || 'Runs a dev task'}
const optionHelp = (
dedent((options.flags && options.flags.help) || '') +
'\n' +
dedent`
--verbose, -v Log verbosely
--debug Log debug messages (less than verbose)
--quiet Only log errors
--silent Don't log anything
--help Show this message
`
)
.split('\n')
.filter(Boolean)
.join('\n ');
return `
${usage}
${dedent(options.description || 'Runs a dev task')
.split('\n')
.join('\n ')}
Options:
--verbose, -v Log verbosely
--debug Log debug messages (less than verbose)
--quiet Only log errors
--silent Don't log anything
--help Show this message
${optionHelp}
`;
}

View file

@ -18,4 +18,4 @@
*/
export { run } from './run';
export { createFailError, combineErrors, isFailError } from './fail';
export { createFailError, createFlagError, combineErrors, isFailError } from './fail';

View file

@ -18,17 +18,27 @@
*/
import { pickLevelFromFlags, ToolingLog } from '@kbn/dev-utils';
import { isFailError } from './fail';
import { createFlagError, isFailError } from './fail';
import { Flags, getFlags, getHelp } from './flags';
type RunFn = (args: { log: ToolingLog; flags: Flags }) => Promise<void> | void;
export interface Options {
helpDescription?: string;
usage?: string;
description?: string;
flags?: {
allowUnexpected?: boolean;
help?: string;
alias?: { [key: string]: string | string[] };
boolean?: string[];
string?: string[];
default?: { [key: string]: any };
};
}
export async function run(fn: RunFn, options: Options = {}) {
const flags = getFlags(process.argv.slice(2));
const flags = getFlags(process.argv.slice(2), options);
const allowUnexpected = options.flags ? options.flags.allowUnexpected : false;
if (flags.help) {
process.stderr.write(getHelp(options));
@ -41,10 +51,19 @@ export async function run(fn: RunFn, options: Options = {}) {
});
try {
if (!allowUnexpected && flags.unexpected.length) {
throw createFlagError(`Unknown flag(s) "${flags.unexpected.join('", "')}"`);
}
await fn({ log, flags });
} catch (error) {
if (isFailError(error)) {
log.error(error.message);
if (error.showHelp) {
log.write(getHelp(options));
}
process.exit(error.exitCode);
} else {
log.error('UNHANDLED ERROR');

View file

@ -104,5 +104,10 @@ run(
log.error(e);
}
}
},
{
flags: {
allowUnexpected: true,
},
}
);

View file

@ -58,5 +58,10 @@ run(
resolve(outputDir, 'en.json'),
outputFormat === 'json5' ? serializeToJson5(sortedMessages) : serializeToJson(sortedMessages)
);
},
{
flags: {
allowUnexpected: true,
},
}
);

View file

@ -87,5 +87,10 @@ run(
config,
log,
});
},
{
flags: {
allowUnexpected: true,
},
}
);

View file

@ -55,5 +55,5 @@ export function lintFiles(log, files) {
}
log.error(sassLint.format(report));
throw createFailError(`[sasslint] ${failTypes.join(' & ')}`, 1);
throw createFailError(`[sasslint] ${failTypes.join(' & ')}`);
}

View file

@ -70,7 +70,7 @@ export async function lintFiles(log: ToolingLog, files: File[]) {
);
if (exitCode > 0) {
throw createFailError(`[tslint] failure`, 1);
throw createFailError(`[tslint] failure`);
} else {
log.success('[tslint/%s] %d files linted successfully', project.name, filesInProject.length);
}

View file

@ -84,7 +84,7 @@ export async function runCheckTsProjectsCli() {
process.exit(1);
},
{
helpDescription:
description:
'Check that all .ts and .tsx files in the repository are assigned to a tsconfig.json file',
}
);